diff --git a/Cargo.lock b/Cargo.lock index 2357dfd13..df08f7758 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -910,12 +910,12 @@ dependencies = [ [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "fixed_decimal", "icu_decimal", @@ -938,7 +938,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "craft-winprint-gdi", "log", @@ -952,7 +952,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -972,7 +972,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "chrono", "dunce", @@ -995,10 +995,21 @@ dependencies = [ "zip 9.1.0", ] +[[package]] +name = "craft-web-api" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505" +dependencies = [ + "js-sys", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "craft-web-host" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6" +source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505" dependencies = [ "wasm-bindgen", "web-sys", @@ -1007,7 +1018,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "egui", "objc2 0.6.5", @@ -1018,7 +1029,7 @@ dependencies = [ [[package]] name = "craft-winprint-gdi" version = "0.1.0" -source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" +source = "git+https://github.com/storytold/winprint.rs?rev=64e7afd4d1483512385012c340ecaaaf0f3beb7f#64e7afd4d1483512385012c340ecaaaf0f3beb7f" dependencies = [ "windows-sys 0.61.2", ] @@ -2195,6 +2206,7 @@ dependencies = [ name = "gridcraft-web" version = "0.17.0" dependencies = [ + "craft-web-api", "craft-web-host", "eframe", "egui", @@ -6473,7 +6485,7 @@ dependencies = [ [[package]] name = "winprint" version = "0.2.1" -source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49" +source = "git+https://github.com/storytold/winprint.rs?rev=51934db382f225454cf152a8657eb03789be63c4#51934db382f225454cf152a8657eb03789be63c4" dependencies = [ "flate2", "fmt-derive", @@ -6923,7 +6935,6 @@ dependencies = [ "nix", "typed-path", "windows-sys 0.61.2", - "zopfli", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 165b6a5f5..f7abab2d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,9 +26,9 @@ gridcraft-mcp = { path = "crates/mcp" } gridcraft-pdf = { path = "crates/pdf" } gridcraft-math = { path = "crates/math" } # Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in -# the app) comes from one storytold/craft-libs revision. +# the app) comes from one craft-libs revision. # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", features = ["egui"] } # foundation thiserror = "2" @@ -71,7 +71,7 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] } epaint_default_fonts = "0.37" # One native window per workbook: the window list, Window menu titles and the immediate-viewport # driver. Same craft-libs revision as craft-update above. -craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false, features = ["egui"] } +craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", default-features = false, features = ["egui"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/ROADMAP.md b/ROADMAP.md index 59b3bf2d8..340bb2d06 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-11-06 | A sheet keeps its own scroll position when sheets are inserted, deleted or moved before it: a new PivotTable or inserted sheet opens at the top instead of where its neighbour was scrolled; a closed window's positions are let go (#3485) | | 2026-11-02 | Form controls: Insert › Controls places buttons, sliders (scroll bars) and spin buttons on the sheet. A slider or spin button holds a whole number from 0 to 30000 in a linked cell (a drag or a held arrow is one undo step, and editing the cell moves the control); a button runs a macro or one command through the macro policy, asking first in a workbook opened from a file; Assign Action and Format Control edit them, and XLSX files read and write them (control properties, `` and VML), alongside the form controls GridCraft keeps unchanged | | 2026-11-02 | Dialogs take ranges by selection on the grid: reference boxes (RefEdit) with a collapse button that shrinks the dialog while the person selects, in Goal Seek, Solver (including the Model Range of Load/Save) and Data Analysis; while collapsed, the arrow keys move the selection and Shift+arrow extends it, and F4 cycles a reference's anchors | | 2026-11-02 | Python mode in the formula bar: Ctrl+Alt+Shift+P, `=PY` then Tab, or editing a Python cell edits its code in the formula bar, with Enter for a new line, Tab to indent and Ctrl+Enter to commit through `python.set`. The code still doesn't run | @@ -186,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) | @@ -196,6 +198,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) | | 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-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) | | 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) | @@ -259,6 +263,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-11-06 | trivial | Progress log: scroll positions follow their sheet through sheet inserts, deletes and moves (#3485) | | 2026-11-02 | minor | Form controls; catalog 277 / 292 → 281 / 296 (Developer › Controls); progress log row | | 2026-11-02 | minor | Progress log: RefEdit reference boxes in dialogs | | 2026-11-02 | minor | Progress log: Python mode in the formula bar | diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml index e5c599bd8..09c6d7e2a 100644 --- a/apps/gridcraft-web/Cargo.toml +++ b/apps/gridcraft-web/Cargo.toml @@ -17,7 +17,9 @@ path = "src/main.rs" gridcraft-engine = { workspace = true } gridcraft-ui-egui = { workspace = true } # The host messages (storytold/craft-libs): one contract and one bridge for the web builds. -craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" } +# The page command API (storytold/craft-libs#413): window.gridcraft over the control channel. +craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } +craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } egui = { workspace = true } log = { workspace = true } serde_json = { workspace = true } diff --git a/apps/gridcraft-web/src/api.rs b/apps/gridcraft-web/src/api.rs new file mode 100644 index 000000000..34aca8c18 --- /dev/null +++ b/apps/gridcraft-web/src/api.rs @@ -0,0 +1,151 @@ +//! `window.gridcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the +//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`). +//! +//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`, +//! `screenshot`, as promises with a timeout). This module is GridCraft's part: a [`Request`] that +//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the +//! reply. A call the page gave up on is released ([`Pending`]), so the app skips it if it has not started it, instead of +//! applying a command the page was told had failed. + +use std::sync::Arc; +use std::sync::atomic::AtomicU8; +use std::sync::mpsc::{Receiver, TryRecvError}; + +use gridcraft_ui_egui::ControlRequest; +use serde_json::{Value, json}; + +/// How often a waiting call looks for the app's reply, in milliseconds (about a frame). +#[cfg(target_arch = "wasm32")] +const POLL_MS: i32 = 16; + +/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's +/// timeout fired, or the call was abandoned) releases the request: the app skips it if it has not started it (one it has taken still finishes). +struct Pending { + reply: Receiver, + state: Arc, + answered: bool, +} + +impl Pending { + /// `Some` once the app has answered or gone away; `None` while the request is still waiting. + fn poll(&mut self) -> Option { + match self.reply.try_recv() { + Ok(reply) => { + self.answered = true; + Some(reply) + } + Err(TryRecvError::Empty) => None, + Err(TryRecvError::Disconnected) => { + self.answered = true; + Some(json!({"ok": false, "error": "the app dropped the request"})) + } + } + } +} + +impl Drop for Pending { + fn drop(&mut self) { + if !self.answered { + ControlRequest::abandon(&self.state); + } + } +} + +#[cfg(target_arch = "wasm32")] +pub use web::install; + +#[cfg(target_arch = "wasm32")] +mod web { + use std::rc::Rc; + use std::sync::mpsc::channel; + + use craft_web_api::methods::Methods; + use craft_web_api::page::{PageApi, Request}; + use gridcraft_ui_egui::{ControlRequest, SheetApp}; + use serde_json::json; + use wasm_bindgen_futures::JsFuture; + + use super::{POLL_MS, Pending}; + + /// The app id in `window.`. + const APP: &str = "gridcraft"; + + /// Give the app its control queue and put `window.gridcraft` on the page. An existing + /// `window.gridcraft` keeps the functions it already has. + pub fn install(app: &mut SheetApp, ctx: egui::Context) { + let (queue, requests) = channel::(); + app.control_rx = Some(requests); + let request: Request = Rc::new(move |method, params| { + let (control, reply) = ControlRequest::new(method, params); + let mut pending = Pending { reply, state: control.state.clone(), answered: false }; + if queue.send(control).is_err() { + pending.answered = true; + return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) }); + } + ctx.request_repaint(); + Box::pin(async move { + loop { + if let Some(reply) = pending.poll() { + return reply; + } + sleep(POLL_MS).await; + } + }) + }); + if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() { + log::warn!("couldn't install window.{APP}: {e:?}"); + } + } + + async fn sleep(ms: i32) { + let promise = js_sys::Promise::new(&mut |resolve, _| { + if let Some(window) = web_sys::window() { + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms); + } + }); + let _ = JsFuture::from(promise).await; + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::Ordering; + + use super::*; + + /// `ControlRequest`'s `state` values (`gridcraft_ui_egui::control`): the app has started it, or its + /// sender gave up. + const TAKEN: u8 = 1; + const ABANDONED: u8 = 2; + + fn pending() -> (Pending, ControlRequest) { + let (control, reply) = ControlRequest::new("document.inspect", json!({})); + (Pending { reply, state: control.state.clone(), answered: false }, control) + } + + #[test] + fn a_call_the_page_gave_up_on_is_released_so_the_app_skips_it() { + let (waiting, control) = pending(); + drop(waiting); + assert_eq!(control.state.load(Ordering::SeqCst), ABANDONED); + assert!(!control.take(), "the app must not run a request the page was told had failed"); + } + + #[test] + fn an_answered_call_is_not_released() { + let (mut waiting, control) = pending(); + assert!(control.take()); + control.reply.send(json!({"ok": true, "result": 1})).unwrap_or_default(); + assert_eq!(waiting.poll(), Some(json!({"ok": true, "result": 1}))); + drop(waiting); + assert_eq!(control.state.load(Ordering::SeqCst), TAKEN); + } + + #[test] + fn a_request_the_app_never_runs_waits_until_it_is_dropped() { + let (mut waiting, control) = pending(); + assert_eq!(waiting.poll(), None); + drop(control); + assert_eq!(waiting.poll().and_then(|r| r["ok"].as_bool()), Some(false), "the app went away"); + } +} diff --git a/apps/gridcraft-web/src/main.rs b/apps/gridcraft-web/src/main.rs index 2c701feb4..823a68e9b 100644 --- a/apps/gridcraft-web/src/main.rs +++ b/apps/gridcraft-web/src/main.rs @@ -13,11 +13,16 @@ //! workbook to open and stores what GridCraft saves (`host`). A page can host GridCraft in its //! own document (no iframe) with `data-host="page"` on the canvas, talking over DOM `CustomEvent`s //! on the canvas. `&author=` names the user for notes and comments in either host mode. +//! `window.gridcraft` (`craft-web-api`) is the page command API in every mode: `request`, +//! `execute`, `commands`, `docInspect`, `uiInspect` and `screenshot` over the control methods +//! (`api`). //! `?chrome=none|notitlebar|nofiletab` leaves the title bar and/or the File tab undrawn for a host //! that embeds the sheet in its own window (see `Chrome`). Preferences (theme, interface size, //! editing options…) are kept in `localStorage` (`prefs`). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(any(target_arch = "wasm32", test))] +mod api; #[cfg(target_arch = "wasm32")] mod host; #[cfg(any(target_arch = "wasm32", test))] diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index 268560520..1108c4c11 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -99,6 +99,7 @@ pub fn start() { } bridge.post_ready(); } + crate::api::install(&mut app, cc.egui_ctx.clone()); Ok(Box::new(WebShell { app, inbox, bridge: app_bridge, prefs })) }), ) diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index a7472821f..75acee3ff 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -34,7 +34,7 @@ winit = { version = "0.30", default-features = false, features = ["x11", "waylan [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Same craft-libs revision as the workspace's craft-update and craft-window (see the root # Cargo.toml). -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" } craft-update = { workspace = true } rfd = { workspace = true } arboard = { workspace = true, features = ["image-data"] } diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 2aa98b6eb..cc67e241a 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -64,6 +64,9 @@ impl eframe::App for App { fn on_exit(&mut self) { print::finish_pending_jobs(); save_prefs(&self.0); + // A ready update installs once the process has exited. The macOS exit below skips + // dropping the app, so the helper is started here. + self.0.start_pending_update(); // macOS (#117): end the process here, before eframe tears the window down. AccessKit's // macOS adapter subclasses the window's content view and restores its class on teardown; // AppKit's Touch Bar support observes that view (KVO), so in the next display cycle diff --git a/contributors/people.toml b/contributors/people.toml index 00f6ce76a..1b8bcf996 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -33,7 +33,8 @@ sync_github_name = false [people.vangel76] real_name = "Valentin Klein" -sync_github_name = true +display_name = "vangel76" +sync_github_name = false [people.MacJedi42] sync_github_name = true 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/lib.rs b/crates/calc/src/lib.rs index 2cc6bf4cf..e30cff29f 100644 --- a/crates/calc/src/lib.rs +++ b/crates/calc/src/lib.rs @@ -6,6 +6,7 @@ pub mod eval; mod precision; pub mod recalc; +mod spill_index; mod trimrange; pub use eval::{Area, SPECIAL_FUNCTIONS, is_known_function}; diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 160e0d60d..2b2ae7019 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -10,13 +10,24 @@ //! and the formulas that read one are evaluated again until the loop settles //! (`PassHost::iterate_loops`). -use std::collections::VecDeque; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value}; use gridcraft_formula::Expr; use gridcraft_model::{CalcMode, Workbook}; use crate::eval::{Evaluator, Host, precedents, stack_position}; +use crate::spill_index::{SpillIndex, indexing_pays}; + +/// Changed cells (in one `cells_changed`) from which a sheet's spill areas may be indexed. +const INDEX_SPILLS_FROM: usize = 16; + +/// Whether a sheet's spill areas are indexed for a batch of `batch` changed cells of which +/// `on_sheet` are on that sheet: the batch must be a paste, and registering the areas must cost +/// less than walking them once for each of the sheet's own changed cells. +fn index_spills<'a>(batch: usize, on_sheet: usize, areas: impl ExactSizeIterator) -> bool { + batch > INDEX_SPILLS_FROM && areas.len() > 0 && indexing_pays(areas, on_sheet) +} mod graph; mod pass_host; @@ -37,11 +48,61 @@ pub struct Calc { /// Cells whose formulas have errors from cycles. pub circular: Vec, pub last_recalc_cells: usize, + /// The cells whose value is `#SPILL!`, kept so that finding the formulas an emptied cell or a + /// freed spill area may unblock doesn't walk every formula or window of cells. Filled in + /// `rebuild`, kept up to date by `cells_changed` and by the write-back of a run; an entry is + /// always checked against the workbook before it is used. + spill_errors: SpillErrors, +} + +/// A set of cells by row, each row's columns in their own set: a window of rows and columns is +/// found by one column range per row that has an entry, so the cost follows the cells in the +/// window, not those to the side of it in the same rows. +#[derive(Clone, Debug, Default)] +struct SpillErrors { + rows: BTreeMap<(usize, u32), BTreeSet>, +} + +impl SpillErrors { + fn is_empty(&self) -> bool { + self.rows.is_empty() + } + + fn clear(&mut self) { + self.rows.clear(); + } + + fn insert(&mut self, (sheet, c): Key) { + self.rows.entry((sheet, c.row)).or_default().insert(c.col); + } + + fn remove(&mut self, (sheet, c): &Key) { + if let Some(cols) = self.rows.get_mut(&(*sheet, c.row)) { + cols.remove(&c.col); + if cols.is_empty() { + self.rows.remove(&(*sheet, c.row)); + } + } + } + + /// The cells of `sheet` in `rows` x `cols` (both inclusive), in row-major order. + fn range(&self, sheet: usize, rows: (u32, u32), cols: (u32, u32)) -> impl Iterator + '_ { + self.rows + .range((sheet, rows.0)..=(sheet, rows.1)) + .flat_map(move |(&(_, row), set)| set.range(cols.0..=cols.1).map(move |&col| CellRef::new(row, col))) + } } impl Default for Calc { fn default() -> Self { - Calc { graph: Graph::default(), rng: 0x9E37_79B9_7F4A_7C15, fixed_now: None, circular: vec![], last_recalc_cells: 0 } + Calc { + graph: Graph::default(), + rng: 0x9E37_79B9_7F4A_7C15, + fixed_now: None, + circular: vec![], + last_recalc_cells: 0, + spill_errors: SpillErrors::default(), + } } } @@ -125,12 +186,15 @@ impl Calc { pub fn rebuild(&mut self, wb: &Workbook) { self.graph = Graph::default(); let mut circ = Vec::new(); + self.spill_errors.clear(); for (si, sheet) in wb.sheets.iter().enumerate() { for (c, cell) in sheet.cells.iter() { + if cell.value == Value::Error(CellError::Spill) { + self.spill_errors.insert((si, c)); + } if let Some(f) = &cell.formula - && let Some(e) = f.expr() + && let Some((areas, dynamic)) = f.with_expr(|e| precedents(wb, si, e)) { - let (areas, dynamic) = precedents(wb, si, &e); self.graph.insert((si, c), Node { areas, dynamic }); if cell.value == Value::Error(CellError::Circ) { circ.push((si, c)); @@ -188,6 +252,20 @@ impl Calc { out } + /// The cells of `sheet` in `rows` x `cols` (inclusive, `cols` a lower and an upper column) that + /// show `#SPILL!`, in row-major order. + fn spill_errors_in(&self, wb: &Workbook, sheet: usize, rows: (u32, u32), cols: (u32, u32)) -> Vec { + let Some(sh) = wb.sheet(sheet) else { return Vec::new() }; + self.spill_errors.range(sheet, rows, cols).filter(|c| sh.cells.get(*c).is_some_and(|x| x.value == Value::Error(CellError::Spill))).collect() + } + + /// The formulas showing `#SPILL!` on `sheet` at or above and to the left of `end`: what a + /// cell emptied or a spill area freed there may unblock. + fn unblock_candidates(&self, wb: &Workbook, sheet: usize, end: CellRef) -> Vec { + let cells = self.spill_errors_in(wb, sheet, (0, end.row), (0, end.col)); + cells.into_iter().filter(|c| self.graph.nodes.contains_key(&(sheet, *c))).map(|c| (sheet, c)).collect() + } + /// Recalculates every formula (F9 / Ctrl+Alt+F9, and after loading). pub fn recalc_all(&mut self, wb: &mut Workbook) { self.rebuild(wb); @@ -209,14 +287,19 @@ impl Calc { pub fn cells_changed(&mut self, wb: &mut Workbook, changed: &[Key]) { let mut cleared: Vec<(usize, RangeRef)> = Vec::new(); for &k in changed { - let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone()); + if wb.sheet(k.0).and_then(|s| s.cell(k.1)).is_some_and(|c| c.value == Value::Error(CellError::Spill)) { + self.spill_errors.insert(k); + } else { + self.spill_errors.remove(&k); + } + let precedent = + wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.with_expr(|e| precedents(wb, k.0, e))); let py = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.py.as_ref()); let python = py.is_some(); // A Python cell whose result (if any) is not an array spills nothing. let spills = py.and_then(|p| p.result_value()).is_some_and(|v| matches!(v, Value::Array(_))); - match formula.and_then(|f| f.expr()) { - Some(e) => { - let (areas, dynamic) = precedents(wb, k.0, &e); + match precedent { + Some((areas, dynamic)) => { self.graph.insert(k, Node { areas, dynamic }); } // See `rebuild`: its spill is laid out again from its result when it is calculated. @@ -249,6 +332,7 @@ impl Calc { && cell.formula.is_some() { cell.value = Value::Error(CellError::Name); + self.spill_errors.remove(&k); } } } @@ -264,17 +348,40 @@ impl Calc { let mut emptied: Vec = Vec::new(); // Typing into (or clearing) a cell a dynamic array spills over (or would spill over) // re-evaluates the anchor, which may now be blocked or unblocked. + // With many changed cells (a paste) a sheet's spill areas are indexed once instead of being + // walked for each one, when registering them costs less than walking them would. + let mut areas = SpillIndex::default(); + let mut strategy: HashMap = HashMap::default(); + let mut per_sheet: HashMap = HashMap::default(); + for k in changed { + *per_sheet.entry(k.0).or_insert(0) += 1; + } + let mut at = Vec::new(); for &k in changed { if let Some(sh) = wb.sheet(k.0) { - for (anchor, r) in &sh.spill_ranges { - if *anchor != k.1 && r.contains(k.1) { - seeds.push((k.0, *anchor)); + let indexed = *strategy.entry(k.0).or_insert_with(|| { + let indexed = index_spills(changed.len(), per_sheet[&k.0], sh.spill_ranges.values()); + if indexed { + for (anchor, r) in &sh.spill_ranges { + areas.insert_rect((k.0, *anchor), *r, false); + } + } + indexed + }); + if indexed { + // Anchor order, as walking `spill_ranges` gives. + areas.meeting(k.0, RangeRef::new(k.1, k.1), &mut at); + seeds.extend(at.iter().filter(|a| **a != k.1).map(|a| (k.0, *a))); + } else { + for (anchor, r) in &sh.spill_ranges { + if *anchor != k.1 && r.contains(k.1) { + seeds.push((k.0, *anchor)); + } } } - for (c, cell) in sh.cells.iter_range(RangeRef::new(CellRef::new(k.1.row.saturating_sub(64), k.1.col.saturating_sub(64)), k.1)) { - if c != k.1 && cell.value == Value::Error(CellError::Spill) { - seeds.push((k.0, c)); - } + if !self.spill_errors.is_empty() { + let (r0, c0) = (k.1.row.saturating_sub(64), k.1.col.saturating_sub(64)); + seeds.extend(self.spill_errors_in(wb, k.0, (r0, k.1.row), (c0, k.1.col)).into_iter().filter(|c| *c != k.1).map(|c| (k.0, c))); } if !sh.cells.has(k.1) { match emptied.iter_mut().find(|x| x.0 == k.0) { @@ -286,11 +393,7 @@ impl Calc { } // An emptied cell can unblock an anchor any distance above or to the left of it. for (si, end) in emptied { - for &(s, c) in self.graph.nodes.keys() { - if s == si && c.row <= end.row && c.col <= end.col && wb.sheet(s).is_some_and(|sh| sh.value(c) == Value::Error(CellError::Spill)) { - seeds.push((s, c)); - } - } + seeds.extend(self.unblock_candidates(wb, si, end)); } // Spill areas of changed anchors also change. They stay rectangles: one seed per spilled // cell listed the formulas reading the area once per cell. @@ -454,10 +557,15 @@ impl Calc { let mut freed = Vec::new(); for (key, value) in results { let Some(sheet) = wb.sheet_mut(key.0) else { continue }; - if let Some(cell) = sheet.cells.get_mut(key.1) - && cell.value != value - { - cell.value = value; + if let Some(cell) = sheet.cells.get_mut(key.1) { + if value == Value::Error(CellError::Spill) { + self.spill_errors.insert(key); + } else { + self.spill_errors.remove(&key); + } + if cell.value != value { + cell.value = value; + } } } for key in dirty { @@ -487,6 +595,7 @@ impl Calc { if blocked { if let Some(cell) = sheet.cells.get_mut(key.1) { cell.value = Value::Error(CellError::Spill); + self.spill_errors.insert(*key); } let mut blocked_dependents = Vec::new(); self.graph.dependents_of_range(key.0, range, &mut blocked_dependents); @@ -513,14 +622,7 @@ impl Calc { } } for (sheet_index, range) in freed { - for &(sheet, cell) in self.graph.nodes.keys() { - let was_blocked_cell = cell.row <= range.end.row - && cell.col <= range.end.col - && wb.sheet(sheet).is_some_and(|sheet| sheet.value(cell) == Value::Error(CellError::Spill)); - if sheet == sheet_index && was_blocked_cell { - spill_changes.push((sheet, cell)); - } - } + spill_changes.extend(self.unblock_candidates(wb, sheet_index, range.end)); } spill_changes } @@ -577,7 +679,7 @@ pub(crate) mod tests { use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_model::{Cell, Formula, Workbook}; - use super::{Calc, HashMap, Host, Intervals, Key, PassHost}; + use super::{Calc, HashMap, Host, Intervals, Key, PassHost, index_spills}; /// xorshift64 for repeatable pseudo-random cases. fn rng(state: &mut u64) -> u64 { @@ -594,6 +696,19 @@ pub(crate) mod tests { v } + /// One changed cell on a sheet with many small spills does not index them just because the + /// batch (mostly on another sheet) is large; the sheet's own share of the batch decides. + #[test] + fn the_spill_index_is_chosen_from_the_sheets_own_changed_cells() { + let small: Vec = (0..1_000).map(|i| RangeRef::new(CellRef::new(i * 3, 0), CellRef::new(i * 3 + 2, 0))).collect(); + // Same areas, same batch of 5000: indexed when 5000 of them are on the sheet, walked when 1 is. + assert!(index_spills(5_000, 5_000, small.iter())); + assert!(!index_spills(5_000, 1, small.iter())); + // Not a paste, or nothing to index: never. + assert!(!index_spills(16, 16, small.iter())); + assert!(!index_spills(5_000, 5_000, [].iter())); + } + /// A 1,000 x 1,000 Python result laid out from A1 in a pass, with the same values already /// spilled on the sheet from the last calculation, as a full recalculation finds them. fn big_python_spill() -> (Workbook, HashMap) { @@ -636,6 +751,39 @@ pub(crate) mod tests { assert!(!host.visit_range(0, inside, &mut |_| true), "an overlapping read leaves the block to range_values"); } + /// A Python array laid out in this pass is seen by the indexed readers, whether the sheet + /// has no spill from before or an older, smaller one. + #[test] + fn a_python_spill_found_in_the_pass_is_read_through_the_index() { + for stale in [false, true] { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + let anchor = CellRef::new(0, 0); + let py = gridcraft_model::PyCode::new("grid"); + sh.set_cell(anchor, Cell { value: Value::Number(0.0), py: Some(std::sync::Arc::new(py)), ..Cell::default() }); + if stale { + // The last calculation spilled a 2 x 2 block of 100s. + sh.spill_ranges.insert(anchor, RangeRef::new(anchor, CellRef::new(1, 1))); + for c in RangeRef::new(anchor, CellRef::new(1, 1)).iter().skip(1) { + sh.spill.insert(c, Value::Number(100.0)); + } + } + let data: Vec = (1..=9).map(|i| Value::Number(f64::from(i))).collect(); + let array = gridcraft_core::Array::new(3, 3, data).unwrap(); + let mut python = HashMap::default(); + python.insert((0, anchor), Value::Array(std::sync::Arc::new(array))); + let exprs = HashMap::default(); + let mut rng = 1u64; + let mut host = PassHost::new(&wb, &exprs, &mut rng, 0.0).with_python(python); + assert_eq!(host.cell_value(0, anchor), Value::Number(1.0)); + let all = RangeRef::parse("A1:C3").unwrap(); + let sum: f64 = host.range_values(0, all).iter().map(|v| if let Value::Number(n) = v { *n } else { 0.0 }).sum(); + assert_eq!(sum, 45.0, "stale={stale}: a SUM over the new 3 x 3 spill reads all of it"); + assert_eq!(host.cell_value(0, CellRef::new(2, 2)), Value::Number(9.0), "stale={stale}"); + assert!(!host.visit_range(0, all, &mut |_| true), "stale={stale}: the new spill leaves the block to range_values"); + } + } + #[test] fn intervals_match_a_linear_scan() { let mut iv: Intervals = Intervals::default(); diff --git a/crates/calc/src/recalc/pass_host.rs b/crates/calc/src/recalc/pass_host.rs index 9c56b2912..1d7d1c38c 100644 --- a/crates/calc/src/recalc/pass_host.rs +++ b/crates/calc/src/recalc/pass_host.rs @@ -1,5 +1,6 @@ use super::graph::{HashMap, HashSet, Key}; use super::{Evaluator, Host, Workbook, stack_position}; +use crate::spill_index::{Cover, SpillIndex}; use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value}; use gridcraft_formula::Expr; @@ -13,6 +14,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>, @@ -21,6 +25,8 @@ pub(super) struct PassHost<'a> { in_progress: HashSet, /// New spills found during the pass: anchor → array. pub(super) spills: HashMap>, + /// Where the areas in `spills` are, so a read finds the ones it meets without scanning them all. + spill_index: SpillIndex, /// Used range per sheet: the workbook doesn't change during a pass, and working it out walks /// every row, so once per sheet rather than once per range read. used: HashMap>, @@ -71,6 +77,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. @@ -86,6 +96,7 @@ impl<'a> PassHost<'a> { pending: exprs.keys().copied().collect(), in_progress: HashSet::default(), spills: HashMap::default(), + spill_index: SpillIndex::default(), used: HashMap::default(), rng, now, @@ -110,6 +121,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(), } } @@ -123,6 +135,30 @@ impl<'a> PassHost<'a> { } } +/// Most spill areas a range read meets before they are checked for overlapping one another by +/// walking all of the pass's spills instead of comparing pairs. +const MAX_UNORDERED_HITS: usize = 16; + +/// Whether an array at `anchor` covers `c`. +fn spill_covers(anchor: CellRef, arr: &Array, c: CellRef) -> bool { + c.row >= anchor.row && c.col >= anchor.col && ((c.row - anchor.row) as usize) < arr.rows && ((c.col - anchor.col) as usize) < arr.cols +} + +/// Whether two of the spill areas anchored at `hits` share a cell inside `range`. +fn spill_hits_overlap(index: &SpillIndex, sheet: usize, hits: &[CellRef], range: RangeRef) -> bool { + let rects: Vec = hits.iter().filter_map(|a| index.area(sheet, *a)).filter(|(_, empty)| !empty).map(|(r, _)| r).collect(); + rects.iter().enumerate().any(|(i, a)| { + rects.iter().skip(i + 1).any(|b| { + a.intersects(b) + && RangeRef::new( + CellRef::new(a.start.row.max(b.start.row), a.start.col.max(b.start.col)), + CellRef::new(a.end.row.min(b.end.row), a.end.col.min(b.end.col)), + ) + .intersects(&range) + }) + }) +} + /// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet, /// overlaps merged cells, holds other cells, or another formula's spill (unless that formula is /// recalculated in this pass, and so may spill elsewhere now). @@ -161,13 +197,6 @@ pub(super) fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef, .all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty)))) } -/// The area an array anchored at `anchor` covers. -fn array_area(anchor: CellRef, a: &Array) -> RangeRef { - let end = - CellRef::new(anchor.row.saturating_add((a.rows as u32).saturating_sub(1)), anchor.col.saturating_add((a.cols as u32).saturating_sub(1))); - RangeRef::new(anchor, end) -} - /// The spilled values `sheet` stores inside `range`, passed to `f` until it returns false. Values /// outside the range are not walked: with fewer rows in the range than spilled values, each row /// is looked up on its own; otherwise one ordered scan visits each spilled value at most once. @@ -369,6 +398,7 @@ impl PassHost<'_> { // value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so `C1#` // is the 1 x 1 range and `ROWS(C1#)` is 1, as in Excel (#1491). A formula that // returns a scalar never reaches this arm, so it does not become an anchor. + self.spill_index.insert(k, a.rows, a.cols); self.spills.insert(k, a.clone()); a.data.first().cloned().unwrap_or_default() } @@ -383,6 +413,7 @@ impl PassHost<'_> { let v = match self.spills.get(&k) { Some(a) if spill_blocked(self, k, a) => { self.spills.remove(&k); + self.spill_index.remove(k); Value::Error(CellError::Spill) } _ => v, @@ -402,6 +433,7 @@ impl PassHost<'_> { && a.cols >= 1 && a.rows.checked_mul(a.cols).is_some_and(|n| n == a.data.len() && n <= gridcraft_model::python::MAX_RESULT_CELLS); if sane { + self.spill_index.insert(k, a.rows, a.cols); self.spills.insert(k, a.clone()); a.data.first().cloned().unwrap_or_default() } else { @@ -413,6 +445,7 @@ impl PassHost<'_> { let v = match self.spills.get(&k) { Some(a) if spill_blocked(self, k, a) => { self.spills.remove(&k); + self.spill_index.remove(k); Value::Error(CellError::Spill) } _ => v, @@ -459,7 +492,11 @@ impl PassHost<'_> { while passes < max_iterations.min(max_passes) && !self.converged(max_change) { self.prev = again.iter().filter_map(|k| self.results.remove(k).map(|v| (*k, v))).collect(); self.pending = set.clone(); - self.spills.retain(|a, _| !set.contains(a)); + let gone: Vec = self.spills.keys().filter(|a| set.contains(a)).copied().collect(); + for a in gone { + self.spills.remove(&a); + self.spill_index.remove(a); + } for k in &again { self.calculate(*k); } @@ -543,17 +580,19 @@ impl Host for PassHost<'_> { if self.pending.contains(&k) || self.results.contains_key(&k) { return self.compute(k); } - // A cell covered by a spill computed in this pass. - for (anchor, arr) in &self.spills { - if anchor.0 == sheet && c.row >= anchor.1.row && c.col >= anchor.1.col { - let (dr, dc) = ((c.row - anchor.1.row) as usize, (c.col - anchor.1.col) as usize); - if dr < arr.rows && dc < arr.cols { - if self.iterate && self.tainted.contains(anchor) { - self.taint = true; - } - return arr.get(dr, dc).cloned().unwrap_or_default(); - } + // A cell covered by a spill computed in this pass. Where several overlap, the first in + // `spills`' iteration order counts, as it always has. + let covering = match self.spill_index.covering(sheet, c) { + Cover::None => None, + Cover::One(a) => self.spills.get_key_value(&(sheet, a)), + Cover::Many => self.spills.iter().find(|(a, arr)| a.0 == sheet && spill_covers(a.1, arr, c)), + }; + if let Some((anchor, arr)) = covering { + let (dr, dc) = ((c.row - anchor.1.row) as usize, (c.col - anchor.1.col) as usize); + if self.iterate && self.tainted.contains(anchor) { + self.taint = true; } + return arr.get(dr, dc).cloned().unwrap_or_default(); } self.wb.sheet(sheet).map(|s| s.value(c)).unwrap_or_default() } @@ -592,46 +631,44 @@ impl Host for PassHost<'_> { } } if !self.spills.is_empty() { - // Only the part of each array inside the range is walked: a large array beside a small - // read costs nothing. - let mut new: Vec<(usize, Value)> = Vec::new(); - for (a, arr) in self.spills.iter().filter(|(a, _)| a.0 == sheet) { - let area = array_area(a.1, arr); - if !area.intersects(&range) { - continue; - } - let (r0, r1) = (area.start.row.max(range.start.row), area.end.row.min(range.end.row)); - let (c0, c1) = (area.start.col.max(range.start.col), area.end.col.min(range.end.col)); - for row in r0..=r1 { - for col in c0..=c1 { - let c = CellRef::new(row, col); - if c == a.1 { - continue; - } - let v = arr.get((row - a.1.row) as usize, (col - a.1.col) as usize).cloned().unwrap_or_default(); - new.push(((row - range.start.row) as usize * w + (col - range.start.col) as usize, v)); - } - } - } - #[cfg(test)] - { - self.spill_visits += new.len(); - } + let mut hits = Vec::new(); + self.spill_index.meeting(sheet, range, &mut hits); // Iterating: an array spilled by a formula that read a previous value. - if self.iterate - && self.spills.iter().any(|(a, arr)| { - let end = CellRef::new( - a.1.row.saturating_add((arr.rows as u32).saturating_sub(1)), - a.1.col.saturating_add((arr.cols as u32).saturating_sub(1)), - ); - a.0 == sheet && self.tainted.contains(a) && RangeRef::new(a.1, end).intersects(&range) - }) - { + if self.iterate && hits.iter().any(|a| self.tainted.contains(&(sheet, *a))) { self.taint = true; } - for (i, v) in new { - if let Some(slot) = out.get_mut(i) { - *slot = v; + // Where areas overlap inside the range, the last in `spills`' iteration order wins, + // as it always has; otherwise the order doesn't matter. + let ordered = hits.len() > MAX_UNORDERED_HITS || spill_hits_overlap(&self.spill_index, sheet, &hits, range); + let mut put = |anchor: CellRef, arr: &Array| { + if arr.rows == 0 || arr.cols == 0 { + return; + } + let (r0, c0) = (range.start.row.max(anchor.row), range.start.col.max(anchor.col)); + let r1 = range.end.row.min(anchor.row.saturating_add((arr.rows as u32).saturating_sub(1))); + let c1 = range.end.col.min(anchor.col.saturating_add((arr.cols as u32).saturating_sub(1))); + for r in r0..=r1 { + for cc in c0..=c1 { + if (r, cc) != (anchor.row, anchor.col) + && let Some(slot) = out.get_mut((r - range.start.row) as usize * w + (cc - range.start.col) as usize) + { + *slot = arr.get((r - anchor.row) as usize, (cc - anchor.col) as usize).cloned().unwrap_or_default(); + } + } + } + }; + if ordered { + let wanted: HashSet = hits.iter().map(|a| (sheet, *a)).collect(); + for (a, arr) in &self.spills { + if wanted.contains(a) { + put(a.1, arr); + } + } + } else { + for a in &hits { + if let Some(arr) = self.spills.get(&(sheet, *a)) { + put(*a, arr); + } } } } @@ -650,7 +687,8 @@ impl Host for PassHost<'_> { } // Spilled values sit between the stored cells (and the formulas just calculated may have // produced some): leave such blocks to `range_values`. - let arrays = self.spills.iter().any(|(a, arr)| a.0 == sheet && array_area(a.1, arr).intersects(&range)); + let mut arrays = Vec::new(); + self.spill_index.meeting(sheet, range, &mut arrays); let mut stored = false; let _seen = stored_spill_in(sh, range, &mut |_, _| { stored = true; @@ -660,7 +698,7 @@ impl Host for PassHost<'_> { { self.spill_visits += _seen; } - if arrays || stored { + if !arrays.is_empty() || stored { return false; } for (c, cell) in sh.cells.iter_range(range) { @@ -671,6 +709,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/spill_index.rs b/crates/calc/src/spill_index.rs new file mode 100644 index 000000000..d642bdee7 --- /dev/null +++ b/crates/calc/src/spill_index.rs @@ -0,0 +1,296 @@ +//! A spatial index over spill areas: which areas cover a cell, and which meet a range, without +//! looking at every area. Areas are registered in 16 x 16 cell blocks, so a lookup touches one +//! block (a cell) or the blocks a range covers (a range) rather than every spill on the sheet. +//! An area that would take more than `BIG_AREA_BLOCKS` blocks (a full-height column spill takes +//! 65,536) is not registered in blocks but kept in a short list that every lookup tests directly, +//! so registering costs what the area's size warrants only for small areas. + +use crate::recalc::{FxHasher, Key}; +use gridcraft_core::{CellRef, RangeRef}; +use std::hash::BuildHasherDefault; + +type HashMap = std::collections::HashMap>; + +/// Block edge in cells (rows and columns). +const BLOCK: u32 = 16; + +/// Areas spanning more blocks than this are tested directly instead of being registered. +const BIG_AREA_BLOCKS: u64 = 1024; + +/// One indexed area: its rectangle, and whether it covers no cells (an array with no rows or no +/// columns still meets the ranges its anchor is in, as a 1 x 1 area does for taint tracking). +#[derive(Clone, Copy)] +struct Entry { + rect: RangeRef, + empty: bool, +} + +#[derive(Default)] +struct SheetAreas { + entries: HashMap, + blocks: HashMap<(u32, u32), Vec>, + /// Anchors of the areas too big to register in blocks. + big: Vec, +} + +/// What covers a cell. +pub(crate) enum Cover { + None, + One(CellRef), + /// Several areas overlap here: the caller decides which one counts. + Many, +} + +#[derive(Default)] +pub(crate) struct SpillIndex { + sheets: HashMap, +} + +fn block_span(rect: &RangeRef) -> (u32, u32, u32, u32) { + (rect.start.row / BLOCK, rect.end.row / BLOCK, rect.start.col / BLOCK, rect.end.col / BLOCK) +} + +fn block_count(rect: &RangeRef) -> u64 { + let (r0, r1, c0, c1) = block_span(rect); + u64::from(r1 - r0 + 1) * u64::from(c1 - c0 + 1) +} + +/// Whether registering `areas` in blocks costs less than checking each of them against each of +/// `lookups` cells would: a few block entries cost about one containment check each. A tall or +/// wide area is not registered at all (see `BIG_AREA_BLOCKS`) and so costs nothing here. +pub(crate) fn indexing_pays<'a>(areas: impl ExactSizeIterator, lookups: usize) -> bool { + const BLOCK_COST: u64 = 16; + let budget = (lookups as u64).saturating_mul(areas.len() as u64) / BLOCK_COST; + let mut blocks = 0u64; + for r in areas { + let n = block_count(r); + if n > BIG_AREA_BLOCKS { + continue; + } + blocks = blocks.saturating_add(n); + if blocks > budget { + return false; + } + } + true +} + +impl SpillIndex { + /// Whether any area is indexed on `sheet`. + #[cfg(test)] + pub(crate) fn has_sheet(&self, sheet: usize) -> bool { + self.sheets.get(&sheet).is_some_and(|s| !s.entries.is_empty()) + } + + /// Indexes the area an array of `rows` x `cols` anchored at `anchor` covers (replacing the + /// one already indexed for that anchor). + pub(crate) fn insert(&mut self, (sheet, anchor): Key, rows: usize, cols: usize) { + let end = + CellRef::new(anchor.row.saturating_add((rows as u32).saturating_sub(1)), anchor.col.saturating_add((cols as u32).saturating_sub(1))); + self.insert_rect((sheet, anchor), RangeRef::new(anchor, end), rows == 0 || cols == 0); + } + + /// Indexes an area given as a range. + pub(crate) fn insert_rect(&mut self, (sheet, anchor): Key, rect: RangeRef, empty: bool) { + self.remove((sheet, anchor)); + let areas = self.sheets.entry(sheet).or_default(); + if block_count(&rect) > BIG_AREA_BLOCKS { + areas.big.push(anchor); + } else { + let (r0, r1, c0, c1) = block_span(&rect); + for br in r0..=r1 { + for bc in c0..=c1 { + areas.blocks.entry((br, bc)).or_default().push(anchor); + } + } + } + areas.entries.insert(anchor, Entry { rect, empty }); + } + + pub(crate) fn remove(&mut self, (sheet, anchor): Key) { + let Some(areas) = self.sheets.get_mut(&sheet) else { return }; + let Some(old) = areas.entries.remove(&anchor) else { return }; + if block_count(&old.rect) > BIG_AREA_BLOCKS { + areas.big.retain(|a| *a != anchor); + return; + } + let (r0, r1, c0, c1) = block_span(&old.rect); + for br in r0..=r1 { + for bc in c0..=c1 { + if let Some(list) = areas.blocks.get_mut(&(br, bc)) { + list.retain(|a| *a != anchor); + if list.is_empty() { + areas.blocks.remove(&(br, bc)); + } + } + } + } + } + + /// The areas that cover `c`. + pub(crate) fn covering(&self, sheet: usize, c: CellRef) -> Cover { + let Some(areas) = self.sheets.get(&sheet) else { return Cover::None }; + let in_block = areas.blocks.get(&(c.row / BLOCK, c.col / BLOCK)).map_or(&[][..], |l| l.as_slice()); + let mut found = Cover::None; + for a in in_block.iter().chain(&areas.big) { + if let Some(e) = areas.entries.get(a) + && !e.empty + && e.rect.contains(c) + { + found = match found { + Cover::None => Cover::One(*a), + _ => return Cover::Many, + }; + } + } + found + } + + /// The anchors of the areas whose rectangle meets `range` (an empty area counts through its + /// anchor cell), sorted by anchor. + pub(crate) fn meeting(&self, sheet: usize, range: RangeRef, out: &mut Vec) { + out.clear(); + let Some(areas) = self.sheets.get(&sheet) else { return }; + let (r0, r1, c0, c1) = block_span(&range); + let touched = u64::from(r1 - r0 + 1) * u64::from(c1 - c0 + 1); + if touched > 4 * areas.entries.len() as u64 + 16 { + // A range wider than the areas are many: look at each area once. + out.extend(areas.entries.iter().filter(|(_, e)| e.rect.intersects(&range)).map(|(a, _)| *a)); + } else { + for br in r0..=r1 { + for bc in c0..=c1 { + if let Some(list) = areas.blocks.get(&(br, bc)) { + out.extend(list.iter().copied().filter(|a| areas.entries.get(a).is_some_and(|e| e.rect.intersects(&range)))); + } + } + } + out.extend(areas.big.iter().copied().filter(|a| areas.entries.get(a).is_some_and(|e| e.rect.intersects(&range)))); + } + out.sort_unstable(); + out.dedup(); + } + + /// The rectangle indexed for `anchor` and whether it covers no cells. + pub(crate) fn area(&self, sheet: usize, anchor: CellRef) -> Option<(RangeRef, bool)> { + self.sheets.get(&sheet)?.entries.get(&anchor).map(|e| (e.rect, e.empty)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(r: u32, c: u32) -> CellRef { + CellRef::new(r, c) + } + + #[test] + fn finds_the_areas_covering_a_cell_and_meeting_a_range() { + let mut ix = SpillIndex::default(); + ix.insert((0, at(0, 0)), 40, 3); + ix.insert((0, at(30, 1)), 1, 40); + ix.insert((1, at(0, 0)), 5, 5); + assert!(matches!(ix.covering(0, at(39, 2)), Cover::One(a) if a == at(0, 0))); + assert!(matches!(ix.covering(0, at(30, 2)), Cover::Many)); + assert!(matches!(ix.covering(0, at(40, 0)), Cover::None)); + assert!(matches!(ix.covering(2, at(0, 0)), Cover::None)); + let mut hits = Vec::new(); + ix.meeting(0, RangeRef::new(at(30, 30), at(50, 60)), &mut hits); + assert_eq!(hits, vec![at(30, 1)]); + ix.meeting(0, RangeRef::new(at(0, 0), at(1_000_000, 16_000)), &mut hits); + assert_eq!(hits, vec![at(0, 0), at(30, 1)]); + ix.remove((0, at(30, 1))); + assert!(matches!(ix.covering(0, at(30, 2)), Cover::One(_))); + assert!(ix.has_sheet(1) && !ix.has_sheet(2)); + ix.insert((0, at(0, 0)), 1, 1); + assert!(matches!(ix.covering(0, at(39, 2)), Cover::None)); + } + + #[test] + fn tall_or_wide_areas_are_not_registered_in_blocks() { + let column = RangeRef::new(at(0, 0), at(1_048_575, 0)); + assert!(block_count(&column) > BIG_AREA_BLOCKS); + let mut ix = SpillIndex::default(); + ix.insert_rect((0, at(0, 0)), column, false); + assert!(ix.sheets[&0].blocks.is_empty()); + assert!(matches!(ix.covering(0, at(900_000, 0)), Cover::One(a) if a == at(0, 0))); + assert!(matches!(ix.covering(0, at(900_000, 1)), Cover::None)); + let mut hits = Vec::new(); + ix.meeting(0, RangeRef::new(at(5, 0), at(6, 3)), &mut hits); + assert_eq!(hits, vec![at(0, 0)]); + ix.remove((0, at(0, 0))); + assert!(matches!(ix.covering(0, at(900_000, 0)), Cover::None) && ix.sheets[&0].big.is_empty()); + // Registering a big area costs nothing (lookups test it directly), so a small paste still indexes next to many small areas. + assert!(indexing_pays([column].iter(), 17)); + // Many small areas pay back for a paste of a hundred cells, but not for a handful. + let small: Vec = (0..1_000).map(|i| RangeRef::new(at(i * 3, 0), at(i * 3 + 2, 0))).collect(); + assert!(indexing_pays(small.iter(), 100)); + assert!(!indexing_pays(small.iter(), 17)); + } + + /// The index against the old way: test every area for every lookup. Mixed shapes: small, + /// thin and tall, wide, big (kept out of the blocks), empty, on two sheets, replaced and removed. + #[test] + fn matches_a_scan_of_every_area_on_mixed_shapes() { + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = |n: u32| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 11) as u32 % n + }; + let mut ix = SpillIndex::default(); + let mut model: std::collections::BTreeMap<(usize, CellRef), (RangeRef, bool)> = Default::default(); + for step in 0..3_000 { + let sheet = next(2) as usize; + let anchor = at(next(400) * 5, next(40) * 3); + if next(4) == 0 { + ix.remove((sheet, anchor)); + model.remove(&(sheet, anchor)); + } else { + let (rows, cols) = match next(8) { + 0 => (0, 1 + next(5) as usize), + 1 => (1 + next(5) as usize, 0), + 2 => (5_000 + next(100_000) as usize, 1), + 3 => (1, 1 + next(40_000) as usize), + 4 => (200 + next(300) as usize, 40 + next(40) as usize), + _ => (1 + next(60) as usize, 1 + next(40) as usize), + }; + ix.insert((sheet, anchor), rows, cols); + let end = at(anchor.row + (rows as u32).saturating_sub(1), anchor.col + (cols as u32).saturating_sub(1)); + model.insert((sheet, anchor), (RangeRef::new(anchor, end), rows == 0 || cols == 0)); + } + if step % 7 != 0 { + continue; + } + let sheet = next(3) as usize; + for _ in 0..20 { + let cell = at(next(2_100), next(130)); + let want: Vec = + model.iter().filter(|((s, _), (r, e))| *s == sheet && !e && r.contains(cell)).map(|((_, a), _)| *a).collect(); + match ix.covering(sheet, cell) { + Cover::None => assert!(want.is_empty(), "{cell:?}"), + Cover::One(a) => assert_eq!(want, vec![a], "{cell:?}"), + Cover::Many => assert!(want.len() > 1, "{cell:?}"), + } + let (r0, c0) = (next(2_100), next(130)); + let (tall, wide) = (if next(5) == 0 { 3_000 } else { 40 }, if next(5) == 0 { 130 } else { 20 }); + let range = RangeRef::new(at(r0, c0), at(r0 + next(tall), c0 + next(wide))); + let want: Vec = model.iter().filter(|((s, _), (r, _))| *s == sheet && r.intersects(&range)).map(|((_, a), _)| *a).collect(); + let mut got = Vec::new(); + ix.meeting(sheet, range, &mut got); + assert_eq!(got, want, "{range:?}"); + } + } + } + + #[test] + fn an_area_without_cells_covers_none_but_is_met_at_its_anchor() { + let mut ix = SpillIndex::default(); + ix.insert((0, at(3, 3)), 0, 4); + assert!(matches!(ix.covering(0, at(3, 3)), Cover::None)); + let mut hits = Vec::new(); + ix.meeting(0, RangeRef::new(at(3, 3), at(3, 3)), &mut hits); + assert_eq!(hits, vec![at(3, 3)]); + } +} diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index d10c2f983..083a5ff25 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -2379,6 +2379,354 @@ fn loan_schedule_residue_keeps_excels_sign() { assert_eq!(t.get("P7"), Value::Text("".into())); } +#[test] +fn clearing_a_cell_unblocks_the_arrays_above_and_left_of_it() { + let mut t = T::new(); + for r in 1..=50 { + t.set(&format!("D{r}"), &format!("={r}*2")); + } + t.set("B2", "7"); + t.set("B1", "=SEQUENCE(3)"); + t.set("C1", "=SEQUENCE(1,3)"); + t.set("A20", "=B1+1"); + assert_eq!(t.get("B1"), Value::Error(CellError::Spill)); + assert_eq!(t.get("A20"), Value::Error(CellError::Spill)); + // Emptied: the array above it spills. + t.wb.sheet_mut(0).unwrap().cells.remove(c("B2")); + t.calc.cells_changed(&mut t.wb, &[(0, c("B2"))]); + assert_eq!(t.num("B3"), 3.0); + assert_eq!(t.num("A20"), 2.0); + // Typing over the area blocks it again; far from it, nothing is disturbed. + t.set("B3", "9"); + assert_eq!(t.get("B1"), Value::Error(CellError::Spill)); + t.set("D50", "1"); + assert_eq!(t.get("B1"), Value::Error(CellError::Spill)); + assert_eq!(t.num("D49"), 98.0); + // Clearing it again frees the array. + t.set("B3", ""); + assert_eq!(t.num("B3"), 3.0); +} + +/// A sheet with `#SPILL!` cells in rows 0..64, columns 100..16384 (as blocked arrays leave), none +/// near column A. +fn spill_error_field() -> T { + let mut t = T::new(); + let sh = t.wb.sheet_mut(0).unwrap(); + for r in 0..64 { + for col in 100..16_384 { + sh.cells.set(CellRef::new(r, col), Cell::value(Value::Error(CellError::Spill))); + } + } + t.calc.recalc_all(&mut t.wb); + t +} + +#[test] +fn an_edit_far_left_of_many_spill_errors_does_not_disturb_them() { + let mut t = spill_error_field(); + t.set("A65", "5"); + assert_eq!(t.num("A65"), 5.0); + assert_eq!(t.get("CW1"), Value::Error(CellError::Spill)); + assert_eq!(t.get("XFD64"), Value::Error(CellError::Spill)); + // A blocked formula inside the window is still found when a cell to its right is cleared. + t.set("DA66", "7"); + t.set("CZ66", "=SEQUENCE(1,3)"); + assert_eq!(t.get("CZ66"), Value::Error(CellError::Spill)); + t.wb.sheet_mut(0).unwrap().cells.remove(c("DA66")); + t.calc.cells_changed(&mut t.wb, &[(0, c("DA66"))]); + assert_eq!(t.num("DA66"), 2.0); +} + +/// Probe: single-cell edits in column A below a block of `#SPILL!` cells to its right. +/// `cargo test -p gridcraft-calc --release -- --ignored --nocapture spill_error_field_probe` +#[test] +#[ignore] +#[allow(clippy::disallowed_methods)] +fn spill_error_field_probe() { + let mut t = spill_error_field(); + let mut best = std::time::Duration::MAX; + for i in 0..9 { + let start = std::time::Instant::now(); + for _ in 0..100 { + t.set("A65", &format!("{i}")); + } + best = best.min(start.elapsed()); + } + eprintln!("100 edits of A65 beside 1,040,000 spill errors: best of 9 = {best:?}"); +} + +/// A full-height column spill (inside the 10,000,000-cell cap) and a paste of 17 cells elsewhere: +/// the paste must not register the spill's 65,536 blocks, and a paste over the spill must still +/// block it. +fn tall_spill_book() -> T { + let mut t = T::new(); + t.set("A1", "=SEQUENCE(1048576)"); + t.calc.recalc_all(&mut t.wb); + assert_eq!(t.num("A1048576"), 1_048_576.0); + t +} + +fn paste_into(t: &mut T, cells: &[(u32, u32)]) { + let changed: Vec<(usize, CellRef)> = cells.iter().map(|&(r, c)| (0, CellRef::new(r, c))).collect(); + for k in &changed { + t.wb.sheet_mut(0).unwrap().cells.set(k.1, Cell::value(Value::Number(1.0))); + } + t.calc.cells_changed(&mut t.wb, &changed); +} + +#[test] +fn a_paste_beside_a_tall_spill_leaves_it_alone_and_one_over_it_blocks_it() { + let mut t = tall_spill_book(); + let beside: Vec<(u32, u32)> = (0..17).map(|i| (i, 5)).collect(); + paste_into(&mut t, &beside); + assert_eq!(t.num("A1048576"), 1_048_576.0); + let over: Vec<(u32, u32)> = (0..17).map(|i| (500_000 + i, if i == 9 { 0 } else { 5 })).collect(); + paste_into(&mut t, &over); + assert_eq!(t.get("A1"), Value::Error(CellError::Spill)); +} + +/// Probe: `cells_changed` for a 17-cell paste in column F beside the tall spill. +/// `cargo test -p gridcraft-calc --release -- --ignored --nocapture tall_spill_paste_probe` +#[test] +#[ignore] +#[allow(clippy::disallowed_methods)] +fn tall_spill_paste_probe() { + let mut t = tall_spill_book(); + let mut best = std::time::Duration::MAX; + for round in 0..9u32 { + let beside: Vec<(u32, u32)> = (0..17).map(|i| (round * 20 + i, 5)).collect(); + let start = std::time::Instant::now(); + paste_into(&mut t, &beside); + best = best.min(start.elapsed()); + } + eprintln!("tall spill, 17-cell paste beside it: best of 9 = {best:?}"); +} + +#[test] +fn many_spills_are_read_and_blocked_by_a_paste() { + let mut t = T::new(); + // 40 arrays of 3 rows side by side in rows 1-3, and a reader of a range over two of them. + for i in 0..40u32 { + let anchor = CellRef::new(0, 2 * i); + t.wb.sheet_mut(0).unwrap().cells.set(anchor, Cell::formula(Formula::new(&format!("=SEQUENCE(3,1,{})", 10 * i)))); + } + t.set("A10", "=SUM(C1:F3)"); + t.set("A12", "=COUNT(C1:F3)"); + t.set("A11", "=C2*1"); + t.calc.recalc_all(&mut t.wb); + assert_eq!(t.num("A10"), (10 + 11 + 12 + 20 + 21 + 22) as f64); + assert_eq!(t.num("A11"), 11.0); + assert_eq!(t.num("A12"), 6.0); + // Pasting 20 cells (more than one spill's width worth, so the changed set is indexed) over + // the second row of every other array blocks those arrays; the rest are unchanged. + let changed: Vec<(usize, CellRef)> = (0..20u32).map(|i| (0, CellRef::new(1, 4 * i))).collect(); + for k in &changed { + t.wb.sheet_mut(0).unwrap().cells.set(k.1, Cell::value(Value::Number(1.0))); + } + t.calc.cells_changed(&mut t.wb, &changed); + assert_eq!(t.get("E1"), Value::Error(CellError::Spill)); + assert_eq!(t.get("C1"), Value::Number(10.0)); + assert_eq!(t.get("G2"), Value::Number(31.0)); + assert_eq!(t.get("A10"), Value::Error(CellError::Spill)); + assert_eq!(t.num("A12"), 4.0); +} + +#[test] +fn a_paste_over_small_and_tall_spills_blocks_those_it_touches() { + let mut t = T::new(); + for i in 0..40u32 { + t.wb.sheet_mut(0).unwrap().cells.set(CellRef::new(0, 2 * i), Cell::formula(Formula::new("=SEQUENCE(3)"))); + } + t.wb.sheet_mut(0).unwrap().cells.set(CellRef::new(0, 200), Cell::formula(Formula::new("=SEQUENCE(1048576)"))); + t.calc.recalc_all(&mut t.wb); + // 20 cells: the middle row of the small arrays in columns 0, 8, 16 ... 72 (columns 80 and up hit + // nothing), and one deep inside the tall one. + let mut cells: Vec<(u32, u32)> = (0..19u32).map(|i| (1, 8 * i)).collect(); + cells.push((700_000, 200)); + paste_into(&mut t, &cells); + assert_eq!(t.get("A1"), Value::Error(CellError::Spill)); + assert_eq!(t.get("GS1"), Value::Error(CellError::Spill)); + assert_eq!(t.num("C1"), 1.0); + assert_eq!(t.num("C3"), 3.0); + assert_eq!(t.get("I1"), Value::Error(CellError::Spill)); + assert_eq!(t.num("E3"), 3.0); +} + +#[test] +fn a_big_paste_on_one_sheet_and_one_cell_over_many_spills_on_another() { + let mut t = T::new(); + t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data"))); + for i in 0..1_500u32 { + t.wb.sheet_mut(1).unwrap().cells.set(CellRef::new(4 * i, 0), Cell::formula(Formula::new(&format!("=SEQUENCE(3,1,{})", 10 * i)))); + } + t.calc.recalc_all(&mut t.wb); + assert_eq!(t.wb.sheet(1).unwrap().value(CellRef::new(4 * 700 + 2, 0)), Value::Number(7002.0)); + // 5000 cells pasted on the first sheet, one over the middle of the 701st array on the second. + let mut changed: Vec<(usize, CellRef)> = (0..5_000u32).map(|i| (0, CellRef::new(i / 50, i % 50))).collect(); + changed.push((1, CellRef::new(4 * 700 + 1, 0))); + for k in &changed { + t.wb.sheet_mut(k.0).unwrap().cells.set(k.1, Cell::value(Value::Number(1.0))); + } + t.calc.cells_changed(&mut t.wb, &changed); + let data = t.wb.sheet(1).unwrap(); + assert_eq!(data.value(CellRef::new(4 * 700, 0)), Value::Error(CellError::Spill)); + assert_eq!(data.value(CellRef::new(4 * 699 + 2, 0)), Value::Number(6992.0)); + assert_eq!(data.value(CellRef::new(4 * 701 + 2, 0)), Value::Number(7012.0)); +} + +/// `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). +#[test] +fn graph_built_from_text_only_formulas_matches_cached() { + fn run(cached: bool) -> Vec { + let mut t = T::new(); + t.set("A1", "2"); + for (at, f) in [("B1", "=A1*10"), ("C1", "=B1+A1"), ("D1", "=SUM(A1:C1)"), ("E1", "=NOPE(")] { + // E1 does not parse, so it has no tree either way and only covers the `None` branch. + let mut formula = Formula::new(f); + if !cached { + formula.expr = None; + } + t.wb.sheet_mut(0).unwrap().cells.set(c(at), Cell::formula(formula)); + } + t.calc.rebuild(&t.wb); + t.calc.recalc_all(&mut t.wb); + let mut out: Vec = ["B1", "C1", "D1", "E1"].iter().map(|a| t.get(a)).collect(); + // Editing the input must reach the dependents through the rebuilt graph. + t.set("A1", "5"); + out.extend(["B1", "C1", "D1"].iter().map(|a| t.get(a))); + // A text-only formula that is rewritten through `cells_changed` joins the graph too. + let mut formula = Formula::new("=A1+100"); + if !cached { + formula.expr = None; + } + t.wb.sheet_mut(0).unwrap().cells.set(c("F1"), Cell::formula(formula)); + t.calc.cells_changed(&mut t.wb, &[(0, c("F1"))]); + t.set("A1", "7"); + out.push(t.get("F1")); + out + } + let cached = run(true); + assert_eq!(cached, run(false)); + assert_eq!(cached[4], Value::Number(50.0)); + assert_eq!(cached[7], Value::Number(107.0)); +} + /// Makes `at` a Python cell showing `cached` (its value before this calculation), with `result` /// as its kept result, and returns the cell's key for `Calc::cells_changed`. fn plant_python(t: &mut T, at: &str, cached: Value, result: Value) -> (usize, CellRef) { diff --git a/crates/chart/src/axis.rs b/crates/chart/src/axis.rs index 8686bf7d9..46fc43f99 100644 --- a/crates/chart/src/axis.rs +++ b/crates/chart/src/axis.rs @@ -25,9 +25,11 @@ impl Scale { (0..=n) .map(|i| { let v = self.min + i as f64 * self.step; - // Snap away accumulated float error. + // Snap away accumulated float error, but only onto a multiple of the step that is + // really that close: a `min` off the step grid must keep its own ticks. let snapped = (v / self.step).round() * self.step; - if snapped.abs() < self.step * 1e-9 { 0.0 } else { snapped } + let v = if (snapped - v).abs() < self.step * 1e-9 { snapped } else { v }; + if v.abs() < self.step * 1e-9 { 0.0 } else { v } }) .collect() } @@ -107,11 +109,6 @@ pub fn nice_scale(lo: f64, hi: f64, force_zero: bool) -> Scale { Scale { min, max, step } } -/// Exact fixed scale (100% stacked axes). -pub(crate) fn fixed_scale(min: f64, max: f64, step: f64) -> Scale { - Scale { min, max, step } -} - /// A number format prepared once and reused for many labels. pub(crate) struct Fmt { general: bool, diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 2b46c5fbd..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, fixed_scale, nice_scale}; -use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color}; +use crate::axis::{Fmt, Scale, clean, nice_scale}; +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 7fae90a9f..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, fixed_scale, 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,11 +245,12 @@ fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec