/* OpenTally: Open-source election vote counting * Copyright © 2021–2023 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"#)); if election.seats == 1 { result.push(String::from(r#"Majority"#)); } else { 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"#)); if election.seats == 1 { result.push(String::from(r#"Majority"#)); } else { 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; }