/* 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 . */ #![allow(rustdoc::private_intra_doc_links)] use crate::constraints::Constraints; use crate::election::{CandidateState, CountState, Election}; use crate::numbers::{DynNum, Fixed, GuardedFixed, NumKind, Number}; use crate::stv; extern crate console_error_panic_hook; use js_sys::Array; use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; use std::cmp::max; // Init /// Wrapper for [DynNum::set_kind] #[wasm_bindgen] pub fn dynnum_set_kind(kind: NumKind) { DynNum::set_kind(kind); } /// Wrapper for [Fixed::set_dps] #[wasm_bindgen] pub fn fixed_set_dps(dps: usize) { Fixed::set_dps(dps); } /// Wrapper for [GuardedFixed::set_dps] #[wasm_bindgen] pub fn gfixed_set_dps(dps: usize) { GuardedFixed::set_dps(dps); } // Helper macros for making functions macro_rules! impl_type { ($type:ident) => { paste::item! { // Counting /// Wrapper for [Election::from_blt] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](text: String) -> [] { // Install panic! hook console_error_panic_hook::set_once(); let election: Election<$type> = Election::from_blt(text.lines().map(|s| s.to_string()).into_iter()); return [](election); } /// Wrapper for [Election::normalise_ballots] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](election: &mut []) { election.0.normalise_ballots(); } /// Call [Constraints::from_con] and set [Election::constraints] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](election: &mut [], text: String) { election.0.constraints = Some(Constraints::from_con(text.lines().map(|s| s.to_string()).into_iter())); } /// Wrapper for [stv::count_init] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](state: &mut [], opts: &STVOptions) -> Result { match stv::count_init(&mut state.0, opts.as_static()) { Ok(v) => Ok(v), Err(e) => Err(e.name().into()), } } /// Wrapper for [stv::count_one_stage] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](state: &mut [], opts: &STVOptions) -> Result { match stv::count_one_stage::<[<$type>]>(&mut state.0, &opts.0) { Ok(v) => Ok(v), Err(e) => Err(e.name().into()), } } // Reporting /// Wrapper for [init_results_table] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](election: &[], opts: &STVOptions) -> String { return init_results_table(&election.0, &opts.0); } /// Wrapper for [describe_count] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](filename: String, election: &[], opts: &STVOptions) -> String { return describe_count(filename, &election.0, &opts.0); } /// Wrapper for [update_results_table] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](stage_num: usize, state: &[], opts: &STVOptions) -> Array { return update_results_table(stage_num, &state.0, &opts.0); } /// Wrapper for [update_stage_comments] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](state: &[]) -> String { return update_stage_comments(&state.0); } /// Wrapper for [finalise_results_table] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](state: &[]) -> Array { return finalise_results_table(&state.0); } /// Wrapper for [final_result_summary] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](state: &[], opts: &STVOptions) -> String { return final_result_summary(&state.0, &opts.0); } // Wrapper structs /// Wrapper for [CountState] /// /// This is required as `&'static` cannot be specified in wasm-bindgen: see [issue 1187](https://github.com/rustwasm/wasm-bindgen/issues/1187). /// #[wasm_bindgen] pub struct [](CountState<'static, $type>); #[wasm_bindgen] impl [] { /// Create a new [CountState] wrapper pub fn new(election: &[]) -> Self { return [](CountState::new(election.as_static())); } } /// Wrapper for [Election] /// /// This is required as `&'static` cannot be specified in wasm-bindgen: see [issue 1187](https://github.com/rustwasm/wasm-bindgen/issues/1187). /// #[wasm_bindgen] pub struct [](Election<$type>); #[wasm_bindgen] impl [] { /// Return [Election::seats] pub fn seats(&self) -> usize { self.0.seats } /// Return the underlying [Election] as a `&'static Election` /// /// # Safety /// This assumes that the underlying [Election] is valid for the `'static` lifetime, as it would be if the [Election] were created from Javascript. /// fn as_static(&self) -> &'static Election<$type> { unsafe { let ptr = &self.0 as *const Election<$type>; &*ptr } } } }} } //impl_type!(Fixed); //impl_type!(GuardedFixed); //impl_type!(NativeFloat64); //impl_type!(Rational); impl_type!(DynNum); /// Wrapper for [stv::STVOptions] #[wasm_bindgen] pub struct STVOptions(stv::STVOptions); #[wasm_bindgen] impl STVOptions { /// Wrapper for [stv::STVOptions::new] pub fn new( round_tvs: Option, round_weights: Option, round_votes: Option, round_quota: Option, sum_surplus_transfers: &str, meek_surplus_tolerance: &str, normalise_ballots: bool, quota: &str, quota_criterion: &str, quota_mode: &str, ties: Array, random_seed: String, surplus: &str, surplus_order: &str, transferable_only: bool, exclusion: &str, meek_nz_exclusion: bool, early_bulk_elect: bool, bulk_exclude: bool, defer_surpluses: bool, meek_immediate_elect: bool, constraints_path: Option, constraint_mode: &str, pp_decimals: usize, ) -> Self { Self(stv::STVOptions::new( round_tvs, round_weights, round_votes, round_quota, sum_surplus_transfers, meek_surplus_tolerance, normalise_ballots, quota, quota_criterion, quota_mode, &ties.iter().map(|v| v.as_string().unwrap()).collect(), &Some(random_seed), surplus, surplus_order, transferable_only, exclusion, meek_nz_exclusion, early_bulk_elect, bulk_exclude, defer_surpluses, meek_immediate_elect, constraints_path.as_deref(), constraint_mode, pp_decimals, )) } /// Wrapper for [stv::STVOptions::validate] pub fn validate(&self) { self.0.validate(); } } impl STVOptions { /// Return the underlying [stv::STVOptions] as a `&'static stv::STVOptions` /// /// # Safety /// This assumes that the underlying [stv::STVOptions] is valid for the `'static` lifetime, as it would be if the [stv::STVOptions] were created from Javascript. /// fn as_static(&self) -> &'static stv::STVOptions { unsafe { let ptr = &self.0 as *const stv::STVOptions; &*ptr } } } // Reporting /// Generate the lead-in description of the count in HTML 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::zero(), |acc, b| { acc + &b.orig_value }); result.push_str(&format!(r#"). Read {:.0} ballots from ‘{}’ for election ‘{}’. There are {} candidates for {} vacancies. "#, total_ballots, filename, election.name, election.candidates.len(), election.seats)); let opts_str = opts.describe::(); if opts_str.len() > 0 { 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 fn init_results_table(election: &Election, opts: &stv::STVOptions) -> String { let mut result = String::from(r#""#); for candidate in election.candidates.iter() { result.push_str(&format!(r#"{}"#, candidate.name)); } result.push_str(r#"ExhaustedLoss by fractionTotalQuota"#); if opts.quota_mode == stv::QuotaMode::ERS97 { result.push_str(r#"Vote required for election"#); } return result; } /// Generate subsequent columns of the HTML results table fn update_results_table(stage_num: usize, state: &CountState, opts: &stv::STVOptions) -> Array { let result = Array::new(); // Insert borders to left of new exclusions in Wright STV let mut tdclasses1 = ""; let mut tdclasses2 = ""; if opts.exclusion == stv::ExclusionMethod::Wright && state.kind == Some("Exclusion of") { tdclasses1 = r#" class="blw""#; tdclasses2 = r#"blw "#; } result.push(&format!(r#"{}"#, tdclasses1, stage_num).into()); result.push(&format!(r#"{}"#, tdclasses1, state.kind.unwrap_or("")).into()); result.push(&format!(r#"{}"#, tdclasses1, state.title).into()); for candidate in state.election.candidates.iter() { let count_card = &state.candidates[candidate]; match count_card.state { CandidateState::Hopeful | CandidateState::Guarded => { result.push(&format!(r#"{}"#, tdclasses2, pp(&count_card.transfers, opts.pp_decimals)).into()); result.push(&format!(r#"{}"#, tdclasses2, pp(&count_card.votes, opts.pp_decimals)).into()); } CandidateState::Elected => { result.push(&format!(r#"{}"#, tdclasses2, pp(&count_card.transfers, opts.pp_decimals)).into()); result.push(&format!(r#"{}"#, tdclasses2, pp(&count_card.votes, opts.pp_decimals)).into()); } CandidateState::Doomed => { result.push(&format!(r#"{}"#, tdclasses2, pp(&count_card.transfers, opts.pp_decimals)).into()); result.push(&format!(r#"{}"#, tdclasses2, pp(&count_card.votes, opts.pp_decimals)).into()); } CandidateState::Withdrawn => { result.push(&format!(r#""#, tdclasses2).into()); result.push(&format!(r#"WD"#, tdclasses2).into()); } CandidateState::Excluded => { result.push(&format!(r#"{}"#, tdclasses2, pp(&count_card.transfers, opts.pp_decimals)).into()); if count_card.votes.is_zero() { result.push(&format!(r#"Ex"#, tdclasses2).into()); } else { result.push(&format!(r#"{}"#, tdclasses2, pp(&count_card.votes, opts.pp_decimals)).into()); } } } } result.push(&format!(r#"{}"#, tdclasses2, pp(&state.exhausted.transfers, opts.pp_decimals)).into()); result.push(&format!(r#"{}"#, tdclasses2, pp(&state.exhausted.votes, opts.pp_decimals)).into()); result.push(&format!(r#"{}"#, tdclasses2, pp(&state.loss_fraction.transfers, opts.pp_decimals)).into()); result.push(&format!(r#"{}"#, tdclasses2, pp(&state.loss_fraction.votes, opts.pp_decimals)).into()); // Calculate total votes let mut total_vote = state.candidates.values().fold(N::zero(), |acc, cc| { acc + &cc.votes }); total_vote += &state.exhausted.votes; total_vote += &state.loss_fraction.votes; result.push(&format!(r#"{}"#, tdclasses2, pp(&total_vote, opts.pp_decimals)).into()); result.push(&format!(r#"{}"#, tdclasses2, pp(state.quota.as_ref().unwrap(), opts.pp_decimals)).into()); if opts.quota_mode == stv::QuotaMode::ERS97 { result.push(&format!(r#"{}"#, tdclasses2, pp(state.vote_required_election.as_ref().unwrap(), opts.pp_decimals)).into()); } return result; } /// Get the comment for the current stage fn update_stage_comments(state: &CountState) -> String { return state.logger.render().join(" "); } /// Generate the final column of the HTML results table fn finalise_results_table(state: &CountState) -> Array { let result = Array::new(); // Header rows result.push(&r#""#.into()); result.push(&"".into()); result.push(&"".into()); // Candidate states for candidate in state.election.candidates.iter() { let count_card = &state.candidates[candidate]; if count_card.state == stv::CandidateState::Elected { result.push(&format!(r#"ELECTED {}"#, count_card.order_elected).into()); } else if count_card.state == stv::CandidateState::Excluded { result.push(&format!(r#"Excluded {}"#, -count_card.order_elected).into()); } else if count_card.state == stv::CandidateState::Withdrawn { result.push(&r#"Withdrawn"#.into()); } else { result.push(&r#""#.into()); } result.push(&"".into()); } return result; } /// Generate the final lead-out text summarising the result of the election fn final_result_summary(state: &CountState, opts: &stv::STVOptions) -> String { let mut result = String::from("

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.order_elected, &count_card.keep_value)); } } winners.sort_unstable_by(|a, b| a.1.cmp(&b.1)); for (winner, _, kv_opt) in winners.into_iter() { if let Some(kv) = kv_opt { result.push_str(&format!("
  1. {} (kv = {:.dps2$})
  2. ", winner.name, kv, dps2=max(opts.pp_decimals, 2))); } else { result.push_str(&format!("
  3. {}
  4. ", winner.name)); } } 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; }