const TINY_THRESHOLD : f64 = 0.01;
const HUGE_THRESHOLD : f64 = 999.0;
pub fn apportion(shares: &[f64], total_cells: usize) -> Vec<usize> {
let exact = shares.iter().map(|x| x * total_cells as f64 / 100.0).collect::<Vec<_>>();
let mut cells = shares.iter().zip(exact.iter())
.map(|(share, exact)| if *share < TINY_THRESHOLD {0} else {(*exact as usize).max(1)})
.collect::<Vec<_>>();
let mut sum = cells.iter().sum::<usize>();
while sum < total_cells {
let distance_below = |i: &usize| exact[*i] - cells[*i] as f64;
let furthest_below = (0..cells.len()).filter(|i| shares[*i] >= TINY_THRESHOLD)
.filter(|i| distance_below(i) > 0.0)
.max_by(|a, b| distance_below(a).total_cmp(&distance_below(b)));
match furthest_below {
Some(i) => cells[i] += 1,
None => break
}
sum += 1;
}
while sum > total_cells {
let largest = (0..cells.len()).filter(|i| cells[*i] > 1)
.max_by(|a, b| cells[*a].cmp(&cells[*b]).then(exact[*a].total_cmp(&exact[*b])));
match largest {
Some(i) => cells[i] -= 1,
None => break
}
sum -= 1;
}
cells
}
pub fn calculate_percentages_of_their_own_sum(numbers: &[usize]) -> Vec<f64> {
calculate_percentages_of_a_given_total(numbers, numbers.iter().sum())
}
pub fn calculate_percentages_of_a_given_total(numbers: &[usize], total: usize) -> Vec<f64> {
if total == 0 {
return vec![0.0; numbers.len()];
}
let accounts_for_everything = numbers.iter().sum::<usize>() == total;
let exact = |number: usize| number as f64 / total as f64 * 100f64;
let mut shares = Vec::with_capacity(numbers.len());
let mut sum = 0.0;
for (position, number) in numbers.iter().enumerate() {
if accounts_for_everything && position == numbers.len() - 1 {
let remainder = if sum > 99.99 {0.0} else {((100f64 - sum) * 100f64).round() / 100f64};
shares.push(if remainder == 0.0 && *number > 0 {exact(*number)} else {remainder});
} else {
let rounded = (exact(*number) * 100f64).round() / 100f64;
sum += rounded;
shares.push(if rounded == 0.0 && *number > 0 {exact(*number)} else {rounded});
}
}
shares
}
pub fn calculate_relative_change(older: usize, newer: usize) -> f64 {
if older == 0 {
return 0.0;
}
(newer as f64 - older as f64) / older as f64 * 100.0
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NumberFormat {
thousands: Option<char>,
decimal: char
}
impl NumberFormat {
pub fn new(thousands: Option<char>, decimal: char) -> Self {
NumberFormat { thousands, decimal }
}
pub fn integer(&self, number: usize) -> String {
self.grouped(&number.to_string())
}
pub fn grouped(&self, digits: &str) -> String {
let Some(separator) = self.thousands else {
return self.with_decimal_mark(digits);
};
let (whole, rest) = match digits.split_once('.') {
Some((whole, fraction)) => (whole, Some(fraction)),
None => (digits, None)
};
let mut grouped = String::with_capacity(digits.len() + digits.len() / 3);
for (position, character) in whole.chars().rev().enumerate() {
if position != 0 && position % 3 == 0 {
grouped.insert(0, separator);
}
grouped.insert(0, character);
}
match rest {
Some(fraction) => grouped + &self.decimal.to_string() + fraction,
None => grouped
}
}
pub fn size_with_unit(&self, bytes: usize) -> (String, &'static str) {
for (limit, unit) in [(1_000_000_000, "GB"), (1_000_000, "MB"), (1_000, "KB")] {
if bytes >= limit {
return (self.with_decimal_mark(&format!("{:.1}", bytes as f64 / limit as f64)), unit);
}
}
(self.integer(bytes), "B")
}
pub fn percent(&self, value: f64) -> String {
if value == 0.0 {
return "0".to_owned();
}
let text = if value >= 100.0 || format!("{value:.1}") == "100.0" {format!("{value:.0}")}
else if value >= 10.0 || format!("{value:.2}") == "10.00" {format!("{value:.1}")}
else {format!("{value:.2}")};
self.with_decimal_mark(&if text == "0.00" {"<0.01".to_owned()} else {text})
}
pub fn signed_percent(&self, value: f64) -> String {
let magnitude = value.abs();
let sign = if value > 0.0 {"+"} else if value < 0.0 {"-"} else {""};
if magnitude > 0.0 && magnitude < TINY_THRESHOLD {
return format!("{sign} <{}", self.percent(TINY_THRESHOLD));
}
if magnitude > HUGE_THRESHOLD {
return format!("{sign} >{}", self.percent(HUGE_THRESHOLD));
}
format!("{sign}{}", self.percent(magnitude))
}
pub fn with_decimal_mark(&self, text: &str) -> String {
match self.decimal {
'.' => text.to_owned(),
mark => text.replace('.', &mark.to_string())
}
}
}
impl Default for NumberFormat {
fn default() -> Self {
NumberFormat { thousands: Some(','), decimal: '.' }
}
}
#[cfg(test)]
mod tests {
use super::*;
const BAR : usize = 50;
#[test]
fn apportionment_is_exact_and_scales_to_any_number_of_cells() {
assert_eq!(vec![25,25], apportion(&[49.6,50.4], BAR));
assert_eq!(vec![0,50], apportion(&[0.0,100.0], BAR));
assert_eq!(vec![16,17,17], apportion(&[33.33,33.33,33.34], BAR));
assert_eq!(vec![1,32,17], apportion(&[0.3,65.67,34.3], BAR));
assert_eq!(vec![0,0,50], apportion(&[0.0,0.0,100.0], BAR));
assert_eq!(vec![1,24,25], apportion(&[0.2,49.9,49.9], BAR));
assert_eq!(vec![6,25,13,6], apportion(&[12.5,50.0,25.0,12.5], BAR));
assert_eq!(vec![1,1,24,24], apportion(&[0.1,0.1,49.9,49.9], BAR));
assert_eq!(25, apportion(&[50.0,50.0], 50)[0]);
assert_eq!(50, apportion(&[50.0,50.0], 100)[0]);
assert_eq!(10, apportion(&[50.0,50.0], 20)[0]);
}
#[test]
fn the_cell_that_a_protected_minimum_costs_comes_off_the_largest_share() {
assert_eq!(vec![47,1,1,1], apportion(&[97.0,1.0,1.0,1.0], BAR));
assert_eq!(vec![47,1,1,1], apportion(&[99.4,0.2,0.2,0.2], BAR));
let cells = apportion(&[99.7,0.1,0.1,0.1], BAR);
assert_eq!(BAR, cells.iter().sum::<usize>());
assert!(cells.iter().all(|x| *x >= 1), "a share that is present must never lose its last cell");
assert_eq!(vec![93,3,1,1,1,1], apportion(&[96.96, 3.0, 0.01, 0.01, 0.01, 0.01], 100));
assert_eq!(vec![45,1,1,1,1,1], apportion(&[96.96, 3.0, 0.01, 0.01, 0.01, 0.01], BAR));
}
#[test]
fn the_cells_always_sum_to_the_total_and_keep_visible_shares_visible() {
let cases: Vec<Vec<f64>> = vec![
vec![100.0], vec![50.0,50.0], vec![0.01,99.99], vec![0.0,0.0,0.0,100.0],
vec![25.0,25.0,25.0,25.0], vec![70.0,10.0,10.0,10.0], vec![1.0,1.0,1.0,97.0],
vec![0.04,0.04,0.04,99.88], vec![33.34,33.33,33.33], vec![60.5,39.5],
vec![98.0,2.0], vec![2.0,98.0], vec![0.0,100.0,0.0]
];
for shares in cases {
let cells = apportion(&shares, BAR);
assert_eq!(BAR, cells.iter().sum::<usize>(), "wrong total for {shares:?}");
for (i, share) in shares.iter().enumerate() {
if *share > 0.0 {
assert!(cells[i] >= 1, "{shares:?} made a present share disappear");
} else {
assert_eq!(0, cells[i], "{shares:?} gave a cell to a share of nothing");
}
}
}
}
#[test]
fn a_list_that_covers_less_than_the_whole_draws_a_bar_that_stops_short() {
assert_eq!(vec![5], apportion(&[10.0], BAR));
assert_eq!(vec![13, 13], apportion(&[25.0, 25.0], BAR));
assert_eq!(vec![20, 4, 1], apportion(&[40.0, 8.0, 2.0], BAR));
let of_each_other = calculate_percentages_of_their_own_sum(&[40, 8, 2]);
let cells = apportion(&of_each_other, BAR);
assert_eq!(BAR, cells.iter().sum::<usize>());
assert_eq!(vec![40, 8, 2], cells);
}
#[test]
fn percentages_sum_to_a_hundred_with_the_last_entry_absorbing_the_rounding() {
assert_eq!(vec![0f64,50f64,50f64], calculate_percentages_of_their_own_sum(&[0,100,100]));
assert_eq!(vec![100f64,0f64,0f64], calculate_percentages_of_their_own_sum(&[1,0,0]));
assert_eq!(vec![33.33,33.33,33.34], calculate_percentages_of_their_own_sum(&[20,20,20]));
assert_eq!(vec![0f64,50f64,50f64,0f64], calculate_percentages_of_their_own_sum(&[0,100,100,0]));
assert_eq!(vec![33.33,33.33,33.33,0.01], calculate_percentages_of_their_own_sum(&[100,100,100,0]));
assert_eq!(vec![33.28,33.28,33.44,0.0], calculate_percentages_of_their_own_sum(&[200,200,201,0]));
}
#[test]
fn a_share_that_rounds_away_is_named_rather_than_shown_as_absent() {
let format = NumberFormat::default();
for numbers in [vec![500_000, 3, 299_997], vec![500_000, 299_997, 3]] {
let shares = calculate_percentages_of_their_own_sum(&numbers);
let tiny = numbers.iter().position(|x| *x == 3).unwrap();
assert_eq!("<0.01", format.percent(shares[tiny]), "for {numbers:?}");
let cells = apportion(&shares, BAR);
assert_eq!(0, cells[tiny], "a share too small to be printed must not claim a cell either");
assert_eq!(BAR, cells.iter().sum::<usize>(), "the bar came up short around the tiny share");
}
let shares = calculate_percentages_of_their_own_sum(&[500_000, 299_997, 0]);
assert_eq!("0", format.percent(shares[2]));
assert_eq!(0, apportion(&shares, BAR)[2]);
let shares = calculate_percentages_of_their_own_sum(&[500_000, 3, 299_997]);
assert!((shares[1] - 0.000375).abs() < 1e-9, "the share was replaced by a marker: {}", shares[1]);
assert!((shares.iter().sum::<f64>() - 100.0).abs() < 0.01, "the shares no longer sum to 100: {shares:?}");
}
#[test]
fn a_share_is_of_its_own_sum_unless_a_total_is_given() {
assert_eq!(vec![62.5, 37.5], calculate_percentages_of_their_own_sum(&[500_000, 300_000]));
assert_eq!(vec![50.0, 30.0], calculate_percentages_of_a_given_total(&[500_000, 300_000], 1_000_000));
assert_eq!(vec![0.0, 0.0], calculate_percentages_of_a_given_total(&[0, 0], 0));
}
#[test]
fn every_percentage_fits_a_five_column_field() {
let format = NumberFormat::default();
assert_eq!("0", format.percent(0.0));
assert_eq!("0.01", format.percent(0.01));
assert_eq!("9.99", format.percent(9.994));
assert_eq!("10.0", format.percent(9.996));
assert_eq!("12.3", format.percent(12.345));
assert_eq!("99.9", format.percent(99.94));
assert_eq!("100", format.percent(99.996));
assert_eq!("100", format.percent(100.0));
for value in [0.0, 0.000375, 0.01, 9.9, 99.99, 100.0] {
assert!(format.percent(value).len() <= 5, "'{}' does not fit the column", format.percent(value));
}
}
#[test]
fn a_size_takes_the_largest_unit_that_leaves_a_figure_worth_reading() {
let format = NumberFormat::default();
assert_eq!(("999".to_owned(), "B"), format.size_with_unit(999));
assert_eq!(("1.0".to_owned(), "KB"), format.size_with_unit(1_000));
assert_eq!(("1.0".to_owned(), "MB"), format.size_with_unit(1_000_000));
assert_eq!(("1.0".to_owned(), "GB"), format.size_with_unit(1_000_000_000));
assert_eq!(("2.4".to_owned(), "MB"), format.size_with_unit(2_417_403));
assert_eq!(("0".to_owned(), "B"), format.size_with_unit(0));
}
#[test]
fn a_change_out_of_nothing_is_not_a_percentage() {
assert_eq!(0.0, calculate_relative_change(0, 500));
assert_eq!(0.0, calculate_relative_change(0, 0));
assert_eq!(100.0, calculate_relative_change(100, 200));
assert_eq!(-10.0, calculate_relative_change(100, 90));
assert_eq!(0.0, calculate_relative_change(100, 100));
}
#[test]
fn a_number_is_grouped_and_marked_the_way_the_caller_asked() {
let plain = NumberFormat::new(None, '.');
assert_eq!("1234567", plain.integer(1234567));
assert_eq!("1.5", plain.grouped("1.5"));
let english = NumberFormat::new(Some(','), '.');
assert_eq!("123", english.integer(123));
assert_eq!("1,234", english.integer(1234));
assert_eq!("12,345", english.integer(12345));
assert_eq!("1,234,567", english.integer(1234567));
let european = NumberFormat::new(Some('.'), ',');
assert_eq!("1.234.567", european.integer(1234567));
assert_eq!("1.234,5", european.grouped("1234.5"));
assert_eq!("12,3", european.percent(12.345));
assert_eq!(("2,4".to_owned(), "MB"), european.size_with_unit(2_417_403));
assert_eq!(english.integer(1234567), NumberFormat::default().integer(1234567));
assert_eq!("1,234,567", NumberFormat::default().integer(1234567));
}
#[test]
fn a_signed_percentage_carries_its_direction_and_names_the_tiny_and_the_huge_ones() {
let format = NumberFormat::default();
assert_eq!("0", format.signed_percent(calculate_relative_change(100, 100)));
assert_eq!("-10.0", format.signed_percent(calculate_relative_change(100, 90)));
assert_eq!("+100", format.signed_percent(calculate_relative_change(100, 200)));
assert_eq!("+123", format.signed_percent(123.456));
assert_eq!("-34.9", format.signed_percent(-34.87));
assert_eq!("+10.0", format.signed_percent(9.996));
assert_eq!("+100", format.signed_percent(99.996));
assert_eq!("+ <0.01", format.signed_percent(calculate_relative_change(22819, 22820)));
assert_eq!("+0.01", format.signed_percent(0.01));
assert_eq!("+999", format.signed_percent(999.0));
assert_eq!("+ >999", format.signed_percent(calculate_relative_change(1, 213)));
for value in [0.0, 0.000375, -34.87, 999.0, 999.5, 21200.0, 999900.0] {
assert!(format.signed_percent(value).chars().count() <= 7,
"'{}' does not fit the column", format.signed_percent(value));
}
}
}