From df1b2f7bdc0b93e637ffb512952f7799f16c072e Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Sat, 11 Sep 2021 21:08:36 +1000 Subject: [PATCH] Implement detailed transfers in web UI --- Cargo.lock | 20 +++++- Cargo.toml | 5 +- html/index.js | 29 +++++++- html/main.css | 44 ++++-------- html/worker.js | 7 +- src/stv/gregory/mod.rs | 10 +++ src/stv/gregory/prettytable_html.rs | 107 ++++++++++++++++++++++++++++ src/stv/gregory/transfers.rs | 31 ++++++-- src/stv/wasm.rs | 20 ++++-- 9 files changed, 226 insertions(+), 47 deletions(-) create mode 100644 src/stv/gregory/prettytable_html.rs diff --git a/Cargo.lock b/Cargo.lock index cb324ce..8dcd747 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -517,6 +517,15 @@ dependencies = [ "libc", ] +[[package]] +name = "html-escape" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "816ea801a95538fc5f53c836697b3f8b64a9d664c4f0b91efe1fe7c92e4dbcb7" +dependencies = [ + "utf8-width", +] + [[package]] name = "ibig" version = "0.3.2" @@ -637,9 +646,9 @@ checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" [[package]] name = "num-bigint" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e0d047c1062aa51e256408c560894e5251f08925980e53cf1aa5bd00eec6512" +checksum = "74e768dff5fb39a41b3bcd30bb25cf989706c90d028d1ad71971987aa309d535" dependencies = [ "autocfg", "num-integer", @@ -710,6 +719,7 @@ dependencies = [ "derive_more", "flate2", "git-version", + "html-escape", "ibig", "itertools", "js-sys", @@ -1124,6 +1134,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "utf8-width" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cf7d77f457ef8dfa11e4cd5933c5ddb5dc52a94664071951219a97710f0a32b" + [[package]] name = "vec_map" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index c22cbea..043fc76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,15 +14,16 @@ git-version = "0.3.4" ibig = "0.3.2" itertools = "0.10.1" ndarray = "0.15.3" -predicates = "1.0.8" num-traits = "0.2" +predicates = "1.0.8" sha2 = "0.9.5" -wasm-bindgen = "0.2.74" +wasm-bindgen = "=0.2.74" # 0.2.77 causes "remaining data" error # Only for WebAssembly - include here for syntax highlighting #[target.'cfg(target_arch = "wasm32")'.dependencies] console_error_panic_hook = "0.1.6" js-sys = "0.3.51" +html-escape = "0.2.9" num-bigint = "0.4.0" num-rational = "0.4.0" paste = "1.0.5" diff --git a/html/index.js b/html/index.js index a4e17e6..ad39b40 100644 --- a/html/index.js +++ b/html/index.js @@ -29,6 +29,8 @@ var tblResult = document.getElementById('result'); var divLogs2 = document.getElementById('resultLogs2'); var olStageComments; +var detailedTransfers = {}; + var worker = new Worker('worker.js?v=GITVERSION'); worker.onmessage = function(evt) { @@ -78,10 +80,13 @@ worker.onmessage = function(evt) { } else if (evt.data.type === 'updateStageComments') { let elLi = document.createElement('li'); - elLi.id = 'stage' + (olStageComments.childElementCount + 1); + elLi.id = 'stage' + evt.data.stageNum; elLi.innerHTML = evt.data.comment; olStageComments.append(elLi); + } else if (evt.data.type === 'updateDetailedTransfers') { + detailedTransfers[evt.data.stageNum] = evt.data.table; + } else if (evt.data.type === 'finalResultSummary') { divLogs2.insertAdjacentHTML('beforeend', evt.data.summary); document.getElementById('printPane').style.display = 'block'; @@ -170,6 +175,8 @@ async function clickCount() { tblResult.innerHTML = ''; divLogs2.innerHTML = ''; + detailedTransfers = {}; + // Dispatch to worker worker.postMessage({ 'type': 'countElection', @@ -187,6 +194,26 @@ async function clickCount() { }); } +function viewDetailedTransfers(stageNum) { + let wtransfers = window.open('', '', 'location=0,width=800,height=600'); + wtransfers.document.title = 'OpenTally Detailed Transfers: Stage ' + stageNum; + + // Add stylesheets + for (let elCSSBase of document.querySelectorAll('head link')) { + let elCSS = wtransfers.document.createElement('link'); + elCSS.rel = elCSSBase.rel; + elCSS.type = elCSSBase.type; + if (elCSSBase.href.endsWith('?v=GITVERSION')) { + elCSS.href = elCSSBase.href.replace('?v=GITVERSION', '?v=' + Math.random()); + } else { + elCSS.href = elCSSBase.href; + } + wtransfers.document.head.appendChild(elCSS); + } + + wtransfers.document.body.innerHTML = detailedTransfers[stageNum]; +} + // Provide a default seed if (document.getElementById('txtSeed').value === '') { function pad(x) { if (x < 10) { return '0' + x; } return '' + x; } diff --git a/html/main.css b/html/main.css index 8acc577..b8e0914 100644 --- a/html/main.css +++ b/html/main.css @@ -94,7 +94,7 @@ table { color-adjust: exact; -webkit-print-color-adjust: exact; } -.result td { +table.result td, table.transfers td { padding: 0px 8px; height: 1em; } @@ -127,10 +127,12 @@ td.elected { tr.info td { background-color: #f0f5fb; } -tr.stage-no td:not(:empty), tr.hint-papers-votes td:not(:empty), tr.transfers td { +tr.stage-no td:not(:empty), tr.hint-papers-votes td:not(:empty), tr.transfers td, +table.transfers tr:first-child td, table.transfers tr:nth-last-child(2) td, table.transfers tr:last-child td { border-top: 1px solid #76858c; } -tr.info:last-child td, .bb { +tr.info:last-child td, .bb, +table.transfers tr:first-child td, table.transfers tr:nth-last-child(2) td, table.transfers tr:last-child td { border-bottom: 1px solid #76858c; } .blw { @@ -138,13 +140,18 @@ tr.info:last-child td, .bb { border-left: 2px solid #76858c; } +table.transfers tr:first-child td { + font-weight: 600; +} + /* Table stripes */ tr.stage-no td:nth-child(even):not([rowspan]), tr.stage-comment td:nth-child(odd), tr.hint-papers-votes td:nth-child(even), tr.candidate.transfers td:nth-child(even):not(.elected):not(.excluded), -tr.candidate.votes td:nth-child(odd):not(.elected):not(.excluded) { +tr.candidate.votes td:nth-child(odd):not(.elected):not(.excluded), +table.transfers td:nth-child(even) { background-color: #f9f9f9; } tr.candidate.transfers td.elected:nth-child(even), @@ -161,33 +168,8 @@ tr.info.votes td:nth-child(odd) { background-color: #e8eef7; } -/* BLT input tool */ - -#selBallots { - min-width: 10em; - margin-right: 1em; -} - -#bltMain { - display: flex; -} - -#tblBallot { - margin-top: 0.5em; - margin-bottom: 0.5em; -} - -#tblBallot input { - margin-right: 0.5ex; -} - -#divEditCandidates div { - margin-bottom: 0.5em; -} - -#txtCandidates { - min-width: 20em; - min-height: 10em; +a.detailedTransfersLink { + color: #aaa; } /* Print stylesheet */ diff --git a/html/worker.js b/html/worker.js index f469684..95a96eb 100644 --- a/html/worker.js +++ b/html/worker.js @@ -105,7 +105,12 @@ function resumeCount() { } postMessage({'type': 'updateResultsTable', 'result': wasm['update_results_table_' + numbers](stageNum, state, opts, reportStyle)}); - postMessage({'type': 'updateStageComments', 'comment': wasm['update_stage_comments_' + numbers](state)}); + postMessage({'type': 'updateStageComments', 'comment': wasm['update_stage_comments_' + numbers](state, stageNum), 'stageNum': stageNum}); + + let transfers_table = state.transfer_table_render_html(opts); + if (transfers_table) { + postMessage({'type': 'updateDetailedTransfers', 'table': transfers_table, 'stageNum': stageNum}); + } } postMessage({'type': 'updateResultsTable', 'result': wasm['finalise_results_table_' + numbers](state, reportStyle)}); diff --git a/src/stv/gregory/mod.rs b/src/stv/gregory/mod.rs index 47ffa35..46724df 100644 --- a/src/stv/gregory/mod.rs +++ b/src/stv/gregory/mod.rs @@ -15,9 +15,19 @@ * along with this program. If not, see . */ +// -------------- +// Child packages + +/// Transfer tables mod transfers; pub use transfers::{TransferTable, TransferTableCell, TransferTableColumn}; +/// prettytable-compatible API for HTML table output in WebAssembly +pub mod prettytable_html; + +// -------- +// STV code + use super::{ExclusionMethod, STVError, STVOptions, SurplusMethod, SurplusOrder}; use super::sample; diff --git a/src/stv/gregory/prettytable_html.rs b/src/stv/gregory/prettytable_html.rs new file mode 100644 index 0000000..14271e4 --- /dev/null +++ b/src/stv/gregory/prettytable_html.rs @@ -0,0 +1,107 @@ +/* OpenTally: Open-source election vote counting + * Copyright © 2021 Lee Yingtong Li (RunasSudo) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +use itertools::Itertools; + +/// Table +pub struct Table { + /// Rows in the table + rows: Vec, +} + +impl Table { + /// Return a new [Table] + pub fn new() -> Self { + Self { + rows: Vec::new(), + } + } + + /// Add a [Row] to the table + pub fn add_row(&mut self, row: Row) { + self.rows.push(row); + } + + /// Alias for [add_row] + pub fn set_titles(&mut self, row: Row) { + self.add_row(row); + } + + /// Render the table as HTML + pub fn to_string(&self) -> String { + return format!(r#"{}
"#, self.rows.iter().map(|r| r.to_string()).join("")); + } +} + +/// Row in a [Table] +pub struct Row { + /// Cells in the row + cells: Vec, +} + +impl Row { + /// Return a new [Row] + pub fn new(cells: Vec) -> Self { + Self { + cells + } + } + + /// Render the row as HTML + fn to_string(&self) -> String { + return format!(r#"{}"#, self.cells.iter().map(|c| c.to_string()).join("")); + } +} + +/// Cell in a [Row] +pub struct Cell { + /// Content of the cell + content: String, + /// HTML tag/attributes + attrs: Vec<&'static str>, +} + +impl Cell { + /// Return a new [Cell] + pub fn new(content: &str) -> Self { + Self { + content: String::from(content), + attrs: vec!["td"], + } + } + + /// Apply a style to the cell + #[allow(unused_mut)] + pub fn style_spec(mut self, spec: &str) -> Self { + if spec.contains("H2") { + self.attrs.push(r#"colspan="2""#); + } + if spec.contains("c") { + self.attrs.push(r#"style="text-align:center""#); + } + if spec.contains("r") { + self.attrs.push(r#"style="text-align:right""#); + } + + return self; + } + + /// Render the cell as HTML + fn to_string(&self) -> String { + return format!(r#"<{}>{}"#, self.attrs.join(" "), html_escape::encode_text(&self.content)); + } +} diff --git a/src/stv/gregory/transfers.rs b/src/stv/gregory/transfers.rs index ed2240e..2ce1446 100644 --- a/src/stv/gregory/transfers.rs +++ b/src/stv/gregory/transfers.rs @@ -15,7 +15,10 @@ * along with this program. If not, see . */ +#[cfg(not(target_arch = "wasm32"))] use prettytable::{Cell, Row, Table}; +#[cfg(target_arch = "wasm32")] +use super::prettytable_html::{Cell, Row, Table}; use crate::election::{Candidate, CountState}; use crate::numbers::Number; @@ -287,10 +290,10 @@ impl<'e, N: Number> TransferTable<'e, N> { return checksum; } - /// Render table as plain text - pub fn render_text(&self, state: &CountState, opts: &STVOptions) -> String { + /// Render table as [Table] + fn render(&self, state: &CountState, opts: &STVOptions) -> Table { let mut table = Table::new(); - table.set_format(*prettytable::format::consts::FORMAT_NO_LINESEP_WITH_TITLE); + set_table_format(&mut table); let show_transfers_per_ballot = !self.surpfrac.is_none() && opts.sum_surplus_transfers == SumSurplusTransfersMode::PerBallot; @@ -448,12 +451,18 @@ impl<'e, N: Number> TransferTable<'e, N> { table.add_row(Row::new(row)); - return table.to_string(); + return table; + } + + /// Render table as plain text + #[cfg(not(target_arch = "wasm32"))] + pub fn render_text(&self, state: &CountState, opts: &STVOptions) -> String { + return self.render(state, opts).to_string(); } /// Render table as HTML - pub fn render_html(&self) -> String { - todo!(); + pub fn render_html(&self, state: &CountState, opts: &STVOptions) -> String { + return self.render(state, opts).to_string(); } } @@ -497,3 +506,13 @@ pub struct TransferTableCell { /// Votes transferred to the continuing candidate pub votes_out: N, } + +#[cfg(not(target_arch = "wasm32"))] +fn set_table_format(table: &mut Table) { + table.set_format(*prettytable::format::consts::FORMAT_NO_LINESEP_WITH_TITLE); +} + +#[cfg(target_arch = "wasm32")] +fn set_table_format(_table: &mut Table) { + // No op +} diff --git a/src/stv/wasm.rs b/src/stv/wasm.rs index 8d182b2..a5a3814 100644 --- a/src/stv/wasm.rs +++ b/src/stv/wasm.rs @@ -141,8 +141,8 @@ macro_rules! impl_type { /// Wrapper for [update_stage_comments] #[wasm_bindgen] #[allow(non_snake_case)] - pub fn [](state: &[]) -> String { - return update_stage_comments(&state.0); + pub fn [](state: &[], stage_num: usize) -> String { + return update_stage_comments(&state.0, stage_num); } /// Wrapper for [finalise_results_table] @@ -173,6 +173,14 @@ macro_rules! impl_type { pub fn new(election: &[]) -> Self { return [](CountState::new(election.as_static())); } + + /// Call [render_html](crate::stv::transfers::TransferTable::render_html) on [CountState::transfer_table] + pub fn transfer_table_render_html(&self, opts: &STVOptions) -> Option { + return match &self.0.transfer_table { + Some(tt) => Some(tt.render_html(&self.0, &opts.0)), // TODO + None => None, + }; + } } /// Wrapper for [Election] @@ -613,8 +621,12 @@ fn update_results_table(stage_num: usize, state: &CountState, opts } /// Get the comment for the current stage -fn update_stage_comments(state: &CountState) -> String { - return state.logger.render().join(" "); +fn update_stage_comments(state: &CountState, stage_num: usize) -> String { + let mut comments = state.logger.render().join(" "); + if let Some(_) = state.transfer_table { + comments.push_str(&format!(r##" [View detailed transfers]"##, stage_num)); + } + return comments; } /// Generate the final column of the HTML results table