diff --git a/ROADMAP.md b/ROADMAP.md index 72294aca8..a6b7dfae5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -187,6 +187,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values | | 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values | | 2026-10-14 | Border keytips: each Alt, H, B letter applies the preset Excel's Borders menu gives it (H,B,O bottom, H,B,B bottom double, H,B,N no border, H,B,A all borders and the rest); they all drew a bottom border before. H,B,M opens Format Cells on the Border tab | +| 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-13 | Formula-entry Command/Ctrl-click inserts another reference with the locale's argument separator; Shift-click extends from a stable, sheet-aware anchor. Pointer tests cover repeated clicks, reversed ranges, ordinary replacement, typed separators, Spanish syntax, sheet switches and keyboard continuation. Readiness estimates unchanged. | | 2026-10-13 | `formulas.evaluate` (`gridcraft-cli eval`, MCP `evaluate_formula`) refuses what entering the formula in a cell refuses, with the reason (`=SUM(1;2)`: "expected `,` or `)`"), instead of `#NAME?` for any formula that doesn't parse | | 2026-10-13 | Colour menus: Fill Color, Font Color and the Format Cells pickers end with Recent Colors (the last ten custom colours) and More Colors… (hue/saturation area, RGB, hex box, preview, OK/Cancel) (#928) | @@ -198,6 +199,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained | | 2026-10-12 | Insert › Text › WordArt (`insert.wordArt`, `wordArt.set`): floating decorative text with 10 original styles, outline and shadow, in-place editing, XLSX and print (#784) | | 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts | +| 2026-10-13 | Charts: work and memory are bounded by series x points (2,000,000 per chart: every series keeps its first points; 16 trendlines per chart), hairline columns and bars are merged per pixel column, series ranges are clipped before they are evaluated, the moving average is linear and rendering clean data no longer copies it (#1147) | | 2026-10-13 | Charts: one combo plot-kind rule for resolving and drawing; ticks of a scale off the step grid keep their own values (#1146) | | 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) | | 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) | diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 7f10354da..41ffca9c2 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -115,6 +115,12 @@ pub trait Host { fn visit_range(&mut self, _sheet: usize, _range: RangeRef, _f: &mut dyn FnMut(&Value) -> bool) -> bool { false } + /// `(sum, count)` of the numbers in a block that holds nothing but constants (no formula, + /// error or spilled value), added row-major from `0.0` exactly as `visit_range` would hand + /// them over. `None`: the block isn't like that, or this host can't tell. + fn range_sum(&mut self, _sheet: usize, _range: RangeRef) -> Option<(f64, u64)> { + None + } /// While a formula is calculated on demand inside another one: `stack_position()` where the /// outermost one started, which the stack budget is counted from. fn stack_top(&self) -> Option { diff --git a/crates/calc/src/eval/call.rs b/crates/calc/src/eval/call.rs index 08d563d43..8ac58c8d1 100644 --- a/crates/calc/src/eval/call.rs +++ b/crates/calc/src/eval/call.rs @@ -383,6 +383,15 @@ impl<'h> Evaluator<'h> { /// Adds one area to `acc`; `Err` is the result the function ends with. fn aggregate_area(&mut self, kind: Agg, a: &Area, acc: &mut Acc) -> Result<(), Ev> { if !a.range.is_single() { + // Adding the block's remembered sum to anything but +0.0 would add in another order. + // COUNT never reads the sum, so it may reuse every block. + if (kind == Agg::Count || acc.sum.to_bits() == 0) + && let Some((sum, count)) = self.host.range_sum(a.sheet, a.range) + { + acc.sum = sum; + acc.count = acc.count.saturating_add(count); + return Ok(()); + } let mut error = None; let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) { Ok(()) => true, diff --git a/crates/calc/src/recalc/pass_host.rs b/crates/calc/src/recalc/pass_host.rs index 9c56b2912..296d7ac81 100644 --- a/crates/calc/src/recalc/pass_host.rs +++ b/crates/calc/src/recalc/pass_host.rs @@ -13,6 +13,9 @@ const MAX_NESTED: usize = 64; /// evaluated again), however high Maximum Iterations is. const MAX_ITERATION_WORK: usize = 10_000_000; +/// A block's results by last row: its sum and count, or `None` when it can't be summed from constants. +type BlockSums = std::collections::BTreeMap>; + pub(super) struct PassHost<'a> { wb: &'a Workbook, exprs: &'a HashMap>, @@ -71,6 +74,10 @@ pub(super) struct PassHost<'a> { stack_top: usize, /// Whether an evaluation ran past the stack budget since the innermost formula started. out_of_stack: bool, + /// Sum and count of blocks that hold only constants, for `range_sum`. Keyed by sheet, first + /// cell and last column; each value maps a last row to the block's result (`None`: the block + /// has a formula, a Python cell, an error value or a spilled value, so every longer one has too). + sums: HashMap<(usize, CellRef, u32), BlockSums>, /// The kept results of the Python cells in the pass: a Python cell isn't evaluated, its /// result is laid out (an array spills from the cell, as a formula's does). Each one is in /// `pending` until it is laid out, so a formula that reads it first gets its new value. @@ -110,6 +117,7 @@ impl<'a> PassHost<'a> { precision: wb.calc.precision_as_displayed.then(crate::DisplayedPrecision::new), stack_top: 0, out_of_stack: false, + sums: HashMap::default(), python: HashMap::default(), } } @@ -671,6 +679,50 @@ impl Host for PassHost<'_> { } true } + fn range_sum(&mut self, sheet: usize, range: RangeRef) -> Option<(f64, u64)> { + // Constants can't change during a pass, so a block's result stays valid for it. Spills + // found in this pass (and iteration, which tracks what a read depends on) go the long way. + if self.iterate || self.spills.keys().any(|a| a.0 == sheet) { + return None; + } + let sh = self.wb.sheet(sheet)?; + let (key, last) = ((sheet, range.start, range.end.col), range.end.row); + let known = self.sums.entry(key).or_default(); + if let Some(r) = known.get(&last) { + return *r; + } + // Start from the longest shorter block of the same rows from the top: summing row-major + // from there on is the same additions in the same order as one walk from the start. + let (from, mut sum, mut count) = match known.range(..last).next_back() { + Some((_, None)) => { + known.insert(last, None); + return None; + } + Some((&row, &Some((sum, count)))) => (row + 1, sum, count), + None => (range.start.row, 0.0, 0), + }; + let rest = RangeRef::new(CellRef::new(from, range.start.col), range.end); + let mut plain = sh.spill.range(rest.start..=rest.end).all(|(c, _)| !rest.contains(*c)); + if plain { + for (_, cell) in sh.cells.iter_range(rest) { + match &cell.value { + _ if cell.formula.is_some() || cell.py.is_some() => plain = false, + Value::Error(_) => plain = false, + Value::Number(n) => { + sum += n; + count = u64::saturating_add(count, 1); + } + _ => {} + } + if !plain { + break; + } + } + } + let result = plain.then_some((sum, count)); + known.insert(last, result); + result + } fn used_range(&mut self, sheet: usize) -> Option { let wb = self.wb; *self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range())) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 7e871dee0..3f37196b2 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -2379,6 +2379,120 @@ fn loan_schedule_residue_keeps_excels_sign() { assert_eq!(t.get("P7"), Value::Text("".into())); } +/// `SUM`, `COUNT` and `AVERAGE` over blocks of constants share one pass-wide answer per block, and a +/// running total extends the shorter block's. Every result must equal the left-to-right sum of the +/// cells, bit for bit, with text, blanks, errors, formulas and a nonzero start in the mix. +#[test] +fn aggregates_over_ranges_match_a_plain_fold_bit_for_bit() { + struct Rng(u64); + impl Rng { + fn next(&mut self, n: u64) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 % n + } + } + let nums = [0.1, 0.2, 0.3, 1e16, -1e16, 1.0, 3.7, -0.0, 12345.678, 1e-9, 7.0]; + let rows = 40u32; + let mut rng = Rng(0x9E37_79B9_7F4A_7C15); + let mut wb = Workbook::new(); + let fresh = |rng: &mut Rng, plain: bool| match rng.next(if plain { 8 } else { 14 }) { + 0 => Cell::value(Value::text("t")), + 1 => Cell::default(), + 2 => Cell::value(Value::Bool(true)), + 3 if !plain => Cell::value(Value::Error(CellError::NA)), + 4 if !plain => Cell::formula(Formula::new("=A1*2")), + 5 if !plain => Cell::formula(Formula::new("=\"x\"&B2")), + _ => Cell::value(Value::Number(nums[rng.next(nums.len() as u64) as usize])), + }; + // Columns A:C mostly numbers; E holds a formula column and some errors only in a few rows. + for r in 0..rows { + for col in 0..3 { + let plain = !(r % 13 == 7 && col == 1); + wb.sheet_mut(0).unwrap().cells.set(CellRef::new(r, col), fresh(&mut rng, plain)); + } + } + // Formulas in column H and up: (function, optional leading number, blocks). + type Oracle<'a> = (CellRef, &'a str, Option, Vec<(u32, u32, u32, u32)>); + let mut oracle: Vec = vec![]; + let funcs = ["SUM", "COUNT", "AVERAGE"]; + let mut at = 0; + let mut add = |wb: &mut Workbook, f: &'static str, lead: Option, blocks: Vec<(u32, u32, u32, u32)>| { + let args: Vec = blocks + .iter() + .map(|&(r0, c0, r1, c1)| format!("{}{}:{}{}", (b'A' + c0 as u8) as char, r0 + 1, (b'A' + c1 as u8) as char, r1 + 1)) + .collect(); + let lead_s = lead.map(|l| format!("{l:e},")).unwrap_or_default(); + let cell = CellRef::new(at % rows, 7 + at / rows); + wb.sheet_mut(0).unwrap().cells.set(cell, Cell::formula(Formula::new(&format!("={f}({lead_s}{})", args.join(","))))); + oracle.push((cell, f, lead, blocks)); + at += 1; + }; + // Running totals over a clean column and over a column with an error further down. + for r in 0..rows { + add(&mut wb, "SUM", None, vec![(0, 0, r, 0)]); + add(&mut wb, "SUM", None, vec![(0, 1, r, 1)]); + add(&mut wb, "COUNT", None, vec![(0, 0, r, 2)]); + } + for _ in 0..120 { + let (r0, r1) = (rng.next(rows as u64) as u32, rng.next(rows as u64) as u32); + let (c0, c1) = (rng.next(3) as u32, rng.next(3) as u32); + let n = 1 + rng.next(2) as usize; + let blocks = (0..n).map(|_| (r0.min(r1), c0.min(c1), r0.max(r1), c0.max(c1))).collect(); + let lead = [None, Some(1e16), Some(0.5), Some(2.0)][rng.next(4) as usize]; + let f = funcs[rng.next(3) as usize]; + add(&mut wb, f, lead, blocks); + } + let check = |wb: &Workbook, when: &str| { + let sh = wb.sheet(0).unwrap(); + for (cell, f, lead, blocks) in &oracle { + let (mut sum, mut count, mut error) = (lead.unwrap_or(0.0), u64::from(lead.is_some()), None); + for &(r0, c0, r1, c1) in blocks { + for r in r0..=r1 { + for col in c0..=c1 { + match sh.value(CellRef::new(r, col)) { + Value::Number(n) => { + sum += n; + count += 1; + } + Value::Error(e) if *f != "COUNT" => error = error.or(Some(e)), + _ => {} + } + } + } + } + let want = match (error, *f) { + (Some(e), _) => Value::Error(e), + (_, "SUM") => Value::number(sum), + (_, "COUNT") => Value::number(count as f64), + (_, _) if count == 0 => Value::Error(CellError::Div0), + _ => Value::number(sum / count as f64), + }; + let got = sh.value(*cell); + let same = match (&got, &want) { + (Value::Number(a), Value::Number(b)) => a.to_bits() == b.to_bits(), + (a, b) => a == b, + }; + assert!(same, "{when}: {:?} = {:?}, plain fold {:?}", cell, got, want); + } + }; + let mut calc = Calc::new(); + calc.rebuild(&wb); + calc.recalc_all(&mut wb); + check(&wb, "full recalc"); + for round in 0..30 { + let mut changed = vec![]; + for _ in 0..1 + rng.next(4) { + let at = CellRef::new(rng.next(rows as u64) as u32, rng.next(3) as u32); + wb.sheet_mut(0).unwrap().cells.set(at, fresh(&mut rng, round % 2 == 0)); + changed.push((0, at)); + } + calc.cells_changed(&mut wb, &changed); + check(&wb, &format!("edit round {round}")); + } +} + /// `rebuild` and `cells_changed` read formulas through `Formula::with_expr`, which borrows the /// cached tree or parses on the fly. The dependency graph must come out the same for formulas that /// carry their tree and for formulas that only carry their text (as after loading a file). diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 35ccf4551..c3f284bd9 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -1,11 +1,12 @@ //! Chart layout: title, legend, axes and every chart kind, drawn into [`Prim`]s. +use std::borrow::Cow; use std::f32::consts::{PI, TAU}; use gridcraft_model::{Chart, ChartKind, LineDash, QuartileMethod, Theme, TrendlineKind}; use crate::axis::{Fmt, Scale, clean, nice_scale}; -use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color}; +use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, points_per_series, series_color}; use crate::trend::{auto_name, fit_trendline, moving_average}; use crate::{ChartData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign}; @@ -44,6 +45,8 @@ const MAX_LINE_PTS: usize = 2000; const MAX_MARKERS: usize = 5000; /// Data labels are skipped when a series has more points than this. const MAX_LABELS: usize = 200; +/// Columns and bars narrower than this (in pixels) are merged per pixel column into one rectangle. +const MIN_BAR_PX: f32 = 0.25; /// Most wedges a doughnut draws for the whole chart. The per-series and per-category limits do not /// bound their product: 255 series of 20,000 categories would be millions of wedges. Every drawable /// ring shares this budget, so the innermost ring keeps its largest slices and the outer rings are @@ -55,25 +58,53 @@ 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. + /// A label with a line break is not monotone (the ellipsis moves to a new line and the widest + /// line can get shorter), so it takes the linear scan, longest prefix first. 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 + }; + if s.contains('\n') { + let k = (1..=top).rev().find(|&k| fits(k)); + return k.map_or_else(String::new, |k| format!("{}…", s.get(..starts.get(k).copied().unwrap_or(s.len())).unwrap_or(""))); + } + 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("")) } } fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, (align, valign): (HAlign, VAlign)) { if s.is_empty() { @@ -101,7 +132,7 @@ fn sanitize_vals(v: &[Option]) -> Vec> { v.iter().take(MAX_POINTS).map(|x| x.filter(|n| n.is_finite()).map(clean)).collect() } -fn sanitize(d: &ChartData) -> ChartData { +pub(crate) fn sanitize(d: &ChartData) -> ChartData { ChartData { palette: d.palette.iter().take(MAX_POINTS).copied().collect(), categories: d.categories.iter().take(MAX_POINTS).cloned().collect(), @@ -126,12 +157,62 @@ fn sanitize(d: &ChartData) -> ChartData { } } +/// Whether `v` survives `sanitize_vals` unchanged. +fn is_clean(v: &Option) -> bool { + v.is_none_or(|n| n.is_finite() && clean(n) == n) +} + +/// Whether [`sanitize`] and [`apply_budget`] would change `d`. +fn needs_sanitizing(d: &ChartData) -> bool { + let cap = points_per_series(d.series.len()); + let long = |n: usize| n > cap; + let mut trendlines = 0usize; + d.palette.len() > MAX_POINTS + || long(d.categories.len()) + || d.category_levels.len() > 3 + || d.category_levels.iter().any(|l| l.len() > MAX_POINTS) + || d.series.len() > MAX_SERIES + || d.series.iter().any(|s| { + trendlines += s.trendlines.len(); + long(s.values.len()) + || !s.values.iter().all(is_clean) + || s.x.as_ref().is_some_and(|x| long(x.len()) || !x.iter().all(is_clean)) + || s.sizes.as_ref().is_some_and(|x| long(x.len()) || !x.iter().all(is_clean)) + || s.trendlines.len() > MAX_TRENDLINES + }) + || trendlines > MAX_TRENDLINES +} + +/// Enforces the chart's budget of points (`MAX_TOTAL_POINTS` = series x points: every series and +/// the category list keep their first `MAX_TOTAL_POINTS / series` points) and of trendlines +/// (`MAX_TRENDLINES` per chart, in series order). +fn apply_budget(mut d: ChartData) -> ChartData { + let cap = points_per_series(d.series.len()); + d.categories.truncate(cap); + let mut trendlines = MAX_TRENDLINES; + for s in &mut d.series { + s.values.truncate(cap); + for v in [&mut s.x, &mut s.sizes].into_iter().flatten() { + v.truncate(cap); + } + s.trendlines.truncate(trendlines); + trendlines = trendlines.saturating_sub(s.trendlines.len()); + } + d +} + +/// The data `render` draws: `d` itself when it is already clean and within budget (the usual +/// case: no copy), else a cleaned, truncated copy. +pub(crate) fn sanitized(d: &ChartData) -> Cow<'_, ChartData> { + if needs_sanitizing(d) { Cow::Owned(apply_budget(sanitize(d))) } else { Cow::Borrowed(d) } +} + /// Lays out and draws a chart into a `w`×`h` box at the origin. Empty for a degenerate box. pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure) -> Vec { if !(w.is_finite() && h.is_finite()) || w < 1.0 || h < 1.0 || w > 1e7 || h > 1e7 { return vec![]; } - let data = sanitize(data); + let data = sanitized(data); let mut ctx = Ctx { out: Vec::new(), m }; ctx.out.push(Prim::Rect { x: 0.0, y: 0.0, w, h, fill: Some(WHITE), stroke: Some((GRID, 1.0)), radius: 0.0 }); let pad = (w.min(h) * 0.04).clamp(2.0, 10.0); diff --git a/crates/chart/src/render/cartesian.rs b/crates/chart/src/render/cartesian.rs index 2d2afd366..8639409e4 100644 --- a/crates/chart/src/render/cartesian.rs +++ b/crates/chart/src/render/cartesian.rs @@ -3,8 +3,8 @@ use super::legend::{cat_label, plot_kind}; use super::lines::{decimate_area, draw_markers, draw_runs}; use super::trend::{draw_trend_labels, draw_trendlines, max_backward, max_forward, max_forward_slots, supports_trendline}; use super::{ - Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, Prim, Scale, SeriesData, TEXT, - VAlign, WHITE, clean, nice_scale, + Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, MIN_BAR_PX, Prim, Rgba, Scale, + SeriesData, TEXT, VAlign, WHITE, clean, nice_scale, }; // ---------------------------------------------------------------- cartesian (column/bar/line/area/combo) @@ -51,6 +51,110 @@ fn is_line(f: Fam) -> bool { matches!(f, Fam::Line | Fam::LineStack) } +/// Bars that fall into one pixel column, merged: extent along the category axis (`a0..=a1`, the +/// bars' start positions) and along the value axis (`lo..hi`). +struct BarRun { + bucket: f32, + a0: f32, + a1: f32, + lo: f32, + hi: f32, + /// More than one bar was merged. + merged: bool, +} + +impl BarRun { + fn new(pos: f32, lo: f32, hi: f32) -> Self { + BarRun { bucket: pos.floor(), a0: pos, a1: pos, lo, hi, merged: false } + } + /// Whether `lo..hi` touches the run's value span; a disjoint bar would be joined by a filled gap. + fn touches(&self, lo: f32, hi: f32) -> bool { + lo <= self.hi && hi >= self.lo + } + fn add(&mut self, pos: f32, lo: f32, hi: f32) { + self.merged = true; + self.a0 = self.a0.min(pos); + self.a1 = self.a1.max(pos); + self.lo = self.lo.min(lo); + self.hi = self.hi.max(hi); + } + /// `band` is the part of the pixel column reserved for this series (`start`, `len`): in a + /// clustered chart a merged run stays inside it, so the other series keep a visible strip. + fn draw(&self, ctx: &mut Ctx, bw: f32, horizontal: bool, color: Rgba, band: Option<(f32, f32)>) { + let (a0, along) = match band { + Some((start, len)) if self.merged => (self.bucket + start, len), + _ => (self.a0, self.a1 - self.a0 + bw), + }; + if horizontal { + ctx.rect(self.lo, a0, self.hi - self.lo, along, color, None); + } else { + ctx.rect(a0, self.lo, along, self.hi - self.lo, color, None); + } + } +} + +/// Whether bars of width `bw` are merged per pixel column (never when they carry data labels). +fn merge_hairlines(bw: f32, labels_on: bool) -> bool { + bw < MIN_BAR_PX && !labels_on +} + +/// Draws the bars of one series: a rectangle each, or, for hairline bars, one merged [`BarRun`] +/// per pixel column. +struct BarSink { + run: Option, + bw: f32, + horizontal: bool, + color: Rgba, + nslots: f32, + k: f32, +} + +impl BarSink { + fn new(bw: f32, horizontal: bool, color: Rgba, nslots: f32, k: f32) -> Self { + BarSink { run: None, bw, horizontal, color, nslots, k } + } + /// Start of this series' slot along the category axis, for the category centered at `c`. + fn pos(&self, c: f32) -> f32 { + if self.horizontal { c + self.nslots * self.bw / 2.0 - (self.k + 1.0) * self.bw } else { c - self.nslots * self.bw / 2.0 + self.k * self.bw } + } + /// This series' strip of a pixel column (offset, length) when the columns hold several + /// clustered series, in the direction the slots run. + fn band(&self) -> Option<(f32, f32)> { + (self.nslots > 1.0).then(|| { + let idx = if self.horizontal { self.nslots - 1.0 - self.k } else { self.k }; + (idx / self.nslots, 1.0 / self.nslots) + }) + } + /// Draws the bar at category center `c` spanning `lo..lo + len` on the value axis, or, when + /// `merge` is set, adds it to the current run. + fn bar(&mut self, ctx: &mut Ctx, merge: bool, c: f32, lo: f32, len: f32) { + let pos = self.pos(c); + let band = self.band(); + if !merge { + if self.horizontal { + ctx.rect(lo, pos, len, self.bw, self.color, None); + } else { + ctx.rect(pos, lo, self.bw, len, self.color, None); + } + return; + } + match &mut self.run { + Some(m) if m.bucket == pos.floor() && m.touches(lo, lo + len) => m.add(pos, lo, lo + len), + run => { + if let Some(m) = run.replace(BarRun::new(pos, lo, lo + len)) { + m.draw(ctx, self.bw, self.horizontal, self.color, band); + } + } + } + } + fn finish(self, ctx: &mut Ctx) { + let band = self.band(); + if let Some(m) = self.run { + m.draw(ctx, self.bw, self.horizontal, self.color, band); + } + } +} + fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Vec>> { // Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart). let mut totals: Vec<((bool, Fam), Vec)> = Vec::new(); @@ -141,7 +245,8 @@ fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec