369 lines
13 KiB
Rust
369 lines
13 KiB
Rust
/* 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/>.
|
|
*/
|
|
|
|
#![allow(rustdoc::private_intra_doc_links)]
|
|
|
|
use crate::election::{CandidateState, CountState, Election};
|
|
use crate::numbers::{Fixed, NativeFloat64, Number, Rational};
|
|
use crate::stv;
|
|
|
|
extern crate console_error_panic_hook;
|
|
|
|
use js_sys::Array;
|
|
use wasm_bindgen::{JsValue, prelude::wasm_bindgen};
|
|
|
|
// Init
|
|
|
|
/// Wrapper for [Fixed::set_dps]
|
|
#[wasm_bindgen]
|
|
pub fn fixed_set_dps(dps: usize) {
|
|
Fixed::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 [<election_from_blt_$type>](text: String) -> [<Election$type>] {
|
|
// 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$type>](election);
|
|
}
|
|
|
|
/// Wrapper for [Election::normalise_ballots]
|
|
#[wasm_bindgen]
|
|
#[allow(non_snake_case)]
|
|
pub fn [<election_normalise_ballots_$type>](election: &mut [<Election$type>]) {
|
|
election.0.normalise_ballots();
|
|
}
|
|
|
|
/// Wrapper for [stv::count_init]
|
|
#[wasm_bindgen]
|
|
#[allow(non_snake_case)]
|
|
pub fn [<count_init_$type>](state: &mut [<CountState$type>], opts: &STVOptions) {
|
|
stv::count_init(&mut state.0, opts.as_static());
|
|
}
|
|
|
|
/// Wrapper for [stv::count_one_stage]
|
|
#[wasm_bindgen]
|
|
#[allow(non_snake_case)]
|
|
pub fn [<count_one_stage_$type>](state: &mut [<CountState$type>], opts: &STVOptions) -> Result<bool, JsValue> {
|
|
match stv::count_one_stage(&mut state.0, &opts.0) {
|
|
Ok(v) => Ok(v),
|
|
Err(stv::STVError::RequireInput) => Err("RequireInput".into()),
|
|
Err(stv::STVError::UnresolvedTie) => Err("UnresolvedTie".into()),
|
|
}
|
|
}
|
|
|
|
// Reporting
|
|
|
|
/// Wrapper for [init_results_table]
|
|
#[wasm_bindgen]
|
|
#[allow(non_snake_case)]
|
|
pub fn [<init_results_table_$type>](election: &[<Election$type>], opts: &STVOptions) -> String {
|
|
return init_results_table(&election.0, &opts.0);
|
|
}
|
|
|
|
/// Wrapper for [describe_count]
|
|
#[wasm_bindgen]
|
|
#[allow(non_snake_case)]
|
|
pub fn [<describe_count_$type>](filename: String, election: &[<Election$type>], opts: &STVOptions) -> String {
|
|
return describe_count(filename, &election.0, &opts.0);
|
|
}
|
|
|
|
/// Wrapper for [update_results_table]
|
|
#[wasm_bindgen]
|
|
#[allow(non_snake_case)]
|
|
pub fn [<update_results_table_$type>](stage_num: usize, state: &[<CountState$type>], 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 [<update_stage_comments_$type>](state: &[<CountState$type>]) -> String {
|
|
return update_stage_comments(&state.0);
|
|
}
|
|
|
|
/// Wrapper for [finalise_results_table]
|
|
#[wasm_bindgen]
|
|
#[allow(non_snake_case)]
|
|
pub fn [<finalise_results_table_$type>](state: &[<CountState$type>]) -> Array {
|
|
return finalise_results_table(&state.0);
|
|
}
|
|
|
|
/// Wrapper for [final_result_summary]
|
|
#[wasm_bindgen]
|
|
#[allow(non_snake_case)]
|
|
pub fn [<final_result_summary_$type>](state: &[<CountState$type>]) -> String {
|
|
return final_result_summary(&state.0);
|
|
}
|
|
|
|
// Wrapper structs
|
|
// Required as we cannot specify &'static in wasm-bindgen: issue #1187
|
|
|
|
/// Wrapper for [CountState]
|
|
#[wasm_bindgen]
|
|
pub struct [<CountState$type>](CountState<'static, $type>);
|
|
#[wasm_bindgen]
|
|
impl [<CountState$type>] {
|
|
pub fn new(election: &[<Election$type>]) -> Self {
|
|
return [<CountState$type>](CountState::new(election.as_static()));
|
|
}
|
|
}
|
|
|
|
/// Wrapper for [Election]
|
|
#[wasm_bindgen]
|
|
pub struct [<Election$type>](Election<$type>);
|
|
#[wasm_bindgen]
|
|
impl [<Election$type>] {
|
|
pub fn seats(&self) -> usize { self.0.seats }
|
|
|
|
fn as_static(&self) -> &'static Election<$type> {
|
|
// Need to have this as we cannot specify &'static in wasm-bindgen: issue #1187
|
|
unsafe {
|
|
let ptr = &self.0 as *const Election<$type>;
|
|
&*ptr
|
|
}
|
|
}
|
|
}
|
|
}}
|
|
}
|
|
|
|
impl_type!(Fixed);
|
|
impl_type!(NativeFloat64);
|
|
impl_type!(Rational);
|
|
|
|
/// 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<usize>,
|
|
round_weights: Option<usize>,
|
|
round_votes: Option<usize>,
|
|
round_quota: Option<usize>,
|
|
sum_surplus_transfers: &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,
|
|
bulk_exclude: bool,
|
|
defer_surpluses: bool,
|
|
pp_decimals: usize,
|
|
) -> Self {
|
|
Self(stv::STVOptions::new(
|
|
round_tvs,
|
|
round_weights,
|
|
round_votes,
|
|
round_quota,
|
|
sum_surplus_transfers,
|
|
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,
|
|
bulk_exclude,
|
|
defer_surpluses,
|
|
pp_decimals,
|
|
))
|
|
}
|
|
}
|
|
|
|
impl STVOptions {
|
|
/// Return the underlying [stv::STVOptions] as a `&'static stv::STVOptions`
|
|
///
|
|
/// # Safety
|
|
/// 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<N: Number>(filename: String, election: &Election<N>, opts: &stv::STVOptions) -> String {
|
|
let mut result = String::from("<p>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::<N>();
|
|
if opts_str.len() > 0 {
|
|
result.push_str(&format!(r#"Counting using options <span style="font-family: monospace;">{}</span>.</p>"#, opts_str))
|
|
} else {
|
|
result.push_str(r#"Counting using default options.</p>"#);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// Generate the first column of the HTML results table
|
|
fn init_results_table<N: Number>(election: &Election<N>, opts: &stv::STVOptions) -> String {
|
|
let mut result = String::from(r#"<tr class="stage-no"><td rowspan="3"></td></tr><tr class="stage-kind"></tr><tr class="stage-comment"></tr>"#);
|
|
for candidate in election.candidates.iter() {
|
|
result.push_str(&format!(r#"<tr class="candidate transfers"><td rowspan="2">{}</td></tr><tr class="candidate votes"></tr>"#, candidate.name));
|
|
}
|
|
result.push_str(r#"<tr class="info transfers"><td rowspan="2">Exhausted</td></tr><tr class="info votes"></tr><tr class="info transfers"><td rowspan="2">Loss by fraction</td></tr><tr class="info votes"></tr><tr class="info transfers"><td>Total</td></tr><tr class="info transfers"><td>Quota</td></tr>"#);
|
|
if opts.quota_mode == stv::QuotaMode::ERS97 {
|
|
result.push_str(r#"<tr class="info transfers"><td>Vote required for election</td></tr>"#);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// Generate subsequent columns of the HTML results table
|
|
fn update_results_table<N: Number>(stage_num: usize, state: &CountState<N>, opts: &stv::STVOptions) -> Array {
|
|
let result = Array::new();
|
|
result.push(&format!(r#"<td>{}</td>"#, stage_num).into());
|
|
result.push(&format!(r#"<td>{}</td>"#, state.kind.unwrap_or("")).into());
|
|
result.push(&format!(r#"<td>{}</td>"#, state.title).into());
|
|
for candidate in state.election.candidates.iter() {
|
|
let count_card = state.candidates.get(candidate).unwrap();
|
|
if count_card.state == stv::CandidateState::Elected {
|
|
result.push(&format!(r#"<td class="count elected">{}</td>"#, pp(&count_card.transfers, opts.pp_decimals)).into());
|
|
result.push(&format!(r#"<td class="count elected">{}</td>"#, pp(&count_card.votes, opts.pp_decimals)).into());
|
|
} else if count_card.state == stv::CandidateState::Excluded {
|
|
result.push(&format!(r#"<td class="count excluded">{}</td>"#, pp(&count_card.transfers, opts.pp_decimals)).into());
|
|
if count_card.votes.is_zero() {
|
|
result.push(&r#"<td class="count excluded">Ex</td>"#.into());
|
|
} else {
|
|
result.push(&format!(r#"<td class="count excluded">{}</td>"#, pp(&count_card.votes, opts.pp_decimals)).into());
|
|
}
|
|
} else if count_card.state == stv::CandidateState::Withdrawn {
|
|
result.push(&r#"<td class="count excluded"></td>"#.into());
|
|
result.push(&r#"<td class="count excluded">WD</td>"#.into());
|
|
} else {
|
|
result.push(&format!(r#"<td class="count">{}</td>"#, pp(&count_card.transfers, opts.pp_decimals)).into());
|
|
result.push(&format!(r#"<td class="count">{}</td>"#, pp(&count_card.votes, opts.pp_decimals)).into());
|
|
}
|
|
}
|
|
result.push(&format!(r#"<td class="count">{}</td>"#, pp(&state.exhausted.transfers, opts.pp_decimals)).into());
|
|
result.push(&format!(r#"<td class="count">{}</td>"#, pp(&state.exhausted.votes, opts.pp_decimals)).into());
|
|
result.push(&format!(r#"<td class="count">{}</td>"#, pp(&state.loss_fraction.transfers, opts.pp_decimals)).into());
|
|
result.push(&format!(r#"<td class="count">{}</td>"#, 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#"<td class="count">{}</td>"#, pp(&total_vote, opts.pp_decimals)).into());
|
|
|
|
result.push(&format!(r#"<td class="count">{}</td>"#, pp(state.quota.as_ref().unwrap(), opts.pp_decimals)).into());
|
|
if opts.quota_mode == stv::QuotaMode::ERS97 {
|
|
result.push(&format!(r#"<td class="count">{}</td>"#, 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<N: Number>(state: &CountState<N>) -> String {
|
|
return state.logger.render().join(" ");
|
|
}
|
|
|
|
/// Generate the final column of the HTML results table
|
|
fn finalise_results_table<N: Number>(state: &CountState<N>) -> Array {
|
|
let result = Array::new();
|
|
|
|
// Header rows
|
|
result.push(&r#"<td rowspan="3"></td>"#.into());
|
|
result.push(&"".into());
|
|
result.push(&"".into());
|
|
|
|
// Candidate states
|
|
for candidate in state.election.candidates.iter() {
|
|
let count_card = state.candidates.get(candidate).unwrap();
|
|
if count_card.state == stv::CandidateState::Elected {
|
|
result.push(&format!(r#"<td rowspan="2" class="bb elected">ELECTED {}</td>"#, count_card.order_elected).into());
|
|
} else if count_card.state == stv::CandidateState::Excluded {
|
|
result.push(&format!(r#"<td rowspan="2" class="bb excluded">Excluded {}</td>"#, -count_card.order_elected).into());
|
|
} else if count_card.state == stv::CandidateState::Withdrawn {
|
|
result.push(&r#"<td rowspan="2" class="bb excluded">Withdrawn</td>"#.into());
|
|
} else {
|
|
result.push(&r#"<td rowspan="2" class="bb"></td>"#.into());
|
|
}
|
|
result.push(&"".into());
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// Generate the final lead-out text summarising the result of the election
|
|
fn final_result_summary<N: Number>(state: &CountState<N>) -> String {
|
|
let mut result = String::from("<p>Count complete. The winning candidates are, in order of election:</p><ol>");
|
|
|
|
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));
|
|
}
|
|
}
|
|
winners.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
|
|
|
|
for (winner, _) in winners.into_iter() {
|
|
result.push_str(&format!("<li>{}</li>", winner.name));
|
|
}
|
|
|
|
result.push_str("</ol>");
|
|
return result;
|
|
}
|
|
|
|
/// HTML pretty-print the number to the specified decimal places
|
|
fn pp<N: Number>(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(".", ".<sup>", 1);
|
|
raw.push_str("</sup>");
|
|
}
|
|
|
|
if raw.starts_with('-') {
|
|
raw = raw.replacen("-", "−", 1);
|
|
}
|
|
|
|
return raw;
|
|
}
|