Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 19c6f70e2a functions: MODE counts through an index, FREQUENCY bins by binary search
MODE/MODE.SNGL/MODE.MULT searched the list of distinct numbers seen so far
for every input, and FREQUENCY scanned the sorted bins for every data value,
so both were quadratic in computed arrays. MODE now keys counts by value
(-0 folded into 0, NaN never merged) and keeps first-occurrence order;
FREQUENCY finds the first bin at or above each value with partition_point
over the order it already sorts. Results are unchanged.
2026-11-02 22:18:10 -05:00
6 changed files with 55 additions and 86 deletions
+11 -32
View File
@@ -11,10 +11,6 @@ use crate::{ChartData, Rgba, SeriesData, TrendlineData};
pub(crate) const MAX_POINTS: usize = 100_000;
/// Cap on series per chart.
pub(crate) const MAX_SERIES: usize = 255;
/// Cap on the points of all series of one chart together (values, X values and bubble sizes):
/// [`MAX_SERIES`] × [`MAX_POINTS`] would let a few short whole-column references retain and
/// draw tens of millions of points. Series past it get only what is left, or no points.
pub(crate) const MAX_CHART_POINTS: usize = 1_000_000;
/// Cap on trendlines drawn per series.
pub(crate) const MAX_TRENDLINES: usize = 16;
@@ -81,15 +77,10 @@ fn union_parts(f: &str) -> Option<Vec<&str>> {
/// Evaluates a series formula to a flat list of values (row-major), capped. A union of
/// references (`(A1:A3,A5)`, as PivotCharts use) is read part by part.
fn eval_list(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Value> {
eval_list_in(wb, sheet, formula, 0, MAX_POINTS)
eval_list_in(wb, sheet, formula, 0)
}
/// [`eval_list`] keeping at most `cap` values (and never more than [`MAX_POINTS`]).
fn eval_list_capped(wb: &Workbook, sheet: usize, formula: &str, cap: usize) -> Vec<Value> {
eval_list_in(wb, sheet, formula, 0, cap.min(MAX_POINTS))
}
fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32, cap: usize) -> Vec<Value> {
fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec<Value> {
let f = formula.trim();
if f.is_empty() {
return vec![];
@@ -99,19 +90,16 @@ fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32, cap: usi
{
let mut out = Vec::new();
for p in parts {
if out.len() >= cap {
if out.len() >= MAX_POINTS {
break;
}
let rest = cap - out.len();
out.extend(eval_list_in(wb, sheet, p, depth + 1, rest).into_iter().take(rest));
let rest = MAX_POINTS - out.len();
out.extend(eval_list_in(wb, sheet, p, depth + 1).into_iter().take(rest));
}
return out;
}
if cap == 0 {
return vec![];
}
match evaluate(wb, sheet, CellRef::default(), f) {
Value::Array(a) => a.iter().take(cap).cloned().collect(),
Value::Array(a) => a.iter().take(MAX_POINTS).cloned().collect(),
v => vec![v],
}
}
@@ -123,11 +111,8 @@ fn to_num(v: &Value) -> Option<f64> {
}
}
/// A series formula's numbers, at most `cap` of them; `cap` is reduced by what was taken.
fn nums(wb: &Workbook, sheet: usize, formula: &str, cap: &mut usize) -> Vec<Option<f64>> {
let out: Vec<Option<f64>> = eval_list_capped(wb, sheet, formula, *cap).iter().map(to_num).collect();
*cap -= out.len().min(*cap);
out
fn nums(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Option<f64>> {
eval_list(wb, sheet, formula).iter().map(to_num).collect()
}
/// Number format code of the first cell of a reference ("General" when not a reference).
@@ -243,21 +228,15 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
let mut series = Vec::new();
let mut categories: Option<Vec<String>> = None;
let mut category_levels = Vec::new();
// One point budget for the whole chart, not one per series.
let mut budget = MAX_CHART_POINTS;
for (i, s) in chart.series.iter().take(MAX_SERIES).enumerate() {
let values = nums(wb, sheet, &s.values, &mut budget);
let values = nums(wb, sheet, &s.values);
let name = s.name.as_deref().map(|n| series_name(wb, sheet, n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series{}", i + 1));
let color =
s.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or_else(|| series_color(&wb.theme, i));
let kind = effective_kind(chart.kind, s.kind, i);
let x = match (&s.categories, numeric_x || matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble)) {
// X values with text in them (labels) are ignored: the points are numbered 1..n.
(Some(f), true) => {
let xs = eval_list_capped(wb, sheet, f, budget);
budget -= xs.len().min(budget);
Some(xs).filter(|xs| !xs.iter().any(Value::is_text)).map(|xs| xs.iter().map(to_num).collect())
}
(Some(f), true) => Some(eval_list(wb, sheet, f)).filter(|xs| !xs.iter().any(Value::is_text)).map(|xs| xs.iter().map(to_num).collect()),
_ => None,
};
if categories.is_none()
@@ -269,7 +248,7 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
category_levels = outer;
}
}
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f, &mut budget));
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f));
let trendlines = s
.trendlines
.iter()
-18
View File
@@ -1258,21 +1258,3 @@ fn box_whisker_draws_each_series_quartile_method() {
// The boxes span 3.5 and 2.5 data units on the same value axis.
assert!((heights[0] / heights[1] - 1.4).abs() < 0.001);
}
#[test]
fn series_share_one_point_budget() {
// Many series over the same long column each get a share of one chart-wide budget, not
// `MAX_POINTS` apiece.
let mut wb = Workbook::new();
let s = wb.sheet_mut(0).unwrap();
s.set_value(CellRef::new(0, 0), Value::Number(1.0));
s.set_value(CellRef::new(59_999, 0), Value::Number(2.0));
let mut c = chart(ChartKind::ColumnClustered);
c.series = (0..40).map(|_| Series { values: "Sheet1!$A$1:$A$60000".into(), ..Default::default() }).collect();
let d = resolve(&wb, 0, &c);
assert_eq!(d.series.len(), 40);
assert_eq!(d.series[0].values.len(), 60_000);
let total: usize = d.series.iter().map(|s| s.values.len()).sum();
assert_eq!(total, crate::resolve::MAX_CHART_POINTS);
assert!(d.series.last().is_some_and(|s| s.values.is_empty()));
}
+4 -29
View File
@@ -10,10 +10,6 @@ use crate::io::esc;
/// Widest or tallest chart written; larger anchors are clamped.
const MAX_SIDE: f32 = 16_384.0;
/// Most bytes of SVG written for the charts of one web page together. A chart past what is
/// left is left out of the page rather than grown without bound.
pub(crate) const MAX_PAGE_SVG_BYTES: usize = 64 * 1024 * 1024;
struct HelveticaMeasure;
impl gridcraft_chart::Measure for HelveticaMeasure {
@@ -22,34 +18,24 @@ impl gridcraft_chart::Measure for HelveticaMeasure {
}
}
/// `chart` of sheet `sheet` as an `<svg>` element placed by `style`, or `None` for a degenerate
/// size or an element longer than `budget` bytes.
pub(crate) fn chart_svg(wb: &Workbook, sheet: usize, chart: &Chart, style: &str, budget: usize) -> Option<String> {
/// `chart` of sheet `sheet` as an `<svg>` element placed by `style`, or `None` for a degenerate size.
pub(crate) fn chart_svg(wb: &Workbook, sheet: usize, chart: &Chart, style: &str) -> Option<String> {
let (w, h) = (chart.anchor.width.min(MAX_SIDE), chart.anchor.height.min(MAX_SIDE));
if !(w >= 1.0 && h >= 1.0) {
return None;
}
let data = gridcraft_chart::resolve(wb, sheet, chart);
let prims = gridcraft_chart::render(chart, &data, w, h, &HelveticaMeasure);
prims_svg_within(&prims, w, h, style, budget)
Some(prims_svg(&prims, w, h, style))
}
#[cfg(test)]
fn prims_svg(prims: &[Prim], w: f32, h: f32, style: &str) -> String {
prims_svg_within(prims, w, h, style, usize::MAX).unwrap_or_default()
}
/// The `<svg>` element, or `None` as soon as it grows past `budget` bytes.
fn prims_svg_within(prims: &[Prim], w: f32, h: f32, style: &str, budget: usize) -> Option<String> {
let mut out = String::new();
let _ = write!(
out,
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\" font-family=\"Helvetica,Arial,sans-serif\" style=\"{style}\">"
);
for p in prims {
if out.len() > budget {
return None;
}
match p {
Prim::Rect { x, y, w, h, fill, stroke, radius } => {
let _ = write!(out, "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", num(*x), num(*y), num(*w), num(*h));
@@ -106,7 +92,7 @@ fn prims_svg_within(prims: &[Prim], w: f32, h: f32, style: &str, budget: usize)
}
}
out.push_str("</svg>");
(out.len() <= budget).then_some(out)
out
}
/// Fill and stroke attributes of an open shape element; the caller closes it.
@@ -163,17 +149,6 @@ fn num(v: f32) -> String {
mod tests {
use super::*;
#[test]
fn an_svg_past_its_byte_budget_is_left_out() {
let rect = Prim::Rect { x: 1.0, y: 2.0, w: 3.0, h: 4.0, fill: Some([255, 0, 0, 255]), stroke: None, radius: 0.0 };
let prims = vec![rect; 10_000];
let full = prims_svg(&prims, 100.0, 50.0, "");
assert!(full.len() > 100_000);
assert_eq!(prims_svg_within(&prims, 100.0, 50.0, "", full.len()), Some(full.clone()));
assert_eq!(prims_svg_within(&prims, 100.0, 50.0, "", full.len() - 1), None);
assert_eq!(prims_svg_within(&prims, 100.0, 50.0, "", 1_000), None);
}
#[test]
fn every_primitive_becomes_an_svg_element() {
let red = [255, 0, 0, 255];
+1 -3
View File
@@ -736,10 +736,8 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
place(&im.anchor)
);
}
let mut svg_budget = crate::chart_svg::MAX_PAGE_SVG_BYTES;
for ch in &sh.charts {
if let Some(svg) = crate::chart_svg::chart_svg(wb, sheet, ch, &place(&ch.anchor), svg_budget) {
svg_budget -= svg.len();
if let Some(svg) = crate::chart_svg::chart_svg(wb, sheet, ch, &place(&ch.anchor)) {
out.push_str(&svg);
}
}
+22 -4
View File
@@ -50,12 +50,27 @@ pub(super) fn median(v: Vec<f64>) -> R<f64> {
/// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count.
fn modes(v: &[f64]) -> Vec<f64> {
// Index of each distinct number in `counts`, so counting is linear rather than a search of
// every distinct number so far. Keyed by bits with -0 folded into 0 (they compare equal); NaN
// never equals anything, so it is never merged.
let mut index: std::collections::HashMap<u64, usize> = std::collections::HashMap::new();
let mut counts: Vec<(f64, usize)> = Vec::new();
for &x in v {
if let Some(e) = counts.iter_mut().find(|e| e.0 == x) {
e.1 += 1;
} else {
if x.is_nan() {
counts.push((x, 1));
continue;
}
let key = if x == 0.0 { 0 } else { x.to_bits() };
match index.entry(key) {
std::collections::hash_map::Entry::Occupied(e) => {
if let Some(c) = counts.get_mut(*e.get()) {
c.1 += 1;
}
}
std::collections::hash_map::Entry::Vacant(e) => {
e.insert(counts.len());
counts.push((x, 1));
}
}
}
let best = counts.iter().map(|e| e.1).max().unwrap_or(0);
@@ -455,7 +470,10 @@ pub(super) fn frequency(a: &[Arg], _: C) -> R<Value> {
order.sort_by(|&i, &j| bins.get(i).copied().unwrap_or(0.0).total_cmp(&bins.get(j).copied().unwrap_or(0.0)));
let mut counts = vec![0.0; bins.len() + 1];
for x in data {
let slot = order.iter().find(|&&i| x <= bins.get(i).copied().unwrap_or(f64::INFINITY)).copied().unwrap_or(bins.len());
// `order` sorts the bins ascending, so the bins `x` exceeds come first: binary search for
// the first bin at or above `x` (the first of equal bins, as a linear scan would find).
let pos = order.partition_point(|&i| bins.get(i).copied().unwrap_or(f64::INFINITY) < x);
let slot = order.get(pos).copied().unwrap_or(bins.len());
if let Some(c) = counts.get_mut(slot) {
*c += 1.0;
}
+17
View File
@@ -617,3 +617,20 @@ fn aggregate_helper() {
close(aggregate_values_k(9, &vals, &nv(2.0)), 9.0);
is_err(aggregate_values_k(14, &vals, &nv(10.0)), CellError::Num);
}
/// Security: FREQUENCY finds each value's bin by binary search, and MODE counts distinct numbers
/// through an index, so neither is quadratic in its inputs. Results are as before: unsorted and
/// duplicate bins keep their positions (a value goes to the first of equal bins), and tied modes
/// come in first-occurrence order.
#[test]
fn frequency_and_mode_scale_with_their_inputs() {
let f = ev("FREQUENCY", vec![c(&[1.0, 2.0, 2.0, 3.0, 5.0, 9.0, -1.0]), c(&[3.0, 2.0, 2.0, 0.0])]);
assert_eq!(rows_of(&f), vec![vec![nv(1.0)], vec![nv(3.0)], vec![nv(0.0)], vec![nv(1.0)], vec![nv(2.0)]]);
let data: Vec<f64> = (0..200_000).map(|i| f64::from(i) + 1e6).collect();
let bins: Vec<f64> = (0..200_000).map(f64::from).collect();
let f = ev("FREQUENCY", vec![c(&data), c(&bins)]);
assert_eq!(rows_of(&f).last(), Some(&vec![nv(200_000.0)]));
let distinct = ev("MODE", vec![c(&data)]);
is_err(distinct, CellError::NA);
assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[3.0, 0.0, 1.0, -0.0, 3.0, 1.0])])), vec![vec![nv(3.0)], vec![nv(0.0)], vec![nv(1.0)]]);
}