Implement detailed transfers in web UI

This commit is contained in:
RunasSudo 2021-09-11 21:08:36 +10:00
parent 9817d6c199
commit df1b2f7bdc
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
9 changed files with 226 additions and 47 deletions

20
Cargo.lock generated
View File

@ -517,6 +517,15 @@ dependencies = [
"libc", "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]] [[package]]
name = "ibig" name = "ibig"
version = "0.3.2" version = "0.3.2"
@ -637,9 +646,9 @@ checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
[[package]] [[package]]
name = "num-bigint" name = "num-bigint"
version = "0.4.0" version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e0d047c1062aa51e256408c560894e5251f08925980e53cf1aa5bd00eec6512" checksum = "74e768dff5fb39a41b3bcd30bb25cf989706c90d028d1ad71971987aa309d535"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"num-integer", "num-integer",
@ -710,6 +719,7 @@ dependencies = [
"derive_more", "derive_more",
"flate2", "flate2",
"git-version", "git-version",
"html-escape",
"ibig", "ibig",
"itertools", "itertools",
"js-sys", "js-sys",
@ -1124,6 +1134,12 @@ dependencies = [
"arrayvec", "arrayvec",
] ]
[[package]]
name = "utf8-width"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cf7d77f457ef8dfa11e4cd5933c5ddb5dc52a94664071951219a97710f0a32b"
[[package]] [[package]]
name = "vec_map" name = "vec_map"
version = "0.8.2" version = "0.8.2"

View File

@ -14,15 +14,16 @@ git-version = "0.3.4"
ibig = "0.3.2" ibig = "0.3.2"
itertools = "0.10.1" itertools = "0.10.1"
ndarray = "0.15.3" ndarray = "0.15.3"
predicates = "1.0.8"
num-traits = "0.2" num-traits = "0.2"
predicates = "1.0.8"
sha2 = "0.9.5" 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 # Only for WebAssembly - include here for syntax highlighting
#[target.'cfg(target_arch = "wasm32")'.dependencies] #[target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook = "0.1.6" console_error_panic_hook = "0.1.6"
js-sys = "0.3.51" js-sys = "0.3.51"
html-escape = "0.2.9"
num-bigint = "0.4.0" num-bigint = "0.4.0"
num-rational = "0.4.0" num-rational = "0.4.0"
paste = "1.0.5" paste = "1.0.5"

View File

@ -29,6 +29,8 @@ var tblResult = document.getElementById('result');
var divLogs2 = document.getElementById('resultLogs2'); var divLogs2 = document.getElementById('resultLogs2');
var olStageComments; var olStageComments;
var detailedTransfers = {};
var worker = new Worker('worker.js?v=GITVERSION'); var worker = new Worker('worker.js?v=GITVERSION');
worker.onmessage = function(evt) { worker.onmessage = function(evt) {
@ -78,10 +80,13 @@ worker.onmessage = function(evt) {
} else if (evt.data.type === 'updateStageComments') { } else if (evt.data.type === 'updateStageComments') {
let elLi = document.createElement('li'); let elLi = document.createElement('li');
elLi.id = 'stage' + (olStageComments.childElementCount + 1); elLi.id = 'stage' + evt.data.stageNum;
elLi.innerHTML = evt.data.comment; elLi.innerHTML = evt.data.comment;
olStageComments.append(elLi); olStageComments.append(elLi);
} else if (evt.data.type === 'updateDetailedTransfers') {
detailedTransfers[evt.data.stageNum] = evt.data.table;
} else if (evt.data.type === 'finalResultSummary') { } else if (evt.data.type === 'finalResultSummary') {
divLogs2.insertAdjacentHTML('beforeend', evt.data.summary); divLogs2.insertAdjacentHTML('beforeend', evt.data.summary);
document.getElementById('printPane').style.display = 'block'; document.getElementById('printPane').style.display = 'block';
@ -170,6 +175,8 @@ async function clickCount() {
tblResult.innerHTML = ''; tblResult.innerHTML = '';
divLogs2.innerHTML = ''; divLogs2.innerHTML = '';
detailedTransfers = {};
// Dispatch to worker // Dispatch to worker
worker.postMessage({ worker.postMessage({
'type': 'countElection', '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 // Provide a default seed
if (document.getElementById('txtSeed').value === '') { if (document.getElementById('txtSeed').value === '') {
function pad(x) { if (x < 10) { return '0' + x; } return '' + x; } function pad(x) { if (x < 10) { return '0' + x; } return '' + x; }

View File

@ -94,7 +94,7 @@ table {
color-adjust: exact; color-adjust: exact;
-webkit-print-color-adjust: exact; -webkit-print-color-adjust: exact;
} }
.result td { table.result td, table.transfers td {
padding: 0px 8px; padding: 0px 8px;
height: 1em; height: 1em;
} }
@ -127,10 +127,12 @@ td.elected {
tr.info td { tr.info td {
background-color: #f0f5fb; 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; 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; border-bottom: 1px solid #76858c;
} }
.blw { .blw {
@ -138,13 +140,18 @@ tr.info:last-child td, .bb {
border-left: 2px solid #76858c; border-left: 2px solid #76858c;
} }
table.transfers tr:first-child td {
font-weight: 600;
}
/* Table stripes */ /* Table stripes */
tr.stage-no td:nth-child(even):not([rowspan]), tr.stage-no td:nth-child(even):not([rowspan]),
tr.stage-comment td:nth-child(odd), tr.stage-comment td:nth-child(odd),
tr.hint-papers-votes td:nth-child(even), tr.hint-papers-votes td:nth-child(even),
tr.candidate.transfers td:nth-child(even):not(.elected):not(.excluded), 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; background-color: #f9f9f9;
} }
tr.candidate.transfers td.elected:nth-child(even), tr.candidate.transfers td.elected:nth-child(even),
@ -161,33 +168,8 @@ tr.info.votes td:nth-child(odd) {
background-color: #e8eef7; background-color: #e8eef7;
} }
/* BLT input tool */ a.detailedTransfersLink {
color: #aaa;
#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;
} }
/* Print stylesheet */ /* Print stylesheet */

View File

@ -105,7 +105,12 @@ function resumeCount() {
} }
postMessage({'type': 'updateResultsTable', 'result': wasm['update_results_table_' + numbers](stageNum, state, opts, reportStyle)}); 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)}); postMessage({'type': 'updateResultsTable', 'result': wasm['finalise_results_table_' + numbers](state, reportStyle)});

View File

@ -15,9 +15,19 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
// --------------
// Child packages
/// Transfer tables
mod transfers; mod transfers;
pub use transfers::{TransferTable, TransferTableCell, TransferTableColumn}; 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::{ExclusionMethod, STVError, STVOptions, SurplusMethod, SurplusOrder};
use super::sample; use super::sample;

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
use itertools::Itertools;
/// Table
pub struct Table {
/// Rows in the table
rows: Vec<Row>,
}
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#"<table class="transfers">{}</table>"#, self.rows.iter().map(|r| r.to_string()).join(""));
}
}
/// Row in a [Table]
pub struct Row {
/// Cells in the row
cells: Vec<Cell>,
}
impl Row {
/// Return a new [Row]
pub fn new(cells: Vec<Cell>) -> Self {
Self {
cells
}
}
/// Render the row as HTML
fn to_string(&self) -> String {
return format!(r#"<tr>{}</tr>"#, 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#"<{}>{}</td>"#, self.attrs.join(" "), html_escape::encode_text(&self.content));
}
}

View File

@ -15,7 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
#[cfg(not(target_arch = "wasm32"))]
use prettytable::{Cell, Row, Table}; use prettytable::{Cell, Row, Table};
#[cfg(target_arch = "wasm32")]
use super::prettytable_html::{Cell, Row, Table};
use crate::election::{Candidate, CountState}; use crate::election::{Candidate, CountState};
use crate::numbers::Number; use crate::numbers::Number;
@ -287,10 +290,10 @@ impl<'e, N: Number> TransferTable<'e, N> {
return checksum; return checksum;
} }
/// Render table as plain text /// Render table as [Table]
pub fn render_text(&self, state: &CountState<N>, opts: &STVOptions) -> String { fn render(&self, state: &CountState<N>, opts: &STVOptions) -> Table {
let mut table = Table::new(); 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; 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)); 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<N>, opts: &STVOptions) -> String {
return self.render(state, opts).to_string();
} }
/// Render table as HTML /// Render table as HTML
pub fn render_html(&self) -> String { pub fn render_html(&self, state: &CountState<N>, opts: &STVOptions) -> String {
todo!(); return self.render(state, opts).to_string();
} }
} }
@ -497,3 +506,13 @@ pub struct TransferTableCell<N: Number> {
/// Votes transferred to the continuing candidate /// Votes transferred to the continuing candidate
pub votes_out: N, 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
}

