diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..ae8286ad3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-13 | Charts: label shortening needs about nine text measurements instead of up to 400, side legends stop measuring once the column is at its cap and treemap layout avoids per-item allocation (#1149) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 037b9099e..396423d2b 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -62,25 +62,47 @@ struct Ctx<'a> { m: &'a dyn Measure, } +#[cfg(test)] +#[path = "render_perf_tests.rs"] +mod perf_tests; + impl Ctx<'_> { fn tw(&self, s: &str, size: f32) -> f32 { let w = self.m.text_width(s, size, false); if w.is_finite() { w.max(0.0) } else { 0.0 } } + /// `s` shortened with an ellipsis to fit `maxw`; empty when not even one character fits. + /// + /// Binary search over the number of kept characters (at most 400 are tried), about nine + /// `Measure` calls instead of one per character. It assumes the width of `prefix + "…"` does not + /// shrink as the prefix grows, which holds for per-character advance sums (`ApproxMeasure`, the + /// PDF metrics) and for the UI's text layout up to kerning of a few hundredths of a point; with + /// such a non-monotone measure the result may differ from the old linear scan by a character. fn fit(&self, s: &str, size: f32, maxw: f32) -> String { if self.tw(s, size) <= maxw { return s.to_string(); } - let chars: Vec = s.chars().collect(); - let mut n = chars.len().min(400); - while n > 0 { - n -= 1; - let t: String = chars.iter().take(n).collect::() + "…"; - if self.tw(&t, size) <= maxw { - return if n == 0 { String::new() } else { t }; + // Byte offset where each of the first 400 characters starts; a prefix of `k` characters + // (k < number of characters, so the offset exists) ends at `starts[k]`. + let starts: Vec = s.char_indices().map(|(i, _)| i).take(400).collect(); + let top = starts.len().saturating_sub(1); + let fits = |k: usize| -> bool { + let end = starts.get(k).copied().unwrap_or(s.len()); + let mut t = String::with_capacity(end + 3); + t.push_str(s.get(..end).unwrap_or("")); + t.push('…'); + self.tw(&t, size) <= maxw + }; + let (mut lo, mut hi) = (0usize, top); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + if fits(mid) { + lo = mid; + } else { + hi = mid - 1; } } - String::new() + if lo == 0 { String::new() } else { format!("{}…", s.get(..starts.get(lo).copied().unwrap_or(s.len())).unwrap_or("")) } } #[allow(clippy::too_many_arguments)] fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, align: HAlign, valign: VAlign) { @@ -369,7 +391,16 @@ fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } } } LegendPos::Left | LegendPos::Right => { - let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35); + // Widest entry, capped at 35% of the area: once the cap is reached the rest cannot change it. + let cap = area.w * 0.35; + let mut colw = 0.0f32; + for e in entries { + if colw >= cap { + break; + } + colw = colw.max(item_w(ctx, e)); + } + let colw = colw.min(cap); let max_rows = ((area.h / row_h).floor() as usize).max(1); let n = entries.len().min(max_rows); let lh = n as f32 * row_h; @@ -911,7 +942,8 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta let all100 = members.iter().all(|i| fams.get(*i) == Some(&Fam::Col100)); let range = value_range(members.iter().flat_map(|i| { let line = fams.get(*i).is_some_and(|f| is_line(*f)); - spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] }) + // Lines contribute only their top value, bars both ends (no per-point `Vec`). + spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| [*b, *t].into_iter().skip(usize::from(line))) })); let (lo, hi) = range.unwrap_or((0.0, 0.0)); let (scale, fmt) = if all100 { @@ -1399,19 +1431,19 @@ fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inner: f32, nam // ---------------------------------------------------------------- treemap -fn worst(row: &[f64], side: f64) -> f64 { - let s: f64 = row.iter().sum(); +/// Worst aspect ratio of a row with the given area sum, smallest and largest item, laid along `side`. +fn worst(s: f64, mn: f64, mx: f64, side: f64) -> f64 { if s <= 0.0 || side <= 0.0 { return f64::INFINITY; } - let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v))); let s2 = s * s; let w2 = side * side; (w2 * mx / s2).max(s2 / (w2 * mn)) } -/// Squarified treemap layout of (index, area) items, sorted descending, into `r`. -fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { +/// Squarified treemap layout of (index, value) items, sorted descending, into `r`; each block +/// comes back with its index and value. +fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, f64, Bx)> { let total: f64 = items.iter().map(|i| i.1).sum(); let area = (r.w as f64) * (r.h as f64); if !(total > 0.0 && area > 0.0) { @@ -1420,9 +1452,10 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { let scale = area / total; let mut out = Vec::with_capacity(items.len()); let mut rem = r; - let mut row: Vec<(usize, f64)> = Vec::new(); + let mut row: Vec<(usize, f64, f64)> = Vec::new(); + let (mut sum, mut mn, mut mx) = (0.0f64, f64::INFINITY, 0.0f64); let mut i = 0; - let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| { + let layout = |row: &[(usize, f64, f64)], rem: &mut Bx, out: &mut Vec<(usize, f64, Bx)>| { let s: f64 = row.iter().map(|x| x.1).sum(); if s <= 0.0 { return; @@ -1430,9 +1463,9 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { if rem.w >= rem.h { let cw = (s / rem.h.max(1e-6) as f64) as f32; let mut y = rem.y; - for (j, a) in row { + for (j, a, v) in row { let h = (*a / cw.max(1e-6) as f64) as f32; - out.push((*j, Bx { x: rem.x, y, w: cw, h })); + out.push((*j, *v, Bx { x: rem.x, y, w: cw, h })); y += h; } rem.x += cw; @@ -1440,9 +1473,9 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { } else { let rh = (s / rem.w.max(1e-6) as f64) as f32; let mut x = rem.x; - for (j, a) in row { + for (j, a, v) in row { let w = (*a / rh.max(1e-6) as f64) as f32; - out.push((*j, Bx { x, y: rem.y, w, h: rh })); + out.push((*j, *v, Bx { x, y: rem.y, w, h: rh })); x += w; } rem.y += rh; @@ -1452,15 +1485,17 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { while let Some(&(j, v)) = items.get(i) { let a = v * scale; let side = rem.w.min(rem.h) as f64; - let cur: Vec = row.iter().map(|x| x.1).collect(); - let mut with = cur.clone(); - with.push(a); - if row.is_empty() || worst(&with, side) <= worst(&cur, side) { - row.push((j, a)); + // Sum, smallest and largest item of the open row, kept incrementally (same fold order as + // summing the row afresh, so the numbers are bit-identical). + let with = (sum + a, mn.min(a), mx.max(a)); + if row.is_empty() || worst(with.0, with.1, with.2, side) <= worst(sum, mn, mx, side) { + row.push((j, a, v)); + (sum, mn, mx) = with; i += 1; } else { layout(&row, &mut rem, &mut out); row.clear(); + (sum, mn, mx) = (0.0, f64::INFINITY, 0.0); } } if !row.is_empty() { @@ -1475,14 +1510,13 @@ fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect(); items.sort_by(|a, b| b.1.total_cmp(&a.1)); let f = Fmt::new(&s.number_format); - for (j, b) in squarify(&items, area) { + for (j, v, b) in squarify(&items, area) { let fill = point_color(data, j); ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5))); if b.w > 24.0 && b.h > LABEL_SIZE * 1.6 { let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0); ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top); if chart.data_labels && b.h > LABEL_SIZE * 3.2 { - let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0); let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0); ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top); } diff --git a/crates/chart/src/render_perf_tests.rs b/crates/chart/src/render_perf_tests.rs new file mode 100644 index 000000000..7bef70722 --- /dev/null +++ b/crates/chart/src/render_perf_tests.rs @@ -0,0 +1,254 @@ +//! Equality and counter tests for the label-fitting, legend-measuring and treemap rewrites. +//! The old implementations live here as references. + +use std::cell::Cell; + +use super::*; +use crate::{ApproxMeasure, Measure}; + +/// The pre-rewrite `Ctx::fit`: one `Measure` call per candidate length, longest first. +fn fit_reference(ctx: &Ctx, s: &str, size: f32, maxw: f32) -> String { + if ctx.tw(s, size) <= maxw { + return s.to_string(); + } + let chars: Vec = s.chars().collect(); + let mut n = chars.len().min(400); + while n > 0 { + n -= 1; + let t: String = chars.iter().take(n).collect::() + "…"; + if ctx.tw(&t, size) <= maxw { + return if n == 0 { String::new() } else { t }; + } + } + String::new() +} + +/// Counts calls; advances differ per character (wide CJK/emoji, zero-width combining marks) and +/// "AV"-style pairs are kerned tighter, but a character never makes the text narrower. +struct Varied { + calls: Cell, +} + +impl Measure for Varied { + fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 { + self.calls.set(self.calls.get() + 1); + let mut w = 0.0f32; + let mut prev = ' '; + for c in text.chars() { + let adv = match c as u32 { + 0x300..=0x36f => 0.0, + 0x1f300..=0x1faff | 0x4e00..=0x9fff => 1.0, + u => 0.3 + (u % 7) as f32 * 0.07, + }; + let kern = if prev == 'A' && c == 'V' { 0.1 } else { 0.0 }; + w += (adv - kern) * size; + prev = c; + } + if bold { w * 1.1 } else { w } + } +} + +struct Counting<'a>(&'a dyn Measure, Cell); + +impl Measure for Counting<'_> { + fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 { + self.1.set(self.1.get() + 1); + self.0.text_width(text, size, bold) + } +} + +fn corpus() -> Vec { + let mut v: Vec = + ["", "a", "ab", "Hello, world", "AVAVAV tight", "日本語のラベルです", "👨‍👩‍👧 family 🎉 party", "e\u{301}e\u{301}e\u{301} combining", "…", "……"] + .iter() + .map(|s| s.to_string()) + .collect(); + v.push("x".repeat(399)); + v.push("x".repeat(400)); + v.push("x".repeat(401)); + v.push("é🎉".repeat(500)); + v.push("long label ".repeat(100)); + v +} + +fn run_fit(m: &dyn Measure) { + let ctx = Ctx { out: vec![], m }; + for s in corpus() { + for size in [9.0f32, 11.0, 40.0] { + for maxw in [-5.0, 0.0, 0.4, 6.0, 25.0, 61.5, 120.0, 800.0, 1e6, f32::INFINITY, f32::NAN] { + assert_eq!(ctx.fit(&s, size, maxw), fit_reference(&ctx, &s, size, maxw), "{s:?} size {size} maxw {maxw}"); + } + } + } +} + +#[test] +fn fit_matches_linear_reference() { + run_fit(&ApproxMeasure); + run_fit(&Varied { calls: Cell::new(0) }); +} + +#[test] +fn fit_measures_logarithmically() { + let long = "x".repeat(1000); + let c = Counting(&ApproxMeasure, Cell::new(0)); + let ctx = Ctx { out: vec![], m: &c }; + let r = ctx.fit(&long, 11.0, 60.0); + assert!(r.ends_with('…') && r.chars().count() > 3); + // 1 whole-string check + ceil(log2(399)) + 1 probes; the old scan needed ~390 here. + assert!(c.1.get() <= 12, "{} Measure calls", c.1.get()); + let c2 = Counting(&ApproxMeasure, Cell::new(0)); + let ctx2 = Ctx { out: vec![], m: &c2 }; + assert_eq!(ctx2.fit("short", 11.0, 500.0), "short"); + assert_eq!(c2.1.get(), 1); +} + +fn legend_entries(n: usize, label: &dyn Fn(usize) -> String) -> Vec<(String, Rgba, Swatch)> { + (0..n).map(|i| (label(i), [10, 20, 30, 255], if i % 3 == 0 { Swatch::Line } else { Swatch::Box })).collect() +} + +/// Colour column width as the old code computed it, from the returned remaining area. +fn old_colw(m: &dyn Measure, entries: &[(String, Rgba, Swatch)], area: Bx) -> f32 { + let ctx = Ctx { out: vec![], m }; + let size = LABEL_SIZE; + let s = size * 0.8; + entries.iter().map(|e| swatch_w(e.2, s) + 4.0 + ctx.tw(&e.0, size)).fold(0.0f32, f32::max).min(area.w * 0.35) +} + +#[test] +fn side_legend_matches_old_column_width_and_stops_measuring_at_the_cap() { + let area = Bx { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }; + let labels: [&dyn Fn(usize) -> String; 3] = + [&|i| format!("entry {i}"), &|i| if i == 7 { "w".repeat(80) } else { format!("e{i}") }, &|i| "very long legend label ".repeat(1 + i % 3)]; + for pos in [LegendPos::Left, LegendPos::Right] { + for (k, label) in labels.iter().enumerate() { + for n in [0usize, 1, 5, 40] { + let entries = legend_entries(n, label); + let mut ctx = Ctx { out: vec![], m: &ApproxMeasure }; + let rest = draw_legend(&mut ctx, &entries, pos, area); + let colw = old_colw(&ApproxMeasure, &entries, area); + assert!((rest.w - (area.w - (colw + 8.0))).abs() < 1e-4, "{pos:?} labels {k} n {n}"); + } + } + } + // 20,000 entries whose first one already fills the 35% cap: the old code measured all of them + // plus the drawn rows, now only the first entry and the drawn rows are measured. + let entries = legend_entries(20_000, &|i| if i == 0 { "w".repeat(200) } else { format!("e{i}") }); + let c = Counting(&ApproxMeasure, Cell::new(0)); + let mut ctx = Ctx { out: vec![], m: &c }; + let _ = draw_legend(&mut ctx, &entries, LegendPos::Right, area); + let drawn = ctx.out.iter().filter(|p| matches!(p, Prim::Text { .. })).count(); + assert!(c.1.get() <= drawn * 10 + 5, "{} Measure calls for {drawn} drawn entries", c.1.get()); + assert!(c.1.get() < 500); +} + +/// The pre-rewrite `worst`/`squarify` (a `Vec` per item, row recomputed from scratch). +fn worst_reference(row: &[f64], side: f64) -> f64 { + let s: f64 = row.iter().sum(); + if s <= 0.0 || side <= 0.0 { + return f64::INFINITY; + } + let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v))); + let s2 = s * s; + let w2 = side * side; + (w2 * mx / s2).max(s2 / (w2 * mn)) +} + +fn squarify_reference(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { + let total: f64 = items.iter().map(|i| i.1).sum(); + let area = (r.w as f64) * (r.h as f64); + if !(total > 0.0 && area > 0.0) { + return vec![]; + } + let scale = area / total; + let mut out = Vec::with_capacity(items.len()); + let mut rem = r; + let mut row: Vec<(usize, f64)> = Vec::new(); + let mut i = 0; + let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| { + let s: f64 = row.iter().map(|x| x.1).sum(); + if s <= 0.0 { + return; + } + if rem.w >= rem.h { + let cw = (s / rem.h.max(1e-6) as f64) as f32; + let mut y = rem.y; + for (j, a) in row { + let h = (*a / cw.max(1e-6) as f64) as f32; + out.push((*j, Bx { x: rem.x, y, w: cw, h })); + y += h; + } + rem.x += cw; + rem.w = (rem.w - cw).max(0.0); + } else { + let rh = (s / rem.w.max(1e-6) as f64) as f32; + let mut x = rem.x; + for (j, a) in row { + let w = (*a / rh.max(1e-6) as f64) as f32; + out.push((*j, Bx { x, y: rem.y, w, h: rh })); + x += w; + } + rem.y += rh; + rem.h = (rem.h - rh).max(0.0); + } + }; + while let Some(&(j, v)) = items.get(i) { + let a = v * scale; + let side = rem.w.min(rem.h) as f64; + let cur: Vec = row.iter().map(|x| x.1).collect(); + let mut with = cur.clone(); + with.push(a); + if row.is_empty() || worst_reference(&with, side) <= worst_reference(&cur, side) { + row.push((j, a)); + i += 1; + } else { + layout(&row, &mut rem, &mut out); + row.clear(); + } + } + if !row.is_empty() { + layout(&row, &mut rem, &mut out); + } + out +} + +#[test] +fn squarify_matches_reference() { + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut rnd = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 11) as f64 / (1u64 << 53) as f64 + }; + let mut cases: Vec> = vec![ + vec![], + vec![5.0], + vec![6.0, 6.0, 4.0, 3.0, 2.0, 2.0, 1.0], + vec![3.0, 0.0, 0.0, 1.0], + vec![0.0, 0.0], + vec![4.0, -1.0, 2.0], + vec![4.0, f64::NAN, 2.0, 1.0], + vec![f64::INFINITY, 1.0], + vec![1e-300, 1e-310, 0.0], + ]; + for n in [2usize, 9, 40, 300] { + let mut v: Vec = (0..n).map(|_| rnd() * 100.0).collect(); + v.sort_by(|a, b| b.total_cmp(a)); + cases.push(v); + } + for r in [Bx { x: 0.0, y: 0.0, w: 300.0, h: 200.0 }, Bx { x: 5.0, y: 7.0, w: 90.0, h: 400.0 }, Bx { x: 0.0, y: 0.0, w: 0.0, h: 50.0 }] { + for vals in &cases { + let items: Vec<(usize, f64)> = vals.iter().copied().enumerate().collect(); + let new: Vec<(usize, Bx)> = squarify(&items, r) + .into_iter() + .map(|(j, v, b)| { + assert!(vals.get(j).is_some_and(|x| x.to_bits() == v.to_bits()), "value carried through"); + (j, b) + }) + .collect(); + let old = squarify_reference(&items, r); + assert_eq!(format!("{new:?}"), format!("{old:?}"), "{vals:?} in {r:?}"); + } + } +}