From 815055d6e6c0404712921dae0d7d5462a6e78581 Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Fri, 26 Aug 2022 02:27:25 +1000 Subject: [PATCH] Initial implementation of HTML output on CLI --- src/cli/stv.rs | 144 +++++++++++++++- src/stv/html.rs | 449 ++++++++++++++++++++++++++++++++++++++++++++++++ src/stv/mod.rs | 2 + src/stv/wasm.rs | 420 ++------------------------------------------ 4 files changed, 606 insertions(+), 409 deletions(-) create mode 100644 src/stv/html.rs diff --git a/src/cli/stv.rs b/src/cli/stv.rs index 8b10157..a306224 100644 --- a/src/cli/stv.rs +++ b/src/cli/stv.rs @@ -178,7 +178,7 @@ pub struct SubcmdOptions { // --------------------- // -- Output settings -- - #[clap(help_heading=Some("OUTPUT"), short, long, possible_values=&["text", "csv"], default_value="text")] + #[clap(help_heading=Some("OUTPUT"), short, long, possible_values=&["text", "csv", "html"], default_value="text")] output: String, /// Hide excluded candidates from results report @@ -327,6 +327,7 @@ where match cmd_opts.output.as_str() { "text" => { return count_election_text(election, &cmd_opts.filename, opts); } "csv" => { return count_election_csv(election, opts); } + "html" => { return count_election_html(election, &cmd_opts.filename, opts); } _ => unreachable!() } } @@ -343,6 +344,7 @@ where for<'r> &'r N: ops::Neg { // Describe count + // TODO: Can we precompute total_ballots? let total_ballots = election.ballots.iter().fold(N::new(), |mut acc, b| { acc += &b.orig_value; acc }); print!("Count computed by OpenTally (revision {}). Read {:.0} ballots from \"{}\" for election \"{}\". There are {} candidates for {} vacancies. ", crate::VERSION, total_ballots, filename, election.name, election.candidates.iter().filter(|c| !c.is_dummy).count(), election.seats); let opts_str = opts.describe::(); @@ -622,3 +624,143 @@ where return Ok(()); } + +// ----------------------------------- +// HTML report in the style of wasm UI + +fn count_election_html(mut election: Election, filename: &str, opts: STVOptions) -> Result<(), i32> +where + for<'r> &'r N: ops::Add<&'r N, Output=N>, + for<'r> &'r N: ops::Sub<&'r N, Output=N>, + for<'r> &'r N: ops::Mul<&'r N, Output=N>, + for<'r> &'r N: ops::Div<&'r N, Output=N>, + for<'r> &'r N: ops::Neg +{ + // HTML preamble, etc. + print!(r#" + + + + OpenTally Results + + + + +
+
"#); + + // Describe count + println!(r#"{}
"#, stv::html::describe_count(filename, &election, &opts)); + + stv::preprocess_election(&mut election, &opts); + + // Initialise count state + let mut state = CountState::new(&election); + + // TODO: Enable report_style to be customised + let mut result_rows = stv::html::init_results_table(&election, &opts, "votes_transposed"); + + let mut stage_comments = Vec::new(); + + // ----------- + // First stage + + // Distribute first preferences + match stv::count_init(&mut state, &opts) { + Ok(_) => {} + Err(err) => { + println!("Error: {}", err.describe()); + return Err(1); + } + } + + let stage_result = stv::html::update_results_table(1, &state, &opts, "votes_transposed"); + for (row, cell) in stage_result.into_iter().enumerate() { + // 5 characters from end to insert before "" + let idx = result_rows[row].len() - 5; + result_rows[row].insert_str(idx, &cell); + } + + stage_comments.push(state.logger.render().join(" ")); + + // ----------------- + // Subsequent stages + + let mut stage_num = 1; + loop { + match stv::count_one_stage(&mut state, &opts) { + Ok(is_done) => { + if is_done { + break; + } + } + Err(err) => { + println!("Error: {}", err.describe()); + return Err(1); + } + } + + stage_num += 1; + + let stage_result = stv::html::update_results_table(stage_num, &state, &opts, "votes_transposed"); + for (row, cell) in stage_result.into_iter().enumerate() { + // 5 characters from end to insert before "" + let idx = result_rows[row].len() - 5; + result_rows[row].insert_str(idx, &cell); + } + + stage_comments.push(state.logger.render().join(" ")); + } + + // ---------------- + // Candidate states + + for (row, cell) in stv::html::finalise_results_table(&state, "votes_transposed").into_iter().enumerate() { + // 5 characters from end to insert before "" + let idx = result_rows[row].len() - 5; + result_rows[row].insert_str(idx, &cell); + } + + // -------------------- + // Output table to HTML + + println!(r#""#); + for row in result_rows { + println!("{}", row); + } + println!("
"); + + // -------------------- + // Print stage comments + + println!(r#"

Stage comments:

    "#); + for comment in stage_comments { + println!("
  1. {}
  2. ", comment); + } + println!("
"); + + // ------------- + // Print summary + + println!("

Count complete. The winning candidates are, in order of election:

    "); + + let mut winners = Vec::new(); + for (candidate, count_card) in state.candidates.iter() { + if count_card.state == CandidateState::Elected { + winners.push((candidate, count_card)); + } + } + winners.sort_unstable_by(|a, b| a.1.order_elected.cmp(&b.1.order_elected)); + + for (_i, (winner, count_card)) in winners.into_iter().enumerate() { + if let Some(kv) = &count_card.keep_value { + println!("
  1. {} (kv = {:.dps2$})
  2. ", winner.name, kv, dps2=max(opts.pp_decimals, 2)); + } else { + println!("
  3. {}
  4. ", winner.name); + } + } + + println!("
"); + + return Ok(()); +} diff --git a/src/stv/html.rs b/src/stv/html.rs new file mode 100644 index 0000000..0c50f18 --- /dev/null +++ b/src/stv/html.rs @@ -0,0 +1,449 @@ +/* OpenTally: Open-source election vote counting + * Copyright © 2021–2022 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 crate::election::{CandidateState, Election, StageKind}; +use crate::numbers::Number; +use crate::stv::{self, CountState}; + +use itertools::Itertools; + +/// Generate the lead-in description of the count in HTML +pub fn describe_count(filename: &str, election: &Election, opts: &stv::STVOptions) -> String { + let mut result = String::from("

Count computed by OpenTally (revision "); + result.push_str(crate::VERSION); + let total_ballots = election.ballots.iter().fold(N::new(), |mut acc, b| { acc += &b.orig_value; acc }); + result.push_str(&format!(r#"). Read {:.0} ballots from ‘{}’ for election ‘{}’. There are {} candidates for {} vacancies. "#, total_ballots, filename, election.name, election.candidates.iter().filter(|c| !c.is_dummy).count(), election.seats)); + + let opts_str = opts.describe::(); + if !opts_str.is_empty() { + result.push_str(&format!(r#"Counting using options {}.

"#, opts_str)) + } else { + result.push_str(r#"Counting using default options.

"#); + } + + return result; +} + +/// Generate the first column of the HTML results table +pub fn init_results_table(election: &Election, opts: &stv::STVOptions, report_style: &str) -> Vec { + let mut result = Vec::new(); + + result.push(String::from(r#""#)); + result.push(String::from(r#""#)); + result.push(String::from(r#""#)); + + if report_style == "ballots_votes" { + result.push(String::from(r#""#)); + } + + for candidate in election.candidates.iter() { + if candidate.is_dummy { + continue; + } + + if report_style == "votes_transposed" { + result.push(format!(r#"{}"#, candidate.name)); + } else { + result.push(format!(r#"{}"#, candidate.name)); + result.push(String::from(r#""#)); + } + } + + if report_style == "votes_transposed" { + result.push(String::from(r#"Exhausted"#)); + } else { + result.push(String::from(r#"Exhausted"#)); + result.push(String::from(r#""#)); + } + + if report_style == "votes_transposed" { + result.push(String::from(r#"Loss by fraction"#)); + result.push(String::from(r#"Total"#)); + result.push(String::from(r#"Quota"#)); + } else { + result.push(String::from(r#"Loss by fraction"#)); + result.push(String::from(r#""#)); + result.push(String::from(r#"Total"#)); + result.push(String::from(r#"Quota"#)); + } + + if stv::should_show_vre(opts) { + result.push(String::from(r#"Vote required for election"#)); + } + + return result; +} + +/// Generate subsequent columns of the HTML results table +pub fn update_results_table(stage_num: usize, state: &CountState, opts: &stv::STVOptions, report_style: &str) -> Vec { + let mut result = Vec::new(); + + // Insert borders to left of new exclusions if reset-and-reiterate method applied + let classes_o; // Outer version + let classes_i; // Inner version + if (opts.exclusion == stv::ExclusionMethod::ResetAndReiterate && matches!(state.title, StageKind::ExclusionOf(_))) || matches!(state.title, StageKind::Rollback) { + classes_o = r#" class="blw""#; + classes_i = r#"blw "#; + } else { + classes_o = ""; + classes_i = ""; + } + + // Hide transfers column for first preferences if transposed + let hide_xfers_trsp; + if let StageKind::FirstPreferences = state.title { + hide_xfers_trsp = true; + } else if opts.exclusion == stv::ExclusionMethod::ResetAndReiterate && matches!(state.title, StageKind::ExclusionOf(_)) { + hide_xfers_trsp = true; + } else if let StageKind::Rollback = state.title { + hide_xfers_trsp = true; + } else if let StageKind::BulkElection = state.title { + hide_xfers_trsp = true; + } else if state.candidates.values().all(|cc| cc.transfers.is_zero()) && state.exhausted.transfers.is_zero() && state.loss_fraction.transfers.is_zero() { + hide_xfers_trsp = true; + } else { + hide_xfers_trsp = false; + } + + // Header rows + let kind_str = state.title.kind_as_string(); + let title_str; + match &state.title { + StageKind::FirstPreferences | StageKind::Rollback | StageKind::RollbackExhausted | StageKind::SurplusesDistributed | StageKind::BulkElection => { + title_str = format!("{}", state.title); + } + StageKind::SurplusOf(candidate) => { + title_str = candidate.name.clone(); + } + StageKind::ExclusionOf(candidates) => { + if candidates.len() > 5 { + let first_4_cands = candidates.iter().map(|c| &c.name).sorted().take(4).join(",
"); + title_str = format!("{},
and {} others", first_4_cands, candidates.len() - 4); + } else { + title_str = candidates.iter().map(|c| &c.name).join(",
"); + } + } + StageKind::BallotsOf(candidate) => { + title_str = candidate.name.clone(); + } + }; + + match report_style { + "votes" => { + result.push(format!(r##"{1}"##, classes_o, stage_num)); + result.push(format!(r#"{}"#, classes_o, kind_str)); + result.push(format!(r#"{}"#, classes_o, title_str)); + } + "votes_transposed" => { + if hide_xfers_trsp { + result.push(format!(r##"{1}"##, classes_o, stage_num)); + result.push(format!(r#"{}"#, classes_o, kind_str)); + result.push(format!(r#"{}"#, classes_o, title_str)); + } else { + result.push(format!(r##"{1}"##, classes_o, stage_num)); + result.push(format!(r#"{}"#, classes_o, kind_str)); + result.push(format!(r#"{}"#, classes_o, title_str)); + //result.push(format!(r#"X'fersTotal"#, tdclasses1)); + } + } + "ballots_votes" => { + result.push(format!(r##"{1}"##, classes_o, stage_num)); + result.push(format!(r#"{}"#, classes_o, kind_str)); + result.push(format!(r#"{}"#, classes_o, title_str)); + result.push(format!(r#"BallotsVotes"#, classes_o)); + } + _ => unreachable!("Invalid report_style") + } + + for candidate in state.election.candidates.iter() { + if candidate.is_dummy { + continue; + } + + let count_card = &state.candidates[candidate]; + + // TODO: REFACTOR THIS!! + + match report_style { + "votes" => { + match count_card.state { + CandidateState::Hopeful | CandidateState::Guarded => { + result.push(format!(r#"{}"#, classes_i, pps(&count_card.transfers, opts.pp_decimals))); + result.push(format!(r#"{}"#, classes_i, pp(&count_card.votes, opts.pp_decimals))); + } + CandidateState::Elected => { + result.push(format!(r#"{}"#, classes_i, pps(&count_card.transfers, opts.pp_decimals))); + result.push(format!(r#"{}"#, classes_i, pp(&count_card.votes, opts.pp_decimals))); + } + CandidateState::Doomed => { + result.push(format!(r#"{}"#, classes_i, pps(&count_card.transfers, opts.pp_decimals))); + result.push(format!(r#"{}"#, classes_i, pp(&count_card.votes, opts.pp_decimals))); + } + CandidateState::Withdrawn => { + result.push(format!(r#""#, classes_i)); + result.push(format!(r#"WD"#, classes_i)); + } + CandidateState::Excluded => { + result.push(format!(r#"{}"#, classes_i, pps(&count_card.transfers, opts.pp_decimals))); + if count_card.votes.is_zero() && count_card.parcels.iter().all(|p| p.votes.is_empty()) { + result.push(format!(r#"Ex"#, classes_i)); + } else { + result.push(format!(r#"{}"#, classes_i, pp(&count_card.votes, opts.pp_decimals))); + } + } + } + } + "votes_transposed" => { + match count_card.state { + CandidateState::Hopeful | CandidateState::Guarded => { + if hide_xfers_trsp { + result.push(format!(r#"{}"#, classes_i, pp(&count_card.votes, opts.pp_decimals))); + } else { + result.push(format!(r#"{}{}"#, classes_i, pps(&count_card.transfers, opts.pp_decimals), pp(&count_card.votes, opts.pp_decimals))); + } + } + CandidateState::Elected => { + if hide_xfers_trsp { + result.push(format!(r#"{}"#, classes_i, pp(&count_card.votes, opts.pp_decimals))); + } else { + result.push(format!(r#"{}{}"#, classes_i, pps(&count_card.transfers, opts.pp_decimals), pp(&count_card.votes, opts.pp_decimals))); + } + } + CandidateState::Doomed => { + if hide_xfers_trsp { + result.push(format!(r#"{}"#, classes_i, pp(&count_card.votes, opts.pp_decimals))); + } else { + result.push(format!(r#"{}{}"#, classes_i, pps(&count_card.transfers, opts.pp_decimals), pp(&count_card.votes, opts.pp_decimals))); + } + } + CandidateState::Withdrawn => { + if hide_xfers_trsp { + result.push(format!(r#"WD"#, classes_i)); + } else { + result.push(format!(r#"WD"#, classes_i)); + } + } + CandidateState::Excluded => { + if count_card.votes.is_zero() && count_card.parcels.iter().all(|p| p.votes.is_empty()) { + if hide_xfers_trsp { + result.push(format!(r#"Ex"#, classes_i)); + } else { + result.push(format!(r#"{}Ex"#, classes_i, pps(&count_card.transfers, opts.pp_decimals))); + } + } else { + if hide_xfers_trsp { + result.push(format!(r#"{}"#, classes_i, pp(&count_card.votes, opts.pp_decimals))); + } else { + result.push(format!(r#"{}{}"#, classes_i, pps(&count_card.transfers, opts.pp_decimals), pp(&count_card.votes, opts.pp_decimals))); + } + } + } + } + } + "ballots_votes" => { + match count_card.state { + CandidateState::Hopeful | CandidateState::Guarded => { + result.push(format!(r#"{}{}"#, classes_i, pps(&count_card.ballot_transfers, 0), pps(&count_card.transfers, opts.pp_decimals))); + result.push(format!(r#"{}{}"#, classes_i, pp(&count_card.num_ballots(), 0), pp(&count_card.votes, opts.pp_decimals))); + } + CandidateState::Elected => { + result.push(format!(r#"{}{}"#, classes_i, pps(&count_card.ballot_transfers, 0), pps(&count_card.transfers, opts.pp_decimals))); + result.push(format!(r#"{}{}"#, classes_i, pp(&count_card.num_ballots(), 0), pp(&count_card.votes, opts.pp_decimals))); + } + CandidateState::Doomed => { + result.push(format!(r#"{}{}"#, classes_i, pps(&count_card.ballot_transfers, 0), pps(&count_card.transfers, opts.pp_decimals))); + result.push(format!(r#"{}{}"#, classes_i, pp(&count_card.num_ballots(), 0), pp(&count_card.votes, opts.pp_decimals))); + } + CandidateState::Withdrawn => { + result.push(format!(r#""#, classes_i)); + result.push(format!(r#"WD"#, classes_i)); + } + CandidateState::Excluded => { + result.push(format!(r#"{}{}"#, classes_i, pps(&count_card.ballot_transfers, 0), pps(&count_card.transfers, opts.pp_decimals))); + if count_card.votes.is_zero() && count_card.parcels.iter().all(|p| p.votes.is_empty()) { + result.push(format!(r#"Ex"#, classes_i)); + } else { + result.push(format!(r#"{}{}"#, classes_i, pp(&count_card.num_ballots(), 0), pp(&count_card.votes, opts.pp_decimals))); + } + } + } + } + _ => unreachable!("Invalid report_style") + } + } + + match report_style { + "votes" => { + result.push(format!(r#"{}"#, classes_i, pps(&state.exhausted.transfers, opts.pp_decimals))); + result.push(format!(r#"{}"#, classes_i, pp(&state.exhausted.votes, opts.pp_decimals))); + result.push(format!(r#"{}"#, classes_i, pps(&state.loss_fraction.transfers, opts.pp_decimals))); + result.push(format!(r#"{}"#, classes_i, pp(&state.loss_fraction.votes, opts.pp_decimals))); + } + "votes_transposed" => { + if hide_xfers_trsp { + result.push(format!(r#"{}"#, classes_i, pp(&state.exhausted.votes, opts.pp_decimals))); + result.push(format!(r#"{}"#, classes_i, pp(&state.loss_fraction.votes, opts.pp_decimals))); + } else { + result.push(format!(r#"{}{}"#, classes_i, pps(&state.exhausted.transfers, opts.pp_decimals), pp(&state.exhausted.votes, opts.pp_decimals))); + result.push(format!(r#"{}{}"#, classes_i, pps(&state.loss_fraction.transfers, opts.pp_decimals), pp(&state.loss_fraction.votes, opts.pp_decimals))); + } + } + "ballots_votes" => { + result.push(format!(r#"{}{}"#, classes_i, pps(&state.exhausted.ballot_transfers, 0), pps(&state.exhausted.transfers, opts.pp_decimals))); + result.push(format!(r#"{}{}"#, classes_i, pp(&state.exhausted.num_ballots(), 0), pp(&state.exhausted.votes, opts.pp_decimals))); + result.push(format!(r#"{}"#, classes_i, pps(&state.loss_fraction.transfers, opts.pp_decimals))); + result.push(format!(r#"{}"#, classes_i, pp(&state.loss_fraction.votes, opts.pp_decimals))); + } + _ => unreachable!("Invalid report_style") + } + + // Calculate total votes + let mut total_vote = state.candidates.iter().filter_map(|(c, cc)| if c.is_dummy { None } else { Some(cc) }).fold(N::new(), |mut acc, cc| { acc += &cc.votes; acc }); + total_vote += &state.exhausted.votes; + total_vote += &state.loss_fraction.votes; + + match report_style { + "votes" => { + result.push(format!(r#"{}"#, classes_i, pp(&total_vote, opts.pp_decimals))); + } + "votes_transposed" => { + if hide_xfers_trsp { + result.push(format!(r#"{}"#, classes_i, pp(&total_vote, opts.pp_decimals))); + } else { + result.push(format!(r#"{}"#, classes_i, pp(&total_vote, opts.pp_decimals))); + } + } + "ballots_votes" => { + // Calculate total ballots + let mut total_ballots = state.candidates.values().fold(N::new(), |mut acc, cc| { acc += cc.num_ballots(); acc }); + total_ballots += state.exhausted.num_ballots(); + result.push(format!(r#"{}{}"#, classes_i, pp(&total_ballots, 0), pp(&total_vote, opts.pp_decimals))); + } + _ => unreachable!("Invalid report_style") + } + + if report_style == "votes" || (report_style == "votes_transposed" && hide_xfers_trsp) { + result.push(format!(r#"{}"#, classes_i, pp(state.quota.as_ref().unwrap(), opts.pp_decimals))); + } else { + result.push(format!(r#"{}"#, classes_i, pp(state.quota.as_ref().unwrap(), opts.pp_decimals))); + } + + if stv::should_show_vre(opts) { + if let Some(vre) = &state.vote_required_election { + if report_style == "votes" || (report_style == "votes_transposed" && hide_xfers_trsp) { + result.push(format!(r#"{}"#, classes_i, pp(vre, opts.pp_decimals))); + } else { + result.push(format!(r#"{}"#, classes_i, pp(vre, opts.pp_decimals))); + } + } else { + if report_style == "votes" || (report_style == "votes_transposed" && hide_xfers_trsp) { + result.push(format!(r#""#, classes_i)); + } else { + result.push(format!(r#""#, classes_i)); + } + } + } + + return result; +} + +/// Generate the final column of the HTML results table +pub fn finalise_results_table(state: &CountState, report_style: &str) -> Vec { + let mut result = Vec::new(); + + // Header rows + match report_style { + "votes" | "votes_transposed" => { + result.push(String::from(r#""#)); + result.push(String::from("")); + result.push(String::from("")); + } + "ballots_votes" => { + result.push(String::from(r#""#)); + result.push(String::from("")); + result.push(String::from("")); + result.push(String::from("")); + } + _ => unreachable!("Invalid report_style") + } + + let rowspan = if report_style == "votes_transposed" { "" } else { r#" rowspan="2""# }; + + // Candidate states + for candidate in state.election.candidates.iter() { + if candidate.is_dummy { + continue; + } + + let count_card = &state.candidates[candidate]; + if count_card.state == stv::CandidateState::Elected { + result.push(format!(r#"ELECTED {}"#, rowspan, count_card.order_elected)); + } else if count_card.state == stv::CandidateState::Excluded { + result.push(format!(r#"Excluded {}"#, rowspan, -count_card.order_elected)); + } else if count_card.state == stv::CandidateState::Withdrawn { + result.push(format!(r#"Withdrawn"#, rowspan)); + } else { + result.push(format!(r#""#, rowspan)); + } + + if report_style != "votes_transposed" { + result.push(String::from("")); + } + } + + return result; +} + +/// HTML pretty-print the number to the specified decimal places +fn pp(n: &N, dps: usize) -> String { + if n.is_zero() { + return "".to_string(); + } + + let mut raw = format!("{:.dps$}", n, dps=dps); + if raw.contains('.') { + raw = raw.replacen(".", ".", 1); + raw.push_str(""); + } + + if raw.starts_with('-') { + raw = raw.replacen("-", "−", 1); + } + + return raw; +} + +/// Signed version of [pp] +fn pps(n: &N, dps: usize) -> String { + if n.is_zero() { + return "".to_string(); + } + + let mut raw = format!("{:.dps$}", n, dps=dps); + if raw.contains('.') { + raw = raw.replacen(".", ".", 1); + raw.push_str(""); + } + + if raw.starts_with('-') { + raw = raw.replacen("-", "−", 1); + } else { + raw.insert(0, '+'); + } + + return raw; +} diff --git a/src/stv/mod.rs b/src/stv/mod.rs index a9629e0..ab8f89c 100644 --- a/src/stv/mod.rs +++ b/src/stv/mod.rs @@ -19,6 +19,8 @@ pub mod gregory; /// Meek method of surplus distributions, etc. pub mod meek; +/// Helper functions for HTML reporting output +pub mod html; /// Random sample methods of surplus distributions pub mod sample; diff --git a/src/stv/wasm.rs b/src/stv/wasm.rs index 652a419..0de5285 100644 --- a/src/stv/wasm.rs +++ b/src/stv/wasm.rs @@ -19,7 +19,7 @@ #![allow(unused_unsafe)] // Confuses cargo check use crate::constraints::{self, Constraints}; -use crate::election::{CandidateState, CountState, Election, StageKind}; +use crate::election::{CandidateState, CountState, Election}; //use crate::numbers::{DynNum, Fixed, GuardedFixed, NativeFloat64, Number, NumKind, Rational}; use crate::numbers::{Fixed, GuardedFixed, NativeFloat64, Number, Rational}; use crate::parser::blt; @@ -28,9 +28,8 @@ use crate::ties; extern crate console_error_panic_hook; -use itertools::Itertools; use js_sys::Array; -use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; use std::cmp::max; @@ -148,7 +147,7 @@ macro_rules! impl_type { #[cfg_attr(feature = "wasm", wasm_bindgen)] #[allow(non_snake_case)] pub fn [](filename: String, election: &[], opts: &STVOptions) -> String { - return describe_count(filename, &election.0, &opts.0); + return stv::html::describe_count(&filename, &election.0, &opts.0); } /// Wrapper for [update_results_table] @@ -325,333 +324,17 @@ impl STVOptions { // Reporting -/// Generate the lead-in description of the count in HTML -pub fn describe_count(filename: String, election: &Election, opts: &stv::STVOptions) -> String { - let mut result = String::from("

Count computed by OpenTally (revision "); - result.push_str(crate::VERSION); - let total_ballots = election.ballots.iter().fold(N::new(), |mut acc, b| { acc += &b.orig_value; acc }); - result.push_str(&format!(r#"). Read {:.0} ballots from ‘{}’ for election ‘{}’. There are {} candidates for {} vacancies. "#, total_ballots, filename, election.name, election.candidates.iter().filter(|c| !c.is_dummy).count(), election.seats)); - - let opts_str = opts.describe::(); - if !opts_str.is_empty() { - result.push_str(&format!(r#"Counting using options {}.

"#, opts_str)) - } else { - result.push_str(r#"Counting using default options.

"#); - } - - return result; -} - /// Generate the first column of the HTML results table pub fn init_results_table(election: &Election, opts: &stv::STVOptions, report_style: &str) -> String { - let mut result = String::from(r#""#); - - if report_style == "ballots_votes" { - result.push_str(r#""#); - } - - for candidate in election.candidates.iter() { - if candidate.is_dummy { - continue; - } - - if report_style == "votes_transposed" { - result.push_str(&format!(r#"{}"#, candidate.name)); - } else { - result.push_str(&format!(r#"{}"#, candidate.name)); - } - } - - if report_style == "votes_transposed" { - result.push_str(r#"Exhausted"#); - } else { - result.push_str(r#"Exhausted"#); - } - - if report_style == "votes_transposed" { - result.push_str(r#"Loss by fractionTotalQuota"#); - } else { - result.push_str(r#"Loss by fractionTotalQuota"#); - } - - if stv::should_show_vre(opts) { - result.push_str(r#"Vote required for election"#); - } - - return result; + return stv::html::init_results_table(election, opts, report_style).join(""); } /// Generate subsequent columns of the HTML results table pub fn update_results_table(stage_num: usize, state: &CountState, opts: &stv::STVOptions, report_style: &str) -> Array { - let result = Array::new(); - - // Insert borders to left of new exclusions if reset-and-reiterate method applied - let classes_o; // Outer version - let classes_i; // Inner version - if (opts.exclusion == stv::ExclusionMethod::ResetAndReiterate && matches!(state.title, StageKind::ExclusionOf(_))) || matches!(state.title, StageKind::Rollback) { - classes_o = r#" class="blw""#; - classes_i = r#"blw "#; - } else { - classes_o = ""; - classes_i = ""; - } - - // Hide transfers column for first preferences if transposed - let hide_xfers_trsp; - if let StageKind::FirstPreferences = state.title { - hide_xfers_trsp = true; - } else if opts.exclusion == stv::ExclusionMethod::ResetAndReiterate && matches!(state.title, StageKind::ExclusionOf(_)) { - hide_xfers_trsp = true; - } else if let StageKind::Rollback = state.title { - hide_xfers_trsp = true; - } else if let StageKind::BulkElection = state.title { - hide_xfers_trsp = true; - } else if state.candidates.values().all(|cc| cc.transfers.is_zero()) && state.exhausted.transfers.is_zero() && state.loss_fraction.transfers.is_zero() { - hide_xfers_trsp = true; - } else { - hide_xfers_trsp = false; - } - - // Header rows - let kind_str = state.title.kind_as_string(); - let title_str; - match &state.title { - StageKind::FirstPreferences | StageKind::Rollback | StageKind::RollbackExhausted | StageKind::SurplusesDistributed | StageKind::BulkElection => { - title_str = format!("{}", state.title); - } - StageKind::SurplusOf(candidate) => { - title_str = candidate.name.clone(); - } - StageKind::ExclusionOf(candidates) => { - if candidates.len() > 5 { - let first_4_cands = candidates.iter().map(|c| &c.name).sorted().take(4).join(",
"); - title_str = format!("{},
and {} others", first_4_cands, candidates.len() - 4); - } else { - title_str = candidates.iter().map(|c| &c.name).join(",
"); - } - } - StageKind::BallotsOf(candidate) => { - title_str = candidate.name.clone(); - } - }; - - match report_style { - "votes" => { - result.push(&format!(r##"{1}"##, classes_o, stage_num).into()); - result.push(&format!(r#"{}"#, classes_o, kind_str).into()); - result.push(&format!(r#"{}"#, classes_o, title_str).into()); - } - "votes_transposed" => { - if hide_xfers_trsp { - result.push(&format!(r##"{1}"##, classes_o, stage_num).into()); - result.push(&format!(r#"{}"#, classes_o, kind_str).into()); - result.push(&format!(r#"{}"#, classes_o, title_str).into()); - } else { - result.push(&format!(r##"{1}"##, classes_o, stage_num).into()); - result.push(&format!(r#"{}"#, classes_o, kind_str).into()); - result.push(&format!(r#"{}"#, classes_o, title_str).into()); - //result.push(&format!(r#"X'fersTotal"#, tdclasses1).into()); - } - } - "ballots_votes" => { - result.push(&format!(r##"{1}"##, classes_o, stage_num).into()); - result.push(&format!(r#"{}"#, classes_o, kind_str).into()); - result.push(&format!(r#"{}"#, classes_o, title_str).into()); - result.push(&format!(r#"BallotsVotes"#, classes_o).into()); - } - _ => unreachable!("Invalid report_style") - } - - for candidate in state.election.candidates.iter() { - if candidate.is_dummy { - continue; - } - - let count_card = &state.candidates[candidate]; - - // TODO: REFACTOR THIS!! - - match report_style { - "votes" => { - match count_card.state { - CandidateState::Hopeful | CandidateState::Guarded => { - result.push(&format!(r#"{}"#, classes_i, pps(&count_card.transfers, opts.pp_decimals)).into()); - result.push(&format!(r#"{}"#, classes_i, pp(&count_card.votes, opts.pp_decimals)).into()); - } - CandidateState::Elected => { - result.push(&format!(r#"{}"#, classes_i, pps(&count_card.transfers, opts.pp_decimals)).into()); - result.push(&format!(r#"{}"#, classes_i, pp(&count_card.votes, opts.pp_decimals)).into()); - } - CandidateState::Doomed => { - result.push(&format!(r#"{}"#, classes_i, pps(&count_card.transfers, opts.pp_decimals)).into()); - result.push(&format!(r#"{}"#, classes_i, pp(&count_card.votes, opts.pp_decimals)).into()); - } - CandidateState::Withdrawn => { - result.push(&format!(r#""#, classes_i).into()); - result.push(&format!(r#"WD"#, classes_i).into()); - } - CandidateState::Excluded => { - result.push(&format!(r#"{}"#, classes_i, pps(&count_card.transfers, opts.pp_decimals)).into()); - if count_card.votes.is_zero() && count_card.parcels.iter().all(|p| p.votes.is_empty()) { - result.push(&format!(r#"Ex"#, classes_i).into()); - } else { - result.push(&format!(r#"{}"#, classes_i, pp(&count_card.votes, opts.pp_decimals)).into()); - } - } - } - } - "votes_transposed" => { - match count_card.state { - CandidateState::Hopeful | CandidateState::Guarded => { - if hide_xfers_trsp { - result.push(&format!(r#"{}"#, classes_i, pp(&count_card.votes, opts.pp_decimals)).into()); - } else { - result.push(&format!(r#"{}{}"#, classes_i, pps(&count_card.transfers, opts.pp_decimals), pp(&count_card.votes, opts.pp_decimals)).into()); - } - } - CandidateState::Elected => { - if hide_xfers_trsp { - result.push(&format!(r#"{}"#, classes_i, pp(&count_card.votes, opts.pp_decimals)).into()); - } else { - result.push(&format!(r#"{}{}"#, classes_i, pps(&count_card.transfers, opts.pp_decimals), pp(&count_card.votes, opts.pp_decimals)).into()); - } - } - CandidateState::Doomed => { - if hide_xfers_trsp { - result.push(&format!(r#"{}"#, classes_i, pp(&count_card.votes, opts.pp_decimals)).into()); - } else { - result.push(&format!(r#"{}{}"#, classes_i, pps(&count_card.transfers, opts.pp_decimals), pp(&count_card.votes, opts.pp_decimals)).into()); - } - } - CandidateState::Withdrawn => { - if hide_xfers_trsp { - result.push(&format!(r#"WD"#, classes_i).into()); - } else { - result.push(&format!(r#"WD"#, classes_i).into()); - } - } - CandidateState::Excluded => { - if count_card.votes.is_zero() && count_card.parcels.iter().all(|p| p.votes.is_empty()) { - if hide_xfers_trsp { - result.push(&format!(r#"Ex"#, classes_i).into()); - } else { - result.push(&format!(r#"{}Ex"#, classes_i, pps(&count_card.transfers, opts.pp_decimals)).into()); - } - } else { - if hide_xfers_trsp { - result.push(&format!(r#"{}"#, classes_i, pp(&count_card.votes, opts.pp_decimals)).into()); - } else { - result.push(&format!(r#"{}{}"#, classes_i, pps(&count_card.transfers, opts.pp_decimals), pp(&count_card.votes, opts.pp_decimals)).into()); - } - } - } - } - } - "ballots_votes" => { - match count_card.state { - CandidateState::Hopeful | CandidateState::Guarded => { - result.push(&format!(r#"{}{}"#, classes_i, pps(&count_card.ballot_transfers, 0), pps(&count_card.transfers, opts.pp_decimals)).into()); - result.push(&format!(r#"{}{}"#, classes_i, pp(&count_card.num_ballots(), 0), pp(&count_card.votes, opts.pp_decimals)).into()); - } - CandidateState::Elected => { - result.push(&format!(r#"{}{}"#, classes_i, pps(&count_card.ballot_transfers, 0), pps(&count_card.transfers, opts.pp_decimals)).into()); - result.push(&format!(r#"{}{}"#, classes_i, pp(&count_card.num_ballots(), 0), pp(&count_card.votes, opts.pp_decimals)).into()); - } - CandidateState::Doomed => { - result.push(&format!(r#"{}{}"#, classes_i, pps(&count_card.ballot_transfers, 0), pps(&count_card.transfers, opts.pp_decimals)).into()); - result.push(&format!(r#"{}{}"#, classes_i, pp(&count_card.num_ballots(), 0), pp(&count_card.votes, opts.pp_decimals)).into()); - } - CandidateState::Withdrawn => { - result.push(&format!(r#""#, classes_i).into()); - result.push(&format!(r#"WD"#, classes_i).into()); - } - CandidateState::Excluded => { - result.push(&format!(r#"{}{}"#, classes_i, pps(&count_card.ballot_transfers, 0), pps(&count_card.transfers, opts.pp_decimals)).into()); - if count_card.votes.is_zero() && count_card.parcels.iter().all(|p| p.votes.is_empty()) { - result.push(&format!(r#"Ex"#, classes_i).into()); - } else { - result.push(&format!(r#"{}{}"#, classes_i, pp(&count_card.num_ballots(), 0), pp(&count_card.votes, opts.pp_decimals)).into()); - } - } - } - } - _ => unreachable!("Invalid report_style") - } - } - - match report_style { - "votes" => { - result.push(&format!(r#"{}"#, classes_i, pps(&state.exhausted.transfers, opts.pp_decimals)).into()); - result.push(&format!(r#"{}"#, classes_i, pp(&state.exhausted.votes, opts.pp_decimals)).into()); - result.push(&format!(r#"{}"#, classes_i, pps(&state.loss_fraction.transfers, opts.pp_decimals)).into()); - result.push(&format!(r#"{}"#, classes_i, pp(&state.loss_fraction.votes, opts.pp_decimals)).into()); - } - "votes_transposed" => { - if hide_xfers_trsp { - result.push(&format!(r#"{}"#, classes_i, pp(&state.exhausted.votes, opts.pp_decimals)).into()); - result.push(&format!(r#"{}"#, classes_i, pp(&state.loss_fraction.votes, opts.pp_decimals)).into()); - } else { - result.push(&format!(r#"{}{}"#, classes_i, pps(&state.exhausted.transfers, opts.pp_decimals), pp(&state.exhausted.votes, opts.pp_decimals)).into()); - result.push(&format!(r#"{}{}"#, classes_i, pps(&state.loss_fraction.transfers, opts.pp_decimals), pp(&state.loss_fraction.votes, opts.pp_decimals)).into()); - } - } - "ballots_votes" => { - result.push(&format!(r#"{}{}"#, classes_i, pps(&state.exhausted.ballot_transfers, 0), pps(&state.exhausted.transfers, opts.pp_decimals)).into()); - result.push(&format!(r#"{}{}"#, classes_i, pp(&state.exhausted.num_ballots(), 0), pp(&state.exhausted.votes, opts.pp_decimals)).into()); - result.push(&format!(r#"{}"#, classes_i, pps(&state.loss_fraction.transfers, opts.pp_decimals)).into()); - result.push(&format!(r#"{}"#, classes_i, pp(&state.loss_fraction.votes, opts.pp_decimals)).into()); - } - _ => unreachable!("Invalid report_style") - } - - // Calculate total votes - let mut total_vote = state.candidates.iter().filter_map(|(c, cc)| if c.is_dummy { None } else { Some(cc) }).fold(N::new(), |mut acc, cc| { acc += &cc.votes; acc }); - total_vote += &state.exhausted.votes; - total_vote += &state.loss_fraction.votes; - - match report_style { - "votes" => { - result.push(&format!(r#"{}"#, classes_i, pp(&total_vote, opts.pp_decimals)).into()); - } - "votes_transposed" => { - if hide_xfers_trsp { - result.push(&format!(r#"{}"#, classes_i, pp(&total_vote, opts.pp_decimals)).into()); - } else { - result.push(&format!(r#"{}"#, classes_i, pp(&total_vote, opts.pp_decimals)).into()); - } - } - "ballots_votes" => { - // Calculate total ballots - let mut total_ballots = state.candidates.values().fold(N::new(), |mut acc, cc| { acc += cc.num_ballots(); acc }); - total_ballots += state.exhausted.num_ballots(); - result.push(&format!(r#"{}{}"#, classes_i, pp(&total_ballots, 0), pp(&total_vote, opts.pp_decimals)).into()); - } - _ => unreachable!("Invalid report_style") - } - - if report_style == "votes" || (report_style == "votes_transposed" && hide_xfers_trsp) { - result.push(&format!(r#"{}"#, classes_i, pp(state.quota.as_ref().unwrap(), opts.pp_decimals)).into()); - } else { - result.push(&format!(r#"{}"#, classes_i, pp(state.quota.as_ref().unwrap(), opts.pp_decimals)).into()); - } - - if stv::should_show_vre(opts) { - if let Some(vre) = &state.vote_required_election { - if report_style == "votes" || (report_style == "votes_transposed" && hide_xfers_trsp) { - result.push(&format!(r#"{}"#, classes_i, pp(vre, opts.pp_decimals)).into()); - } else { - result.push(&format!(r#"{}"#, classes_i, pp(vre, opts.pp_decimals)).into()); - } - } else { - if report_style == "votes" || (report_style == "votes_transposed" && hide_xfers_trsp) { - result.push(&format!(r#""#, classes_i).into()); - } else { - result.push(&format!(r#""#, classes_i).into()); - } - } - } - - return result; + return stv::html::update_results_table(stage_num, state, opts, report_style) + .into_iter() + .map(|s| JsValue::from(s)) + .collect(); } /// Get the comment for the current stage @@ -665,49 +348,10 @@ pub fn update_stage_comments(state: &CountState, stage_num: usize) /// Generate the final column of the HTML results table pub fn finalise_results_table(state: &CountState, report_style: &str) -> Array { - let result = Array::new(); - - // Header rows - match report_style { - "votes" | "votes_transposed" => { - result.push(&r#""#.into()); - result.push(&"".into()); - result.push(&"".into()); - } - "ballots_votes" => { - result.push(&r#""#.into()); - result.push(&"".into()); - result.push(&"".into()); - result.push(&"".into()); - } - _ => unreachable!("Invalid report_style") - } - - let rowspan = if report_style == "votes_transposed" { "" } else { r#" rowspan="2""# }; - - // Candidate states - for candidate in state.election.candidates.iter() { - if candidate.is_dummy { - continue; - } - - let count_card = &state.candidates[candidate]; - if count_card.state == stv::CandidateState::Elected { - result.push(&format!(r#"ELECTED {}"#, rowspan, count_card.order_elected).into()); - } else if count_card.state == stv::CandidateState::Excluded { - result.push(&format!(r#"Excluded {}"#, rowspan, -count_card.order_elected).into()); - } else if count_card.state == stv::CandidateState::Withdrawn { - result.push(&format!(r#"Withdrawn"#, rowspan).into()); - } else { - result.push(&format!(r#""#, rowspan).into()); - } - - if report_style != "votes_transposed" { - result.push(&"".into()); - } - } - - return result; + return stv::html::finalise_results_table(state, report_style) + .into_iter() + .map(|s| JsValue::from(s)) + .collect(); } /// Generate the final lead-out text summarising the result of the election @@ -733,43 +377,3 @@ pub fn final_result_summary(state: &CountState, opts: &stv::STVOpt result.push_str(""); return result; } - -/// HTML pretty-print the number to the specified decimal places -fn pp(n: &N, dps: usize) -> String { - if n.is_zero() { - return "".to_string(); - } - - let mut raw = format!("{:.dps$}", n, dps=dps); - if raw.contains('.') { - raw = raw.replacen(".", ".", 1); - raw.push_str(""); - } - - if raw.starts_with('-') { - raw = raw.replacen("-", "−", 1); - } - - return raw; -} - -/// Signed version of [pp] -fn pps(n: &N, dps: usize) -> String { - if n.is_zero() { - return "".to_string(); - } - - let mut raw = format!("{:.dps$}", n, dps=dps); - if raw.contains('.') { - raw = raw.replacen(".", ".", 1); - raw.push_str(""); - } - - if raw.starts_with('-') { - raw = raw.replacen("-", "−", 1); - } else { - raw.insert(0, '+'); - } - - return raw; -}