View File

@ -141,8 +141,8 @@ macro_rules! impl_type {
/// Wrapper for [update_stage_comments] /// Wrapper for [update_stage_comments]
#[wasm_bindgen] #[wasm_bindgen]
#[allow(non_snake_case)] #[allow(non_snake_case)]
pub fn [<update_stage_comments_$type>](state: &[<CountState$type>]) -> String { pub fn [<update_stage_comments_$type>](state: &[<CountState$type>], stage_num: usize) -> String {
return update_stage_comments(&state.0); return update_stage_comments(&state.0, stage_num);
} }
/// Wrapper for [finalise_results_table] /// Wrapper for [finalise_results_table]
@ -173,6 +173,14 @@ macro_rules! impl_type {
pub fn new(election: &[<Election$type>]) -> Self { pub fn new(election: &[<Election$type>]) -> Self {
return [<CountState$type>](CountState::new(election.as_static())); return [<CountState$type>](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<String> {
return match &self.0.transfer_table {
Some(tt) => Some(tt.render_html(&self.0, &opts.0)), // TODO
None => None,
};
}
} }
/// Wrapper for [Election] /// Wrapper for [Election]
@ -613,8 +621,12 @@ fn update_results_table<N: Number>(stage_num: usize, state: &CountState<N>, opts
} }
/// Get the comment for the current stage /// Get the comment for the current stage
fn update_stage_comments<N: Number>(state: &CountState<N>) -> String { fn update_stage_comments<N: Number>(state: &CountState<N>, stage_num: usize) -> String {
return state.logger.render().join(" "); let mut comments = state.logger.render().join(" ");
if let Some(_) = state.transfer_table {
comments.push_str(&format!(r##" <a href="#" class="detailedTransfersLink" onclick="viewDetailedTransfers({});return false;">[View detailed transfers]</a>"##, stage_num));
}
return comments;
} }
/// Generate the final column of the HTML results table /// Generate the final column of the HTML results table