mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-15 14:52:26 +00:00
Merge upstream/main into feat/wordart-784
This commit is contained in:
commit
032b280b04
37 files changed
+3559
-205
No files matched your search
@@ -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<usize> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<u32, Option<(f64, u64)>>;
|
||||
|
||||
pub(super) struct PassHost<'a> {
|
||||
wb: &'a Workbook,
|
||||
exprs: &'a HashMap<Key, std::sync::Arc<Expr>>,
|
||||
@@ -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<RangeRef> {
|
||||
let wb = self.wb;
|
||||
*self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range()))
|
||||
|
||||
@@ -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<f64>, Vec<(u32, u32, u32, u32)>);
|
||||
let mut oracle: Vec<Oracle> = vec![];
|
||||
let funcs = ["SUM", "COUNT", "AVERAGE"];
|
||||
let mut at = 0;
|
||||
let mut add = |wb: &mut Workbook, f: &'static str, lead: Option<f64>, blocks: Vec<(u32, u32, u32, u32)>| {
|
||||
let args: Vec<String> = 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).
|
||||
|
||||
+92
-11
@@ -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<char> = s.chars().collect();
|
||||
let mut n = chars.len().min(400);
|
||||
while n > 0 {
|
||||
n -= 1;
|
||||
let t: String = chars.iter().take(n).collect::<String>() + "…";
|
||||
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<usize> = 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<f64>]) -> Vec<Option<f64>> {
|
||||
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<f64>) -> 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<Prim> {
|
||||
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);
|
||||
|
||||
@@ -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<BarRun>,
|
||||
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<Vec<Option<(f64, f64)>>> {
|
||||
// Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart).
|
||||
let mut totals: Vec<((bool, Fam), Vec<f64>)> = Vec::new();
|
||||
@@ -141,7 +245,8 @@ fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec<Option
|
||||
let all100 = members.iter().all(|i| fams.get(*i).is_some_and(|f| is_100(*f)));
|
||||
let range = value_range(members.iter().flat_map(|i| {
|
||||
let line = fams.get(*i).is_some_and(|f| is_line(*f));
|
||||
spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] })
|
||||
// Lines contribute only their top value, bars both ends (no per-point `Vec`).
|
||||
spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| [*b, *t].into_iter().skip(usize::from(line)))
|
||||
}));
|
||||
let (lo, hi) = range.unwrap_or((0.0, 0.0));
|
||||
let (scale, fmt) = if all100 {
|
||||
@@ -278,19 +383,16 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
}
|
||||
1 => {
|
||||
let k = slot_of.get(i).copied().unwrap_or(0) as f32;
|
||||
// Hairline bars are merged per pixel column (their envelope is what shows).
|
||||
let cull = merge_hairlines(bw, labels_on);
|
||||
let mut sink = BarSink::new(bw, horizontal, s.color, nslots, k);
|
||||
for (j, sp) in row.iter().enumerate() {
|
||||
let Some((b, t)) = sp else { continue };
|
||||
let (pb, pt) = (vpos(*b, &sc), vpos(*t, &sc));
|
||||
let c = center(j);
|
||||
let lo = pb.min(pt);
|
||||
let len = (pb - pt).abs();
|
||||
if horizontal {
|
||||
let y = c + nslots * bw / 2.0 - (k + 1.0) * bw;
|
||||
ctx.rect(lo, y, len, bw, s.color, None);
|
||||
} else {
|
||||
let x = c - nslots * bw / 2.0 + k * bw;
|
||||
ctx.rect(x, lo, bw, len, s.color, None);
|
||||
}
|
||||
sink.bar(ctx, cull, c, lo, len);
|
||||
if labels_on && let Some(v) = s.values.get(j).copied().flatten() {
|
||||
let t = fmt.format(v, None);
|
||||
let stacked = fam != Fam::Col;
|
||||
@@ -316,6 +418,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
}
|
||||
}
|
||||
}
|
||||
sink.finish(ctx);
|
||||
}
|
||||
_ => {
|
||||
let pts: Vec<Option<[f32; 2]>> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect();
|
||||
|
||||
@@ -340,7 +340,7 @@ pub(super) fn doughnut(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
|
||||
|
||||
// ---------------------------------------------------------------- treemap
|
||||
|
||||
/// Worst aspect ratio of a row with the given sum, smallest and largest area.
|
||||
/// Worst aspect ratio of a row with the given area sum, smallest and largest item, laid along `side`.
|
||||
fn worst(s: f64, mn: f64, mx: f64, side: f64) -> f64 {
|
||||
if s <= 0.0 || side <= 0.0 {
|
||||
return f64::INFINITY;
|
||||
@@ -350,8 +350,9 @@ fn worst(s: f64, mn: f64, mx: f64, side: f64) -> f64 {
|
||||
(w2 * mx / s2).max(s2 / (w2 * mn))
|
||||
}
|
||||
|
||||
/// Squarified treemap layout of (index, area) items, sorted descending, into `r`.
|
||||
fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
|
||||
/// Squarified treemap layout of (index, value) items, sorted descending, into `r`; each block
|
||||
/// comes back with its index and value.
|
||||
pub(super) fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, f64, Bx)> {
|
||||
let total: f64 = items.iter().map(|i| i.1).sum();
|
||||
let area = (r.w as f64) * (r.h as f64);
|
||||
if !(total > 0.0 && area > 0.0) {
|
||||
@@ -360,11 +361,10 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
|
||||
let scale = area / total;
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
let mut rem = r;
|
||||
let mut row: Vec<(usize, f64)> = Vec::new();
|
||||
// The row's sum, smallest and largest area, kept as items join so each step is O(1).
|
||||
let mut row: Vec<(usize, f64, f64)> = Vec::new();
|
||||
let (mut sum, mut mn, mut mx) = (0.0f64, f64::INFINITY, 0.0f64);
|
||||
let mut i = 0;
|
||||
let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| {
|
||||
let layout = |row: &[(usize, f64, f64)], rem: &mut Bx, out: &mut Vec<(usize, f64, Bx)>| {
|
||||
let s: f64 = row.iter().map(|x| x.1).sum();
|
||||
if s <= 0.0 {
|
||||
return;
|
||||
@@ -372,9 +372,9 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
|
||||
if rem.w >= rem.h {
|
||||
let cw = (s / rem.h.max(1e-6) as f64) as f32;
|
||||
let mut y = rem.y;
|
||||
for (j, a) in row {
|
||||
for (j, a, v) in row {
|
||||
let h = (*a / cw.max(1e-6) as f64) as f32;
|
||||
out.push((*j, Bx { x: rem.x, y, w: cw, h }));
|
||||
out.push((*j, *v, Bx { x: rem.x, y, w: cw, h }));
|
||||
y += h;
|
||||
}
|
||||
rem.x += cw;
|
||||
@@ -382,9 +382,9 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
|
||||
} else {
|
||||
let rh = (s / rem.w.max(1e-6) as f64) as f32;
|
||||
let mut x = rem.x;
|
||||
for (j, a) in row {
|
||||
for (j, a, v) in row {
|
||||
let w = (*a / rh.max(1e-6) as f64) as f32;
|
||||
out.push((*j, Bx { x, y: rem.y, w, h: rh }));
|
||||
out.push((*j, *v, Bx { x, y: rem.y, w, h: rh }));
|
||||
x += w;
|
||||
}
|
||||
rem.y += rh;
|
||||
@@ -394,9 +394,12 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
|
||||
while let Some(&(j, v)) = items.get(i) {
|
||||
let a = v * scale;
|
||||
let side = rem.w.min(rem.h) as f64;
|
||||
if row.is_empty() || worst(sum + a, mn.min(a), mx.max(a), side) <= worst(sum, mn, mx, side) {
|
||||
row.push((j, a));
|
||||
(sum, mn, mx) = (sum + a, mn.min(a), mx.max(a));
|
||||
// Sum, smallest and largest item of the open row, kept incrementally (same fold order as
|
||||
// summing the row afresh, so the numbers are bit-identical).
|
||||
let with = (sum + a, mn.min(a), mx.max(a));
|
||||
if row.is_empty() || worst(with.0, with.1, with.2, side) <= worst(sum, mn, mx, side) {
|
||||
row.push((j, a, v));
|
||||
(sum, mn, mx) = with;
|
||||
i += 1;
|
||||
} else {
|
||||
layout(&row, &mut rem, &mut out);
|
||||
@@ -416,15 +419,13 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
|
||||
s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect();
|
||||
items.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
let f = Fmt::new(&s.number_format);
|
||||
let value_of: std::collections::HashMap<usize, f64> = items.iter().copied().collect();
|
||||
for (j, b) in squarify(&items, area) {
|
||||
for (j, v, b) in squarify(&items, area) {
|
||||
let fill = point_color(data, j);
|
||||
ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5)));
|
||||
if b.w > 24.0 && b.h > LABEL_SIZE * 1.6 {
|
||||
let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0);
|
||||
ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top));
|
||||
if chart.data_labels && b.h > LABEL_SIZE * 3.2 {
|
||||
let v = value_of.get(&j).copied().unwrap_or(0.0);
|
||||
let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0);
|
||||
ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top));
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa
|
||||
|
||||
// ---------------------------------------------------------------- legend
|
||||
|
||||
fn swatch_w(sw: Swatch, s: f32) -> f32 {
|
||||
pub(super) fn swatch_w(sw: Swatch, s: f32) -> f32 {
|
||||
if sw == Swatch::Box { s } else { s * 2.0 }
|
||||
}
|
||||
|
||||
@@ -149,7 +149,16 @@ pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos
|
||||
if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } }
|
||||
}
|
||||
LegendPos::Left | LegendPos::Right => {
|
||||
let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35);
|
||||
// Widest entry, capped at 35% of the area: once the cap is reached the rest cannot change it.
|
||||
let cap = area.w * 0.35;
|
||||
let mut colw = 0.0f32;
|
||||
for e in entries {
|
||||
if colw >= cap {
|
||||
break;
|
||||
}
|
||||
colw = colw.max(item_w(ctx, e));
|
||||
}
|
||||
let colw = colw.min(cap);
|
||||
let max_rows = ((area.h / row_h).floor() as usize).max(1);
|
||||
let n = entries.len().min(max_rows);
|
||||
let lh = n as f32 * row_h;
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Equality and counter tests for the label-fitting, legend-measuring and treemap rewrites.
|
||||
//! The old implementations live here as references.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use super::kinds::squarify;
|
||||
use super::legend::swatch_w;
|
||||
use super::*;
|
||||
use crate::{ApproxMeasure, Measure};
|
||||
use gridcraft_model::LegendPos;
|
||||
|
||||
/// The pre-rewrite `Ctx::fit`: one `Measure` call per candidate length, longest first.
|
||||
fn fit_reference(ctx: &Ctx, s: &str, size: f32, maxw: f32) -> String {
|
||||
if ctx.tw(s, size) <= maxw {
|
||||
return s.to_string();
|
||||
}
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut n = chars.len().min(400);
|
||||
while n > 0 {
|
||||
n -= 1;
|
||||
let t: String = chars.iter().take(n).collect::<String>() + "…";
|
||||
if ctx.tw(&t, size) <= maxw {
|
||||
return if n == 0 { String::new() } else { t };
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Counts calls; advances differ per character (wide CJK/emoji, zero-width combining marks) and
|
||||
/// "AV"-style pairs are kerned tighter, but a character never makes the text narrower.
|
||||
struct Varied {
|
||||
calls: Cell<usize>,
|
||||
}
|
||||
|
||||
impl Measure for Varied {
|
||||
fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 {
|
||||
self.calls.set(self.calls.get() + 1);
|
||||
let mut w = 0.0f32;
|
||||
let mut prev = ' ';
|
||||
for c in text.chars() {
|
||||
let adv = match c as u32 {
|
||||
0x300..=0x36f => 0.0,
|
||||
0x1f300..=0x1faff | 0x4e00..=0x9fff => 1.0,
|
||||
u => 0.3 + (u % 7) as f32 * 0.07,
|
||||
};
|
||||
let kern = if prev == 'A' && c == 'V' { 0.1 } else { 0.0 };
|
||||
w += (adv - kern) * size;
|
||||
prev = c;
|
||||
}
|
||||
if bold { w * 1.1 } else { w }
|
||||
}
|
||||
}
|
||||
|
||||
struct Counting<'a>(&'a dyn Measure, Cell<usize>);
|
||||
|
||||
impl Measure for Counting<'_> {
|
||||
fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 {
|
||||
self.1.set(self.1.get() + 1);
|
||||
self.0.text_width(text, size, bold)
|
||||
}
|
||||
}
|
||||
|
||||
/// Like a text layout that breaks at `\n`: the width is that of the widest line. Adding a
|
||||
/// newline can make the text narrower, so the width of `prefix + "…"` is not monotone.
|
||||
struct WidestLine;
|
||||
|
||||
impl Measure for WidestLine {
|
||||
fn text_width(&self, text: &str, size: f32, _bold: bool) -> f32 {
|
||||
text.split('\n')
|
||||
.map(|line| {
|
||||
line.chars()
|
||||
.map(|c| {
|
||||
size * if c == 'i' {
|
||||
0.25
|
||||
} else if c == 'M' {
|
||||
0.9
|
||||
} else {
|
||||
0.5
|
||||
}
|
||||
})
|
||||
.sum::<f32>()
|
||||
})
|
||||
.fold(0.0, f32::max)
|
||||
}
|
||||
}
|
||||
|
||||
fn corpus() -> Vec<String> {
|
||||
let mut v: Vec<String> =
|
||||
["", "a", "ab", "Hello, world", "AVAVAV tight", "日本語のラベルです", "👨👩👧 family 🎉 party", "e\u{301}e\u{301}e\u{301} combining", "…", "……"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
v.push("x".repeat(399));
|
||||
v.push("x".repeat(400));
|
||||
v.push("x".repeat(401));
|
||||
v.push("é🎉".repeat(500));
|
||||
v.push("long label ".repeat(100));
|
||||
for s in ["iiii\nMM", "iiii\n", "\niiii", "ab\ncd\nef", "iiiiiiii\niiiiMMMM\nMM", "line one\nline two is longer\nx"] {
|
||||
v.push(s.to_string());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn run_fit(m: &dyn Measure) {
|
||||
let ctx = Ctx { out: vec![], m };
|
||||
for s in corpus() {
|
||||
for size in [9.0f32, 11.0, 40.0] {
|
||||
for maxw in [-5.0, 0.0, 0.4, 6.0, 25.0, 61.5, 120.0, 800.0, 1e6, f32::INFINITY, f32::NAN] {
|
||||
assert_eq!(ctx.fit(&s, size, maxw), fit_reference(&ctx, &s, size, maxw), "{s:?} size {size} maxw {maxw}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_matches_linear_reference() {
|
||||
run_fit(&ApproxMeasure);
|
||||
run_fit(&Varied { calls: Cell::new(0) });
|
||||
run_fit(&WidestLine);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multiline_label_keeps_its_fitting_prefix() {
|
||||
// The whole label is 18 wide and "iii…" is 12.5, both too wide for 10.5, but "iiii\n…" is
|
||||
// 10 wide and fits. A binary search that stops at the failing midpoint would return nothing.
|
||||
let ctx = Ctx { out: vec![], m: &WidestLine };
|
||||
let s = "iiii\nMM";
|
||||
let size = 10.0;
|
||||
let maxw = 10.5;
|
||||
assert!(ctx.tw(s, size) > maxw);
|
||||
assert!(ctx.tw("iii…", size) > maxw && ctx.tw("iiii\n…", size) <= maxw);
|
||||
assert_eq!(ctx.fit(s, size, maxw), "iiii\n…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_measures_logarithmically() {
|
||||
let long = "x".repeat(1000);
|
||||
let c = Counting(&ApproxMeasure, Cell::new(0));
|
||||
let ctx = Ctx { out: vec![], m: &c };
|
||||
let r = ctx.fit(&long, 11.0, 60.0);
|
||||
assert!(r.ends_with('…') && r.chars().count() > 3);
|
||||
// 1 whole-string check + ceil(log2(399)) + 1 probes; the old scan needed ~390 here.
|
||||
assert!(c.1.get() <= 12, "{} Measure calls", c.1.get());
|
||||
let c2 = Counting(&ApproxMeasure, Cell::new(0));
|
||||
let ctx2 = Ctx { out: vec![], m: &c2 };
|
||||
assert_eq!(ctx2.fit("short", 11.0, 500.0), "short");
|
||||
assert_eq!(c2.1.get(), 1);
|
||||
}
|
||||
|
||||
fn legend_entries(n: usize, label: &dyn Fn(usize) -> String) -> Vec<(String, Rgba, Swatch)> {
|
||||
(0..n).map(|i| (label(i), [10, 20, 30, 255], if i % 3 == 0 { Swatch::Line } else { Swatch::Box })).collect()
|
||||
}
|
||||
|
||||
/// Colour column width as the old code computed it, from the returned remaining area.
|
||||
fn old_colw(m: &dyn Measure, entries: &[(String, Rgba, Swatch)], area: Bx) -> f32 {
|
||||
let ctx = Ctx { out: vec![], m };
|
||||
let size = LABEL_SIZE;
|
||||
let s = size * 0.8;
|
||||
entries.iter().map(|e| swatch_w(e.2, s) + 4.0 + ctx.tw(&e.0, size)).fold(0.0f32, f32::max).min(area.w * 0.35)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_legend_matches_old_column_width_and_stops_measuring_at_the_cap() {
|
||||
let area = Bx { x: 0.0, y: 0.0, w: 400.0, h: 300.0 };
|
||||
let labels: [&dyn Fn(usize) -> String; 3] =
|
||||
[&|i| format!("entry {i}"), &|i| if i == 7 { "w".repeat(80) } else { format!("e{i}") }, &|i| "very long legend label ".repeat(1 + i % 3)];
|
||||
for pos in [LegendPos::Left, LegendPos::Right] {
|
||||
for (k, label) in labels.iter().enumerate() {
|
||||
for n in [0usize, 1, 5, 40] {
|
||||
let entries = legend_entries(n, label);
|
||||
let mut ctx = Ctx { out: vec![], m: &ApproxMeasure };
|
||||
let rest = draw_legend(&mut ctx, &entries, pos, area);
|
||||
let colw = old_colw(&ApproxMeasure, &entries, area);
|
||||
assert!((rest.w - (area.w - (colw + 8.0))).abs() < 1e-4, "{pos:?} labels {k} n {n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 20,000 entries whose first one already fills the 35% cap: the old code measured all of them
|
||||
// plus the drawn rows, now only the first entry and the drawn rows are measured.
|
||||
let entries = legend_entries(20_000, &|i| if i == 0 { "w".repeat(200) } else { format!("e{i}") });
|
||||
let c = Counting(&ApproxMeasure, Cell::new(0));
|
||||
let mut ctx = Ctx { out: vec![], m: &c };
|
||||
let _ = draw_legend(&mut ctx, &entries, LegendPos::Right, area);
|
||||
let drawn = ctx.out.iter().filter(|p| matches!(p, Prim::Text { .. })).count();
|
||||
assert!(c.1.get() <= drawn * 10 + 5, "{} Measure calls for {drawn} drawn entries", c.1.get());
|
||||
assert!(c.1.get() < 500);
|
||||
}
|
||||
|
||||
/// The pre-rewrite `worst`/`squarify` (a `Vec` per item, row recomputed from scratch).
|
||||
fn worst_reference(row: &[f64], side: f64) -> f64 {
|
||||
let s: f64 = row.iter().sum();
|
||||
if s <= 0.0 || side <= 0.0 {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v)));
|
||||
let s2 = s * s;
|
||||
let w2 = side * side;
|
||||
(w2 * mx / s2).max(s2 / (w2 * mn))
|
||||
}
|
||||
|
||||
fn squarify_reference(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
|
||||
let total: f64 = items.iter().map(|i| i.1).sum();
|
||||
let area = (r.w as f64) * (r.h as f64);
|
||||
if !(total > 0.0 && area > 0.0) {
|
||||
return vec![];
|
||||
}
|
||||
let scale = area / total;
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
let mut rem = r;
|
||||
let mut row: Vec<(usize, f64)> = Vec::new();
|
||||
let mut i = 0;
|
||||
let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| {
|
||||
let s: f64 = row.iter().map(|x| x.1).sum();
|
||||
if s <= 0.0 {
|
||||
return;
|
||||
}
|
||||
if rem.w >= rem.h {
|
||||
let cw = (s / rem.h.max(1e-6) as f64) as f32;
|
||||
let mut y = rem.y;
|
||||
for (j, a) in row {
|
||||
let h = (*a / cw.max(1e-6) as f64) as f32;
|
||||
out.push((*j, Bx { x: rem.x, y, w: cw, h }));
|
||||
y += h;
|
||||
}
|
||||
rem.x += cw;
|
||||
rem.w = (rem.w - cw).max(0.0);
|
||||
} else {
|
||||
let rh = (s / rem.w.max(1e-6) as f64) as f32;
|
||||
let mut x = rem.x;
|
||||
for (j, a) in row {
|
||||
let w = (*a / rh.max(1e-6) as f64) as f32;
|
||||
out.push((*j, Bx { x, y: rem.y, w, h: rh }));
|
||||
x += w;
|
||||
}
|
||||
rem.y += rh;
|
||||
rem.h = (rem.h - rh).max(0.0);
|
||||
}
|
||||
};
|
||||
while let Some(&(j, v)) = items.get(i) {
|
||||
let a = v * scale;
|
||||
let side = rem.w.min(rem.h) as f64;
|
||||
let cur: Vec<f64> = row.iter().map(|x| x.1).collect();
|
||||
let mut with = cur.clone();
|
||||
with.push(a);
|
||||
if row.is_empty() || worst_reference(&with, side) <= worst_reference(&cur, side) {
|
||||
row.push((j, a));
|
||||
i += 1;
|
||||
} else {
|
||||
layout(&row, &mut rem, &mut out);
|
||||
row.clear();
|
||||
}
|
||||
}
|
||||
if !row.is_empty() {
|
||||
layout(&row, &mut rem, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn squarify_matches_reference() {
|
||||
let mut seed = 0x2545_f491_4f6c_dd1du64;
|
||||
let mut rnd = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
};
|
||||
let mut cases: Vec<Vec<f64>> = vec![
|
||||
vec![],
|
||||
vec![5.0],
|
||||
vec![6.0, 6.0, 4.0, 3.0, 2.0, 2.0, 1.0],
|
||||
vec![3.0, 0.0, 0.0, 1.0],
|
||||
vec![0.0, 0.0],
|
||||
vec![4.0, -1.0, 2.0],
|
||||
vec![4.0, f64::NAN, 2.0, 1.0],
|
||||
vec![f64::INFINITY, 1.0],
|
||||
vec![1e-300, 1e-310, 0.0],
|
||||
];
|
||||
for n in [2usize, 9, 40, 300] {
|
||||
let mut v: Vec<f64> = (0..n).map(|_| rnd() * 100.0).collect();
|
||||
v.sort_by(|a, b| b.total_cmp(a));
|
||||
cases.push(v);
|
||||
}
|
||||
for r in [Bx { x: 0.0, y: 0.0, w: 300.0, h: 200.0 }, Bx { x: 5.0, y: 7.0, w: 90.0, h: 400.0 }, Bx { x: 0.0, y: 0.0, w: 0.0, h: 50.0 }] {
|
||||
for vals in &cases {
|
||||
let items: Vec<(usize, f64)> = vals.iter().copied().enumerate().collect();
|
||||
let new: Vec<(usize, Bx)> = squarify(&items, r)
|
||||
.into_iter()
|
||||
.map(|(j, v, b)| {
|
||||
assert!(vals.get(j).is_some_and(|x| x.to_bits() == v.to_bits()), "value carried through");
|
||||
(j, b)
|
||||
})
|
||||
.collect();
|
||||
let old = squarify_reference(&items, r);
|
||||
assert_eq!(format!("{new:?}"), format!("{old:?}"), "{vals:?} in {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
-21
@@ -1,7 +1,7 @@
|
||||
//! Pulls chart data (names, categories, values, formats, colours) out of a workbook.
|
||||
|
||||
use gridcraft_calc::evaluate;
|
||||
use gridcraft_core::{CellRef, RangeRef, Value, category_levels, number_to_text};
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, category_levels, number_to_text};
|
||||
use gridcraft_model::{Chart, ChartKind, Theme, Workbook, style::apply_tint};
|
||||
use gridcraft_numfmt::{NumberFormat, format_value};
|
||||
|
||||
@@ -11,8 +11,17 @@ use crate::{ChartData, Rgba, SeriesData, TrendlineData};
|
||||
pub(crate) const MAX_POINTS: usize = 100_000;
|
||||
/// Cap on series per chart.
|
||||
pub(crate) const MAX_SERIES: usize = 255;
|
||||
/// Cap on trendlines drawn per series.
|
||||
/// Cap on trendlines per chart (not per series: each one is a fit over a series' points).
|
||||
pub(crate) const MAX_TRENDLINES: usize = 16;
|
||||
/// Budget for series x points in one chart. When a chart would hold more, every series (and the
|
||||
/// category list) keeps its first `MAX_TOTAL_POINTS / series` points; no series is dropped.
|
||||
pub(crate) const MAX_TOTAL_POINTS: usize = 2_000_000;
|
||||
|
||||
/// Points kept per series (and categories) in a chart of `series` series: `MAX_POINTS`, or less
|
||||
/// when `series x MAX_POINTS` would pass `MAX_TOTAL_POINTS`.
|
||||
pub(crate) fn points_per_series(series: usize) -> usize {
|
||||
(MAX_TOTAL_POINTS / series.clamp(1, MAX_SERIES)).clamp(1, MAX_POINTS)
|
||||
}
|
||||
|
||||
/// Tints used for series beyond the six theme accents: lighter, darker, lighter still…
|
||||
const CYCLE_TINTS: [f64; 6] = [0.0, 0.4, -0.25, 0.6, -0.5, 0.2];
|
||||
@@ -77,10 +86,51 @@ fn union_parts(f: &str) -> Option<Vec<&str>> {
|
||||
/// Evaluates a series formula to a flat list of values (row-major), capped. A union of
|
||||
/// references (`(A1:A3,A5)`, as PivotCharts use) is read part by part.
|
||||
fn eval_list(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Value> {
|
||||
eval_list_in(wb, sheet, formula, 0)
|
||||
eval_list_in(wb, sheet, formula, MAX_POINTS, 0)
|
||||
}
|
||||
|
||||
fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec<Value> {
|
||||
/// calc reads a reference of up to this many cells at its full size and trims a larger one to the
|
||||
/// sheet's used range (`FULL_SIZE_CELLS` in `gridcraft_calc::eval`). A clipped reference must stay
|
||||
/// above it, or the clip would turn a trimmed read into a full-size one with trailing blanks.
|
||||
const CALC_FULL_SIZE_CELLS: u64 = 16_384;
|
||||
/// Bounded references up to this many cells are read at their full size too
|
||||
/// (`FULL_SIZE_BOUNDED_CELLS` in `gridcraft_calc::eval`).
|
||||
const CALC_FULL_SIZE_BOUNDED_CELLS: u64 = 1_048_576;
|
||||
|
||||
/// `formula` as a plain reference (`Sheet!A1:A1000000`) cut after the rows that can still hold
|
||||
/// one of the first `limit` points, so that calc never reads the rest; `None` when `formula` is
|
||||
/// no reference or already small enough. Values are read row by row, and calc trims only the
|
||||
/// bottom and right of a reference, so keeping `limit` rows keeps the first `limit` values.
|
||||
fn clipped_reference(wb: &Workbook, sheet: usize, formula: &str, limit: usize) -> Option<String> {
|
||||
let (sh, mut r) = parse_ref(wb, sheet, formula)?;
|
||||
let cells = u64::from(r.height()).saturating_mul(u64::from(r.width()));
|
||||
// calc trims a block that runs to the sheet's edge, and any block over a column's worth, to
|
||||
// the used range. Do it here, or the clipped block below would be read at its full size.
|
||||
let to_edge = r.end.row == MAX_ROWS - 1 || r.end.col == MAX_COLS - 1;
|
||||
let trimmed = cells > CALC_FULL_SIZE_CELLS && (to_edge || cells > CALC_FULL_SIZE_BOUNDED_CELLS);
|
||||
let mut changed = false;
|
||||
if trimmed {
|
||||
let end = match wb.sheet(sh).and_then(|s| s.used_range()) {
|
||||
Some(u) => CellRef::new(r.end.row.min(u.end.row.max(r.start.row)), r.end.col.min(u.end.col.max(r.start.col))),
|
||||
None => r.start,
|
||||
};
|
||||
changed = end != r.end;
|
||||
r = RangeRef::new(r.start, end);
|
||||
}
|
||||
let prefix = formula.rfind('!').and_then(|i| formula.get(..=i)).unwrap_or("");
|
||||
let (h, w) = (u64::from(r.height()), u64::from(r.width()));
|
||||
if h.saturating_mul(w) > CALC_FULL_SIZE_CELLS {
|
||||
let keep = (limit as u64).max(CALC_FULL_SIZE_CELLS / w + 1);
|
||||
if h > keep {
|
||||
let end = CellRef::new(r.start.row.saturating_add(u32::try_from(keep).ok()?).saturating_sub(1), r.end.col);
|
||||
r = RangeRef::new(r.start, end);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
changed.then(|| format!("{prefix}{r}"))
|
||||
}
|
||||
|
||||
fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, limit: usize, depth: u32) -> Vec<Value> {
|
||||
let f = formula.trim();
|
||||
if f.is_empty() {
|
||||
return vec![];
|
||||
@@ -90,16 +140,17 @@ fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec<V
|
||||
{
|
||||
let mut out = Vec::new();
|
||||
for p in parts {
|
||||
if out.len() >= MAX_POINTS {
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
let rest = MAX_POINTS - out.len();
|
||||
out.extend(eval_list_in(wb, sheet, p, depth + 1).into_iter().take(rest));
|
||||
let rest = limit - out.len();
|
||||
out.extend(eval_list_in(wb, sheet, p, rest, depth + 1).into_iter().take(rest));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
match evaluate(wb, sheet, CellRef::default(), f) {
|
||||
Value::Array(a) => a.iter().take(MAX_POINTS).cloned().collect(),
|
||||
let clipped = clipped_reference(wb, sheet, f, limit);
|
||||
match evaluate(wb, sheet, CellRef::default(), clipped.as_deref().unwrap_or(f)) {
|
||||
Value::Array(a) => a.iter().take(limit).cloned().collect(),
|
||||
v => vec![v],
|
||||
}
|
||||
}
|
||||
@@ -111,8 +162,8 @@ fn to_num(v: &Value) -> Option<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
fn nums(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Option<f64>> {
|
||||
eval_list(wb, sheet, formula).iter().map(to_num).collect()
|
||||
fn nums(wb: &Workbook, sheet: usize, formula: &str, limit: usize) -> Vec<Option<f64>> {
|
||||
eval_list_in(wb, sheet, formula, limit, 0).iter().map(to_num).collect()
|
||||
}
|
||||
|
||||
/// Number format code of the first cell of a reference ("General" when not a reference).
|
||||
@@ -137,9 +188,38 @@ fn value_text(v: &Value) -> String {
|
||||
/// the outer levels of a multi-level axis: a category range wider than a column of values (or
|
||||
/// taller than a row of values) groups its innermost labels, year beside month. Each outer level
|
||||
/// has one entry per category, non-empty where a group starts, as the cells are.
|
||||
fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (Vec<String>, Vec<Vec<String>>) {
|
||||
let vals = eval_list(wb, sheet, formula);
|
||||
fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str, limit: usize) -> (Vec<String>, Vec<Vec<String>>) {
|
||||
let cells: Option<(usize, RangeRef)> = parse_ref(wb, sheet, formula);
|
||||
let value_range = parse_ref(wb, sheet, values).map(|(_, r)| r);
|
||||
// Bound the category dimension by `limit` and read only the cells the levels use (the
|
||||
// innermost label line and up to three outer ones), so that every kept category keeps its
|
||||
// cells in every level. Counting raw cells would cut a two-level axis in the middle.
|
||||
let levels = cells.and_then(|(_, r)| category_levels(&r, value_range.as_ref()));
|
||||
let (cells, read) = match (cells, levels) {
|
||||
(Some((sh, r)), Some(lv)) => {
|
||||
let cut = u32::try_from(limit.saturating_sub(1)).unwrap_or(u32::MAX);
|
||||
let outer = lv.outer;
|
||||
let end = if lv.by_columns {
|
||||
CellRef::new(r.end.row.min(r.start.row.saturating_add(cut)), r.end.col)
|
||||
} else {
|
||||
CellRef::new(r.end.row, r.end.col.min(r.start.col.saturating_add(cut)))
|
||||
};
|
||||
let start = if lv.by_columns {
|
||||
CellRef::new(r.start.row, r.start.col.max(end.col.saturating_sub(outer)))
|
||||
} else {
|
||||
CellRef::new(r.start.row.max(end.row.saturating_sub(outer)), r.start.col)
|
||||
};
|
||||
let r = RangeRef::new(start, end);
|
||||
let prefix = formula.rfind('!').and_then(|i| formula.get(..=i)).unwrap_or("");
|
||||
let cap = (r.width() as usize).saturating_mul(r.height() as usize);
|
||||
(Some((sh, r)), Some((format!("{prefix}{r}"), cap)))
|
||||
}
|
||||
(c, _) => (c, None),
|
||||
};
|
||||
let vals = match &read {
|
||||
Some((f, cap)) => eval_list_in(wb, sheet, f, *cap, 0),
|
||||
None => eval_list_in(wb, sheet, formula, limit, 0),
|
||||
};
|
||||
let src = cells.and_then(|(sh, r)| wb.sheet(sh).map(|s| (s, r)));
|
||||
let texts: Vec<String> = vals
|
||||
.iter()
|
||||
@@ -159,8 +239,7 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (
|
||||
})
|
||||
.collect();
|
||||
let Some((_, r)) = src else { return (texts, vec![]) };
|
||||
let value_range = parse_ref(wb, sheet, values).map(|(_, r)| r);
|
||||
let Some(levels) = category_levels(&r, value_range.as_ref()) else { return (texts, vec![]) };
|
||||
let Some(levels) = levels else { return (texts, vec![]) };
|
||||
let (w, h) = (r.width().max(1) as usize, r.height().max(1) as usize);
|
||||
if texts.len() != w.saturating_mul(h) {
|
||||
return (texts, vec![]); // truncated or not a plain block: a flat list
|
||||
@@ -234,37 +313,43 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
|
||||
let numeric_x = matches!(chart.kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble);
|
||||
let mut series = Vec::new();
|
||||
let mut categories: Option<Vec<String>> = None;
|
||||
let limit = points_per_series(chart.series.len());
|
||||
let mut trend_left = MAX_TRENDLINES;
|
||||
let mut category_levels = Vec::new();
|
||||
for (i, s) in chart.series.iter().take(MAX_SERIES).enumerate() {
|
||||
let values = nums(wb, sheet, &s.values);
|
||||
let values = nums(wb, sheet, &s.values, limit);
|
||||
let name = s.name.as_deref().map(|n| series_name(wb, sheet, n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series{}", i + 1));
|
||||
let color =
|
||||
s.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or_else(|| series_color(&wb.theme, i));
|
||||
let kind = effective_kind(chart.kind, s.kind, i);
|
||||
let x = match (&s.categories, numeric_x || matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble)) {
|
||||
// X values with text in them (labels) are ignored: the points are numbered 1..n.
|
||||
(Some(f), true) => Some(eval_list(wb, sheet, f)).filter(|xs| !xs.iter().any(Value::is_text)).map(|xs| xs.iter().map(to_num).collect()),
|
||||
(Some(f), true) => {
|
||||
Some(eval_list_in(wb, sheet, f, limit, 0)).filter(|xs| !xs.iter().any(Value::is_text)).map(|xs| xs.iter().map(to_num).collect())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if categories.is_none()
|
||||
&& let Some(f) = &s.categories
|
||||
{
|
||||
let (c, outer) = category_table(wb, sheet, f, &s.values);
|
||||
let (mut c, outer) = category_table(wb, sheet, f, &s.values, limit);
|
||||
c.truncate(limit);
|
||||
if !c.is_empty() {
|
||||
categories = Some(c);
|
||||
category_levels = outer;
|
||||
}
|
||||
}
|
||||
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f));
|
||||
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f, limit));
|
||||
let trendlines = s
|
||||
.trendlines
|
||||
.iter()
|
||||
.take(MAX_TRENDLINES)
|
||||
.take(trend_left)
|
||||
.map(|t| TrendlineData {
|
||||
spec: t.clone(),
|
||||
color: t.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or(color),
|
||||
})
|
||||
.collect();
|
||||
.collect::<Vec<_>>();
|
||||
trend_left = trend_left.saturating_sub(trendlines.len());
|
||||
series.push(SeriesData {
|
||||
name,
|
||||
values,
|
||||
@@ -283,3 +368,69 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
|
||||
let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE));
|
||||
ChartData { categories, category_levels, series, palette }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gridcraft_model::Workbook;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn book(rows: u32, cols: u32) -> Workbook {
|
||||
let mut wb = Workbook::new();
|
||||
if let Some(s) = wb.sheet_mut(0) {
|
||||
s.name = "My Sheet".into();
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
s.set_value(CellRef::new(r, c), Value::Number(f64::from(r * 10 + c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
wb
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_reference_is_clipped_before_it_is_evaluated() {
|
||||
let wb = book(120_000, 3);
|
||||
let rows = |f: &str, limit| {
|
||||
let g = clipped_reference(&wb, 0, f, limit)?;
|
||||
let (_, r) = parse_ref(&wb, 0, &g)?;
|
||||
Some((g, u64::from(r.height())))
|
||||
};
|
||||
// 1,000,000 rows: at most `MAX_POINTS` of them are asked for.
|
||||
assert_eq!(rows("'My Sheet'!$A$1:$A$1000000", MAX_POINTS), Some(("'My Sheet'!A1:A100000".to_string(), 100_000)));
|
||||
assert_eq!(rows("A5:A1048576", MAX_POINTS).map(|r| r.1), Some(100_000));
|
||||
assert_eq!(rows("A:A", 50_000).map(|r| r.1), Some(50_000));
|
||||
// Never below what keeps calc trimming the clipped reference like the whole one.
|
||||
assert_eq!(rows("A1:A1000000", 100).map(|r| r.1), Some(16_385));
|
||||
// Wide references keep whole rows.
|
||||
assert_eq!(rows("A1:C1000000", MAX_POINTS).map(|r| r.1), Some(100_000));
|
||||
// Small references, single cells and anything that isn't a plain reference are left alone.
|
||||
assert_eq!(rows("A1:A9000", MAX_POINTS), None);
|
||||
assert_eq!(rows("A1:A20000", MAX_POINTS), None);
|
||||
assert_eq!(rows("B2", MAX_POINTS), None);
|
||||
assert_eq!(rows("SUM(A1:A1000000)", MAX_POINTS), None);
|
||||
assert_eq!(rows("{1,2,3}", MAX_POINTS), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_does_not_change_the_points() {
|
||||
for (rows, cols) in [(10, 1), (30_000, 1), (30_000, 2), (3, 4)] {
|
||||
let wb = book(rows, cols);
|
||||
for f in ["'My Sheet'!A1:A1000000", "'My Sheet'!$A$3:$B$1048576", "A:A", "A1:C1000000", "(A1:A1000000,B1:B1000000)", "A2:A20000"] {
|
||||
for limit in [100, 7_843, 25_000, MAX_POINTS] {
|
||||
let clipped = eval_list_in(&wb, 0, f, limit, 0);
|
||||
let whole: Vec<Value> = match evaluate(&wb, 0, CellRef::default(), f) {
|
||||
Value::Array(a) => a.iter().take(limit).cloned().collect(),
|
||||
v => vec![v],
|
||||
};
|
||||
if f.starts_with('(') {
|
||||
// A union is read part by part; each part keeps its own order.
|
||||
assert!(clipped.len() <= limit);
|
||||
continue;
|
||||
}
|
||||
assert_eq!(clipped, whole, "{f} limit {limit} on {rows}x{cols}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sparkl
|
||||
|
||||
use crate::*;
|
||||
|
||||
mod budget;
|
||||
mod data_table;
|
||||
|
||||
const ALL_KINDS: [ChartKind; 26] = [
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Work and memory are bounded by the product of the chart limits (series x points, trendlines
|
||||
//! per chart, hairline bars), and clean data is rendered without a copy.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use gridcraft_model::{ChartKind, Series, Trendline, TrendlineKind, Workbook};
|
||||
|
||||
use super::{assert_sane, chart};
|
||||
use crate::render::{sanitize, sanitized};
|
||||
use crate::resolve::{MAX_POINTS, MAX_TOTAL_POINTS, MAX_TRENDLINES, points_per_series};
|
||||
use crate::*;
|
||||
|
||||
fn data_of(series: usize, points: usize) -> ChartData {
|
||||
ChartData {
|
||||
palette: vec![],
|
||||
categories: (0..points).map(|i| i.to_string()).collect(),
|
||||
category_levels: vec![],
|
||||
series: (0..series)
|
||||
.map(|s| SeriesData {
|
||||
name: format!("S{s}"),
|
||||
values: (0..points).map(|i| Some(((i * 7 + s * 13) % 100 + 1) as f64)).collect(),
|
||||
color: series_color(&Default::default(), s),
|
||||
..SeriesData::default()
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn series_rects(prims: &[Prim], color: Rgba) -> Vec<(f32, f32, f32, f32)> {
|
||||
prims
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Rect { x, y, w, h, fill: Some(f), .. } if *f == color => Some((*x, *y, *w, *h)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_point_budget_keeps_every_series_with_fewer_points() {
|
||||
assert_eq!(points_per_series(1), MAX_POINTS);
|
||||
assert_eq!(points_per_series(20), MAX_POINTS);
|
||||
assert_eq!(points_per_series(21), MAX_TOTAL_POINTS / 21);
|
||||
assert_eq!(points_per_series(255), MAX_TOTAL_POINTS / 255);
|
||||
assert_eq!(points_per_series(0), MAX_POINTS);
|
||||
|
||||
// 25 series x 100,000 points is 2.5 million: over budget.
|
||||
let d = data_of(25, MAX_POINTS);
|
||||
let kept = sanitized(&d);
|
||||
assert_eq!(kept.series.len(), 25, "no series is dropped");
|
||||
assert!(kept.series.iter().all(|s| s.values.len() == MAX_TOTAL_POINTS / 25));
|
||||
assert_eq!(kept.categories.len(), MAX_TOTAL_POINTS / 25);
|
||||
assert!(kept.series.iter().map(|s| s.values.len()).sum::<usize>() <= MAX_TOTAL_POINTS);
|
||||
// The first points are the ones kept.
|
||||
assert_eq!(kept.series[3].values[..5], d.series[3].values[..5]);
|
||||
|
||||
// Drawing it stays bounded: hairline columns are merged per pixel column.
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.data_labels = false;
|
||||
c.legend = LegendPos::None;
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
assert_sane(&prims, 400.0, 300.0);
|
||||
assert!(prims.len() < 25 * 450 + 200, "{} primitives", prims.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trendlines_are_limited_per_chart() {
|
||||
let line = |kind| TrendlineData { spec: Trendline { kind, ..Trendline::default() }, color: [1, 2, 3, 255] };
|
||||
let mut d = data_of(3, 30);
|
||||
for s in &mut d.series {
|
||||
s.trendlines = vec![line(TrendlineKind::Linear); 10];
|
||||
}
|
||||
let kept = sanitized(&d);
|
||||
let counts: Vec<usize> = kept.series.iter().map(|s| s.trendlines.len()).collect();
|
||||
assert_eq!(counts, vec![10, MAX_TRENDLINES - 10, 0], "series order decides which trendlines stay");
|
||||
let entries = legend_entries(&chart(ChartKind::Line), &kept);
|
||||
assert_eq!(entries.len(), 3 + MAX_TRENDLINES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hairline_columns_are_merged_and_wide_ones_are_not() {
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.data_labels = false;
|
||||
c.legend = LegendPos::None;
|
||||
|
||||
// Wide bars (>= 0.25 px): one rectangle per series and category, as before.
|
||||
let d = data_of(3, 40);
|
||||
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
|
||||
for s in &d.series {
|
||||
assert_eq!(series_rects(&prims, s.color).len(), 40);
|
||||
}
|
||||
|
||||
// 4 series x 20,000 categories in 400 px: the bars are hairlines.
|
||||
let d = data_of(4, 20_000);
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
assert_sane(&prims, 400.0, 300.0);
|
||||
for s in &d.series {
|
||||
let n = series_rects(&prims, s.color).len();
|
||||
assert!((1..=420).contains(&n), "{n} rectangles for one series");
|
||||
}
|
||||
|
||||
// The merged rectangles keep the envelope: alternating 1 and 10 draws the full-height bar.
|
||||
let mut d = data_of(1, 20_000);
|
||||
for (i, v) in d.series[0].values.iter_mut().enumerate() {
|
||||
*v = Some(if i % 2 == 0 { 1.0 } else { 10.0 });
|
||||
}
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
let rects = series_rects(&prims, d.series[0].color);
|
||||
let tallest = rects.iter().map(|r| r.3).fold(0.0f32, f32::max);
|
||||
assert!(rects.len() > 100 && tallest > 100.0);
|
||||
assert!(rects.iter().all(|r| (r.3 - tallest).abs() < 0.01), "every pixel column shows the tall bar");
|
||||
// And together they cover the plot's width without a gap wider than a pixel.
|
||||
let mut xs: Vec<(f32, f32)> = rects.iter().map(|r| (r.0, r.0 + r.2)).collect();
|
||||
xs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
assert!(xs.windows(2).all(|w| w[1].0 - w[0].1 < 1.0));
|
||||
|
||||
// Horizontal bars too.
|
||||
let mut c = chart(ChartKind::BarClustered);
|
||||
c.data_labels = false;
|
||||
c.legend = LegendPos::None;
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
let n = series_rects(&prims, d.series[0].color).len();
|
||||
assert!((1..=320).contains(&n), "{n} bars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clustered_hairline_series_keep_a_visible_strip() {
|
||||
// Four series of similar height, 20,000 categories in 400 px: every pixel column holds a
|
||||
// merged bar of each series, and they must not cover each other.
|
||||
for kind in [ChartKind::ColumnClustered, ChartKind::BarClustered] {
|
||||
let horizontal = kind == ChartKind::BarClustered;
|
||||
let mut c = chart(kind);
|
||||
c.data_labels = false;
|
||||
c.legend = LegendPos::None;
|
||||
let mut d = data_of(4, 20_000);
|
||||
for (s, series) in d.series.iter_mut().enumerate() {
|
||||
for (i, v) in series.values.iter_mut().enumerate() {
|
||||
*v = Some(50.0 + ((i + s) % 3) as f64);
|
||||
}
|
||||
}
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
let along = |r: &(f32, f32, f32, f32)| if horizontal { (r.1, r.1 + r.3) } else { (r.0, r.0 + r.2) };
|
||||
let mut strips: Vec<(f32, f32, usize)> = Vec::new();
|
||||
for (s, series) in d.series.iter().enumerate() {
|
||||
strips.extend(series_rects(&prims, series.color).iter().map(|r| (along(r).0, along(r).1, s)));
|
||||
}
|
||||
strips.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
assert!(strips.len() > 400);
|
||||
// Rectangles of different series never overlap (more than rounding).
|
||||
for w in strips.windows(2) {
|
||||
assert!(w[0].2 == w[1].2 || w[1].0 >= w[0].1 - 1e-3, "series {} and {} overlap", w[0].2, w[1].2);
|
||||
}
|
||||
// And all four series show up in the same pixel column.
|
||||
let col = strips[strips.len() / 2].0.floor();
|
||||
let seen: std::collections::BTreeSet<usize> = strips.iter().filter(|s| s.0.floor() == col).map(|s| s.2).collect();
|
||||
assert_eq!(seen.len(), 4, "series visible in pixel column {col}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_hairline_bars_do_not_fill_the_gap_between_disjoint_spans() {
|
||||
// Series 1 alternates +10 (stacked on the 100) and -10 (below zero): its two spans in one
|
||||
// pixel column are 100..110 and -10..0, and the 100 units between them must stay empty.
|
||||
let mut c = chart(ChartKind::ColumnStacked);
|
||||
c.data_labels = false;
|
||||
c.legend = LegendPos::None;
|
||||
let mut d = data_of(2, 20_000);
|
||||
for v in d.series[0].values.iter_mut() {
|
||||
*v = Some(100.0);
|
||||
}
|
||||
for (i, v) in d.series[1].values.iter_mut().enumerate() {
|
||||
*v = Some(if i % 2 == 0 { 10.0 } else { -10.0 });
|
||||
}
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
let rects = series_rects(&prims, d.series[1].color);
|
||||
let tallest = rects.iter().map(|r| r.3).fold(0.0f32, f32::max);
|
||||
assert!(!rects.is_empty() && tallest < 60.0, "tallest merged bar is {tallest} px");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_data_is_rendered_without_a_copy() {
|
||||
let d = data_of(3, 50);
|
||||
assert!(matches!(sanitized(&d), Cow::Borrowed(_)));
|
||||
|
||||
// Dirty data takes the copying path and comes out as `sanitize` has always produced it.
|
||||
let mut dirty = data_of(3, 50);
|
||||
dirty.series[0].values[4] = Some(f64::NAN);
|
||||
dirty.series[1].values[5] = Some(f64::INFINITY);
|
||||
dirty.series[2].values[6] = Some(1e301);
|
||||
dirty.series[2].x = Some(vec![Some(f64::NEG_INFINITY), Some(2.0)]);
|
||||
dirty.series[1].sizes = Some(vec![Some(-1e305)]);
|
||||
let got = sanitized(&dirty);
|
||||
assert!(matches!(got, Cow::Owned(_)));
|
||||
assert_eq!(*got, sanitize(&dirty));
|
||||
assert_eq!(got.series[0].values[4], None);
|
||||
assert_eq!(got.series[2].values[6], Some(1e300));
|
||||
|
||||
// Over the series cap (within the point budget) the same.
|
||||
let many = data_of(300, 3);
|
||||
let got = sanitized(&many);
|
||||
assert_eq!(got.series.len(), 255);
|
||||
assert_eq!(*got, sanitize(&many));
|
||||
|
||||
// Too many category levels take the copying path too.
|
||||
let mut levels = data_of(3, 50);
|
||||
levels.category_levels = vec![vec!["a".into()]; 5];
|
||||
let got = sanitized(&levels);
|
||||
assert!(matches!(got, Cow::Owned(_)));
|
||||
assert_eq!(got.category_levels.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_keeps_every_series_within_the_point_budget() {
|
||||
// 40 series x 60,000 points is 2.4 million: over the 2 million budget.
|
||||
let mut wb = Workbook::new();
|
||||
if let Some(s) = wb.sheet_mut(0) {
|
||||
for r in 0..60_000u32 {
|
||||
s.set_value(CellRef::new(r, 0), Value::Number(f64::from(r)));
|
||||
}
|
||||
}
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.series =
|
||||
(0..40).map(|_| Series { values: "Sheet1!$A$1:$A$1000000".into(), categories: Some("A1:A1000000".into()), ..Series::default() }).collect();
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series.len(), 40);
|
||||
assert!(d.series.iter().all(|s| s.values.len() == MAX_TOTAL_POINTS / 40));
|
||||
assert_eq!(d.categories.len(), MAX_TOTAL_POINTS / 40);
|
||||
assert_eq!(d.series[7].values.first(), Some(&Some(0.0)));
|
||||
// Trendlines: 16 per chart, in series order.
|
||||
c.series = (0..4).map(|_| Series { values: "A1:A5".into(), trendlines: vec![Default::default(); 9], ..Series::default() }).collect();
|
||||
let counts: Vec<usize> = resolve(&wb, 0, &c).series.iter().map(|s| s.trendlines.len()).collect();
|
||||
assert_eq!(counts, vec![9, 7, 0, 0]);
|
||||
}
|
||||
|
||||
/// 100 series lower the per-series cap to 20,000, below the number of category cells of a
|
||||
/// two-level axis (#1155). Each kept category keeps its inner label and its outer level.
|
||||
fn two_level_chart(n: u32, vertical: bool) -> ChartData {
|
||||
let at = |i: u32, level: u32| if vertical { CellRef::new(i, level) } else { CellRef::new(level, i) };
|
||||
let mut wb = Workbook::new();
|
||||
if let Some(s) = wb.sheet_mut(0) {
|
||||
for i in 0..n {
|
||||
s.set_value(at(i, 0), Value::Text(format!("Y{}", i / 3).into()));
|
||||
s.set_value(at(i, 1), Value::Text(format!("C{i}").into()));
|
||||
s.set_value(at(i, 2), Value::Number(f64::from(i)));
|
||||
}
|
||||
}
|
||||
let cats = format!("{}:{}", at(0, 0), at(n - 1, 1));
|
||||
let values = format!("{}:{}", at(0, 2), at(n - 1, 2));
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.series = (0..100).map(|_| Series { values: values.clone(), categories: Some(cats.clone()), ..Series::default() }).collect();
|
||||
resolve(&wb, 0, &c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_level_categories_keep_their_levels_under_the_point_cap() {
|
||||
for vertical in [true, false] {
|
||||
let d = two_level_chart(15_000, vertical);
|
||||
assert_eq!(d.series[0].values.len(), 15_000, "vertical {vertical}");
|
||||
assert_eq!(d.categories.len(), 15_000, "vertical {vertical}");
|
||||
assert_eq!(d.categories[0], "C0");
|
||||
assert_eq!(d.categories[14_999], "C14999");
|
||||
assert_eq!(d.category_levels.len(), 1, "one outer level, vertical {vertical}");
|
||||
assert_eq!(d.category_levels[0].len(), 15_000);
|
||||
assert_eq!(d.category_levels[0][3], "Y1");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_level_categories_longer_than_the_cap_are_cut_together_with_their_levels() {
|
||||
// 25,000 categories against a cap of 20,000.
|
||||
let d = two_level_chart(25_000, true);
|
||||
let cap = points_per_series(100);
|
||||
assert_eq!(d.categories.len(), cap);
|
||||
assert_eq!(d.category_levels.len(), 1);
|
||||
assert_eq!(d.category_levels[0].len(), cap);
|
||||
assert_eq!(d.categories[cap - 1], format!("C{}", cap - 1));
|
||||
assert_eq!(d.category_levels[0][cap - 3], format!("Y{}", (cap - 3) / 3));
|
||||
}
|
||||
+216
-2
@@ -72,7 +72,12 @@ pub(crate) fn fit_trendline(t: &Trendline, pts: &[(f64, f64)]) -> Option<TrendFi
|
||||
let mut aty = vec![0.0f64; m];
|
||||
for &(x, y) in &data {
|
||||
let u = (x - center) / scale;
|
||||
let pows: Vec<f64> = (first..=d).map(|k| u.powi(k as i32)).collect();
|
||||
// `d` is at most 6, so the powers `first..=d` always fit the array (no allocation per point).
|
||||
let mut buf = [0.0f64; 7];
|
||||
for (slot, k) in buf.iter_mut().zip(first..=d) {
|
||||
*slot = u.powi(k as i32);
|
||||
}
|
||||
let pows = buf.get(..m)?;
|
||||
for (i, pi) in pows.iter().enumerate() {
|
||||
if let Some(v) = aty.get_mut(i) {
|
||||
*v += pi * (y - b0);
|
||||
@@ -258,10 +263,45 @@ fn trim_zeros(s: &str) -> String {
|
||||
|
||||
/// Moving average of `period` consecutive points in plotted order: point `i` (from
|
||||
/// `period - 1`) is `(xᵢ, mean(yᵢ₋ₚ₊₁..=yᵢ))`. Empty when there are fewer points than `period`.
|
||||
///
|
||||
/// Linear in the number of points: the points are cut into blocks of `period`, and a window is
|
||||
/// the tail of one block plus the head of the next, taken from running sums that only ever add
|
||||
/// (a running sum that also subtracts would lose small values after a huge one passed through).
|
||||
pub(crate) fn moving_average(pts: &[(f64, f64)], period: u32) -> Vec<(f64, f64)> {
|
||||
let p = period.clamp(2, 255) as usize;
|
||||
let pts: Vec<(f64, f64)> = pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).collect();
|
||||
pts.windows(p).filter_map(|w| w.last().map(|(x, _)| (*x, w.iter().map(|q| q.1).sum::<f64>() / p as f64))).collect()
|
||||
let n = pts.len();
|
||||
if n < p {
|
||||
return vec![];
|
||||
}
|
||||
// `head[i]`: sum of the block's points up to and including `i`; `tail[i]`: from `i` to the block's end.
|
||||
let mut head = vec![0.0f64; n];
|
||||
let mut tail = vec![0.0f64; n];
|
||||
for (b, block) in pts.chunks(p).enumerate() {
|
||||
let (mut h, mut t) = (0.0f64, 0.0f64);
|
||||
for k in 0..block.len() {
|
||||
h += block.get(k).map_or(0.0, |q| q.1);
|
||||
if let Some(v) = head.get_mut(b * p + k) {
|
||||
*v = h;
|
||||
}
|
||||
let r = block.len() - 1 - k;
|
||||
t += block.get(r).map_or(0.0, |q| q.1);
|
||||
if let Some(v) = tail.get_mut(b * p + r) {
|
||||
*v = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
(p - 1..n)
|
||||
.filter_map(|i| {
|
||||
let s = i + 1 - p;
|
||||
let mut sum = tail.get(s)? + if s.is_multiple_of(p) { 0.0 } else { *head.get(i)? };
|
||||
if !sum.is_finite() {
|
||||
// A block sum overflowed although the window itself may not: sum it directly.
|
||||
sum = pts.get(s..=i)?.iter().map(|q| q.1).sum();
|
||||
}
|
||||
Some((pts.get(i)?.0, sum / p as f64))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Excel's automatic trendline name for the legend, e.g. `Linear (Sales)`.
|
||||
@@ -276,3 +316,177 @@ pub(crate) fn auto_name(kind: TrendlineKind, series: &str) -> String {
|
||||
};
|
||||
format!("{k} ({series})")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// xorshift64 for repeatable pseudo-random data.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn unit(&mut self) -> f64 {
|
||||
(self.next() % 1_000_000) as f64 / 1_000_000.0 - 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// The window-by-window moving average this module used to compute.
|
||||
fn moving_average_naive(pts: &[(f64, f64)], period: u32) -> Vec<(f64, f64)> {
|
||||
let p = period.clamp(2, 255) as usize;
|
||||
let pts: Vec<(f64, f64)> = pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).collect();
|
||||
pts.windows(p).filter_map(|w| w.last().map(|(x, _)| (*x, w.iter().map(|q| q.1).sum::<f64>() / p as f64))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_moving_average_matches_the_window_sums() {
|
||||
let mut r = Rng(0x9E37_79B9_7F4A_7C15);
|
||||
for case in 0..60 {
|
||||
let n = (r.next() % 700) as usize;
|
||||
let pts: Vec<(f64, f64)> = (0..n)
|
||||
.map(|i| {
|
||||
let y = match case % 5 {
|
||||
0 => r.unit() * 100.0,
|
||||
1 => 1e9 + r.unit(),
|
||||
// A huge value passing through must not leave rounding residue behind.
|
||||
2 => {
|
||||
if r.next().is_multiple_of(50) {
|
||||
1e300 * r.unit().signum()
|
||||
} else {
|
||||
r.unit()
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
if i % 2 == 0 {
|
||||
1e15
|
||||
} else {
|
||||
-1e15 + r.unit()
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if r.next().is_multiple_of(9) {
|
||||
f64::NAN
|
||||
} else {
|
||||
r.unit() * 1e-3
|
||||
}
|
||||
}
|
||||
};
|
||||
(i as f64, y)
|
||||
})
|
||||
.collect();
|
||||
for period in [0, 2, 3, 7, 50, 255, 400] {
|
||||
let fast = moving_average(&pts, period);
|
||||
let slow = moving_average_naive(&pts, period);
|
||||
assert_eq!(fast.len(), slow.len(), "case {case} period {period}");
|
||||
let p = period.clamp(2, 255) as usize;
|
||||
let finite: Vec<(f64, f64)> = pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).collect();
|
||||
for (i, ((fx, fy), (sx, sy))) in fast.iter().zip(&slow).enumerate() {
|
||||
assert_eq!(fx, sx);
|
||||
let mass: f64 = finite.iter().skip(i).take(p).map(|q| q.1.abs()).sum::<f64>() / p as f64;
|
||||
assert!((fy - sy).abs() <= 1e-12 * mass, "case {case} period {period} point {i}: {fy} vs {sy}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_average_recomputes_a_window_whose_block_sums_overflow() {
|
||||
let pts: Vec<(f64, f64)> = [0.0, -1e308, 1e308, 1e308, -1e308].iter().enumerate().map(|(i, y)| (i as f64, *y)).collect();
|
||||
let fast = moving_average(&pts, 4);
|
||||
let slow = moving_average_naive(&pts, 4);
|
||||
assert_eq!(fast, slow);
|
||||
assert!(fast.iter().all(|q| q.1.is_finite()));
|
||||
}
|
||||
|
||||
/// The trendline fit as it was written with a `Vec` of powers per data point.
|
||||
fn fit_reference(t: &Trendline, pts: &[(f64, f64)]) -> Option<TrendFit> {
|
||||
let kind = t.kind;
|
||||
let d = degree(kind)?;
|
||||
let data: Vec<(f64, f64)> = pts
|
||||
.iter()
|
||||
.filter(|(x, y)| x.is_finite() && y.is_finite() && (!log_x(kind) || *x > 0.0) && (!log_y(kind) || *y > 0.0))
|
||||
.map(|&(x, y)| (if log_x(kind) { x.ln() } else { x }, if log_y(kind) { y.ln() } else { y }))
|
||||
.collect();
|
||||
if data.len() < 2 || data.len() <= d {
|
||||
return None;
|
||||
}
|
||||
let fixed = match kind {
|
||||
TrendlineKind::Linear | TrendlineKind::Polynomial(_) => t.intercept,
|
||||
TrendlineKind::Exponential => t.intercept.filter(|b| *b > 0.0).map(f64::ln),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|b| b.is_finite());
|
||||
let n = data.len() as f64;
|
||||
let center = if fixed.is_some() { 0.0 } else { data.iter().map(|p| p.0).sum::<f64>() / n };
|
||||
let scale = data.iter().map(|p| (p.0 - center).abs()).fold(0.0f64, f64::max);
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let first = usize::from(fixed.is_some());
|
||||
let b0 = fixed.unwrap_or(0.0);
|
||||
let m = d + 1 - first;
|
||||
let mut ata = vec![vec![0.0f64; m]; m];
|
||||
let mut aty = vec![0.0f64; m];
|
||||
for &(x, y) in &data {
|
||||
let u = (x - center) / scale;
|
||||
let pows: Vec<f64> = (first..=d).map(|k| u.powi(k as i32)).collect();
|
||||
for (i, pi) in pows.iter().enumerate() {
|
||||
aty[i] += pi * (y - b0);
|
||||
for (j, pj) in pows.iter().enumerate() {
|
||||
ata[i][j] += pi * pj;
|
||||
}
|
||||
}
|
||||
}
|
||||
let sol = solve(ata, aty)?;
|
||||
let mut a = vec![b0; d + 1];
|
||||
for (k, v) in sol.into_iter().enumerate() {
|
||||
a[k + first] = v;
|
||||
}
|
||||
let coef = expand(&a, center, scale)?;
|
||||
let fit = TrendFit { kind, coef, center, scale, a, r2: None };
|
||||
let mean = data.iter().map(|p| p.1).sum::<f64>() / n;
|
||||
let (mut ss_res, mut ss_tot) = (0.0f64, 0.0f64);
|
||||
for &(x, y) in &data {
|
||||
ss_res += (y - fit.poly(x)).powi(2);
|
||||
ss_tot += if fixed.is_some() { (y - b0).powi(2) } else { (y - mean).powi(2) };
|
||||
}
|
||||
let r2 = (ss_tot > 0.0 && ss_tot.is_finite() && ss_res.is_finite()).then(|| 1.0 - ss_res / ss_tot);
|
||||
Some(TrendFit { r2, ..fit })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_without_a_vec_per_point_is_bit_identical() {
|
||||
let mut r = Rng(0x1234_5678_9ABC_DEF1);
|
||||
let kinds = [
|
||||
TrendlineKind::Linear,
|
||||
TrendlineKind::Exponential,
|
||||
TrendlineKind::Logarithmic,
|
||||
TrendlineKind::Power,
|
||||
TrendlineKind::Polynomial(2),
|
||||
TrendlineKind::Polynomial(3),
|
||||
TrendlineKind::Polynomial(6),
|
||||
TrendlineKind::MovingAverage(3),
|
||||
];
|
||||
for case in 0..40 {
|
||||
let n = 2 + (r.next() % 60) as usize;
|
||||
let pts: Vec<(f64, f64)> = (0..n)
|
||||
.map(|i| {
|
||||
let x = i as f64 * (1.0 + r.unit()) + 1.0;
|
||||
let y = if case % 4 == 0 { f64::NAN } else { (r.unit() + 0.6) * 10f64.powi((case % 7) - 2) };
|
||||
(x, if case % 4 == 0 && i % 3 != 0 { r.unit() + 1.0 } else { y })
|
||||
})
|
||||
.collect();
|
||||
for kind in kinds {
|
||||
for intercept in [None, Some(0.5), Some(-1.0)] {
|
||||
let t = Trendline { kind, intercept, ..Trendline::default() };
|
||||
assert_eq!(fit_trendline(&t, &pts), fit_reference(&t, &pts), "case {case} {kind:?} {intercept:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,7 +239,12 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
/// The rows of `range` an applied filter hides (rows hidden by hand are not among them).
|
||||
fn filtered_rows(sh: &gridcraft_model::Sheet, range: RangeRef) -> std::collections::BTreeSet<u32> {
|
||||
sh.rows.range(range.start.row..=range.end.row).filter(|(r, info)| info.hidden && sh.is_row_filtered(**r)).map(|(r, _)| *r).collect()
|
||||
let spans = sh.filter_row_spans();
|
||||
if spans.is_empty() {
|
||||
return Default::default();
|
||||
}
|
||||
let filtered = |row: u32| spans.iter().any(|&(first, last)| row >= first && row <= last);
|
||||
sh.rows.range(range.start.row..=range.end.row).filter(|(r, info)| info.hidden && filtered(**r)).map(|(r, _)| *r).collect()
|
||||
}
|
||||
|
||||
/// The Clear commands (Delete is Clear Contents). As in Excel, the rows an applied filter hides
|
||||
|
||||
@@ -328,7 +328,11 @@ pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if r.count() > 2_000_000 {
|
||||
return Err(bad("range.fill", "range too large"));
|
||||
}
|
||||
let skip = cx.wb.sheet(sheet).map(|sh| super::filtered_rows(sh, *r)).unwrap_or_default();
|
||||
for at in r.iter() {
|
||||
if skip.contains(&at.row) {
|
||||
continue;
|
||||
}
|
||||
let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
|
||||
let base = match by_style.get(&style) {
|
||||
Some(c) => c.clone(),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::cell::input_to_cell;
|
||||
use super::paste_special::paste_special;
|
||||
use super::{
|
||||
Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, bad, bool_param, cell_param_on, edit, json, ok, str_param, target_ranges,
|
||||
target_sheet,
|
||||
Cell, CellRef, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, bad, bool_param, cell_param_on, edit, json, ok, str_param,
|
||||
target_ranges, target_sheet,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- clipboard
|
||||
@@ -15,7 +15,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
|
||||
return Err(multiple());
|
||||
}
|
||||
// The selection as made (untrimmed), for Insert Copied Cells.
|
||||
let selected = ranges.first().copied().unwrap_or_default();
|
||||
let mut selected = ranges.first().copied().unwrap_or_default();
|
||||
let d = s.doc()?;
|
||||
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
|
||||
let trim = |range: RangeRef| {
|
||||
@@ -31,7 +31,27 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
|
||||
let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?;
|
||||
(std::sync::Arc::new(wb), block, areas)
|
||||
} else {
|
||||
(d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new())
|
||||
let range = trim(ranges.first().copied().unwrap_or_default());
|
||||
// A copied range with rows a filter hides takes only the rows showing: they are copied
|
||||
// like a multi-area selection stacked top to bottom (formulas as values), as in Excel.
|
||||
// A cut still takes every row.
|
||||
let hidden = if cut { Default::default() } else { super::filtered_rows(sh, range) };
|
||||
let visible = if hidden.is_empty() { Vec::new() } else { visible_row_runs(range, &hidden) };
|
||||
let any_visible = !visible.is_empty();
|
||||
let packed = match visible.as_slice() {
|
||||
[] => (d.wb.clone(), range, Vec::new()),
|
||||
[only] => (d.wb.clone(), *only, Vec::new()),
|
||||
_ => {
|
||||
let (wb, block) = slide_together(&d.wb, sheet, &visible).ok_or_else(multiple)?;
|
||||
(std::sync::Arc::new(wb), block, visible)
|
||||
}
|
||||
};
|
||||
// Whole rows copied with some hidden by a filter come as the block of rows showing, so
|
||||
// Insert Copied Cells makes room for those rows, not for the hidden ones too.
|
||||
if !hidden.is_empty() && any_visible && selected.is_full_rows() {
|
||||
selected = RangeRef::rows(packed.1.start.row, packed.1.end.row);
|
||||
}
|
||||
packed
|
||||
};
|
||||
let text = crate::display::range_text(&wb, sheet, range);
|
||||
// The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and
|
||||
@@ -46,6 +66,56 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// The runs of consecutive rows of `range` not in `hidden`, as ranges of `range`'s columns.
|
||||
fn visible_row_runs(range: RangeRef, hidden: &std::collections::BTreeSet<u32>) -> Vec<RangeRef> {
|
||||
let mut runs = Vec::new();
|
||||
let mut start = None;
|
||||
for row in range.start.row..=range.end.row {
|
||||
match (hidden.contains(&row), start) {
|
||||
(false, None) => start = Some(row),
|
||||
(true, Some(first)) => {
|
||||
runs.push(RangeRef::new(CellRef::new(first, range.start.col), CellRef::new(row - 1, range.end.col)));
|
||||
start = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(first) = start {
|
||||
runs.push(RangeRef::new(CellRef::new(first, range.start.col), CellRef::new(range.end.row, range.end.col)));
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
/// The validation rules of `src` cut into the areas of a packed block: one rule per area a rule
|
||||
/// touches, over that area's part moved by the area's shift. `sorted` areas run in order.
|
||||
fn pack_validations(src: &gridcraft_model::Sheet, sorted: &[RangeRef], shifts: &[(i64, i64)], across: bool) -> Vec<gridcraft_model::Validation> {
|
||||
let key = |c: CellRef| if across { c.col } else { c.row };
|
||||
let mut packed_rules = Vec::new();
|
||||
for dv in &src.validations {
|
||||
let Some(from) = dv.ranges.first().map(|r| r.start) else { continue };
|
||||
// The areas each of the rule's ranges touches lie side by side in the sorted list.
|
||||
let end_key = |a: &RangeRef| if across { a.end.col } else { a.end.row };
|
||||
let mut by_area: std::collections::BTreeMap<usize, Vec<RangeRef>> = Default::default();
|
||||
for r in &dv.ranges {
|
||||
let first = sorted.partition_point(|a| end_key(a) < key(r.start));
|
||||
for (i, a) in sorted.iter().enumerate().skip(first).take_while(|(_, a)| key(a.start) <= end_key(r)) {
|
||||
let (dr, dc) = shifts[i];
|
||||
let moved = r.intersection(a).and_then(|x| Some(RangeRef::new(x.start.offset(dr, dc)?, x.end.offset(dr, dc)?)));
|
||||
by_area.entry(i).or_default().extend(moved);
|
||||
}
|
||||
}
|
||||
for ranges in by_area.into_values() {
|
||||
let Some(to) = ranges.first().map(|r| r.start) else { continue };
|
||||
let mut rule = dv.clone();
|
||||
rule.f1 = super::super::data::move_rule_formula(&dv.f1, from, to);
|
||||
rule.f2 = dv.f2.as_ref().map(|f| super::super::data::move_rule_formula(f, from, to));
|
||||
rule.ranges = ranges;
|
||||
packed_rules.push(rule);
|
||||
}
|
||||
}
|
||||
packed_rules
|
||||
}
|
||||
|
||||
/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they
|
||||
/// slide together side by side, left to right) or all cover the same columns (stacked, top to
|
||||
/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed
|
||||
@@ -80,17 +150,35 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
|
||||
dst.spill.retain(|c, _| !block.contains(*c));
|
||||
dst.spill_ranges.retain(|c, _| !block.contains(*c));
|
||||
dst.comments.retain(|c, _| !block.contains(*c));
|
||||
dst.hyperlinks.retain(|c, _| !block.contains(*c));
|
||||
super::super::data::remove_validation_cells(&mut dst.validations, &[block]);
|
||||
dst.merges.retain(|m| !m.intersects(&block));
|
||||
if across {
|
||||
dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c));
|
||||
} else {
|
||||
dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r));
|
||||
}
|
||||
// Where each area lands: the shift of its cells, and its first row or column in the block.
|
||||
let key = |c: CellRef| if across { c.col } else { c.row };
|
||||
let mut next = if across { anchor.col } else { anchor.row };
|
||||
let mut shifts: Vec<(i64, i64)> = Vec::with_capacity(sorted.len());
|
||||
for a in &sorted {
|
||||
let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) };
|
||||
let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c)));
|
||||
for c in cells {
|
||||
let to = i64::from(next);
|
||||
shifts.push(if across { (0, to - i64::from(a.start.col)) } else { (to - i64::from(a.start.row), 0) });
|
||||
next = next.saturating_add(if across { a.width() } else { a.height() });
|
||||
}
|
||||
// The areas never overlap and run in order, so the area holding a cell is found by search:
|
||||
// each map below is read once, whatever the number of areas.
|
||||
let area_of = |c: CellRef| -> Option<usize> {
|
||||
let i = sorted.partition_point(|a| key(a.start) <= key(c)).checked_sub(1)?;
|
||||
sorted.get(i)?.contains(c).then_some(i)
|
||||
};
|
||||
let moved = |c: CellRef| -> Option<CellRef> {
|
||||
let (dr, dc) = shifts[area_of(c)?];
|
||||
c.offset(dr, dc)
|
||||
};
|
||||
for (a, &(dr, dc)) in sorted.iter().zip(&shifts) {
|
||||
for (c, _) in src.cells.iter_range(*a) {
|
||||
let Some(to) = c.offset(dr, dc) else { continue };
|
||||
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
|
||||
dst.set_cell(to, Cell { value: src.value(c), formula: None, style, py: None });
|
||||
@@ -98,17 +186,38 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
|
||||
dst.cell_pictures.insert(to, picture.clone());
|
||||
}
|
||||
}
|
||||
for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) {
|
||||
if let Some(to) = c.offset(dr, dc) {
|
||||
dst.comments.insert(to, comment.clone());
|
||||
}
|
||||
}
|
||||
for c in src.spill.keys().copied() {
|
||||
let Some(to) = moved(c) else { continue };
|
||||
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
|
||||
dst.set_cell(to, Cell { value: src.value(c), formula: None, style, py: None });
|
||||
if let Some(picture) = src.cell_pictures.get(&c) {
|
||||
dst.cell_pictures.insert(to, picture.clone());
|
||||
}
|
||||
for m in src.merges.iter().filter(|m| a.contains_range(m)) {
|
||||
if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
|
||||
dst.merges.push(RangeRef::new(s, e));
|
||||
}
|
||||
}
|
||||
// Links and validation rules follow their cells too, so a row the filter hides leaves its own
|
||||
// behind.
|
||||
for (c, link) in &src.hyperlinks {
|
||||
if let Some(to) = moved(*c) {
|
||||
dst.hyperlinks.insert(to, link.clone());
|
||||
}
|
||||
// Column widths (side by side) or row heights and hidden rows (stacked) follow their cells.
|
||||
}
|
||||
let packed_rules = pack_validations(src, &sorted, &shifts, across);
|
||||
for (c, comment) in &src.comments {
|
||||
if let Some(to) = moved(*c) {
|
||||
dst.comments.insert(to, comment.clone());
|
||||
}
|
||||
}
|
||||
for m in &src.merges {
|
||||
let Some(i) = area_of(m.start).filter(|&i| sorted[i].contains_range(m)) else { continue };
|
||||
let (dr, dc) = shifts[i];
|
||||
if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
|
||||
dst.merges.push(RangeRef::new(s, e));
|
||||
}
|
||||
}
|
||||
// Column widths (side by side) or row heights and hidden rows (stacked) follow their cells.
|
||||
let mut next = if across { anchor.col } else { anchor.row };
|
||||
for a in &sorted {
|
||||
if across {
|
||||
for (c, info) in src.cols.range(a.start.col..=a.end.col) {
|
||||
dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info);
|
||||
@@ -120,6 +229,7 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
|
||||
}
|
||||
next = next.saturating_add(if across { a.width() } else { a.height() });
|
||||
}
|
||||
dst.validations.extend(packed_rules);
|
||||
Some((out, block))
|
||||
}
|
||||
|
||||
|
||||
@@ -31,8 +31,9 @@ pub(super) fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result<Js
|
||||
} else {
|
||||
RangeRef::new(CellRef::new(r.start.row, r.end.col), r.end)
|
||||
};
|
||||
let skip = s.doc()?.wb.sheet(sheet).map(|sh| super::filtered_rows(sh, r)).unwrap_or_default();
|
||||
edit(s, |cx| {
|
||||
crate::fill::fill(cx, sheet, src, r, crate::fill::FillMode::Copy)?;
|
||||
crate::fill::fill_except(cx, sheet, src, r, crate::fill::FillMode::Copy, &skip)?;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,6 +45,12 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
&& !sel.is_full_rows()
|
||||
&& sel.count() <= 4_000_000;
|
||||
let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) };
|
||||
// A single cell or row repeated down a filtered selection skips the rows the filter hides; a
|
||||
// taller block is pasted straight down, hidden rows included, as in Excel.
|
||||
let skip_rows = match s.doc()?.wb.sheet(dest_sheet) {
|
||||
Some(sh) if tile && h == 1 => super::filtered_rows(sh, sel),
|
||||
_ => Default::default(),
|
||||
};
|
||||
// Excel refuses a paste that would run off the sheet (whole columns pasted below row 1, whole
|
||||
// rows right of column A, a whole column transposed) instead of pasting part of it.
|
||||
if at.offset(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1).is_none() {
|
||||
@@ -134,6 +140,9 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sc = CellRef::new(src.start.row + r, src.start.col + c);
|
||||
let (dr, dc) = if transpose { (c, r) } else { (r, c) };
|
||||
let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue };
|
||||
if skip_rows.contains(&dest.row) {
|
||||
continue;
|
||||
}
|
||||
let src_cell = if cut_move { moved_formulas.get(&sc).cloned() } else { src_sheet.cell(sc).cloned() };
|
||||
let src_val = src_sheet.value(sc);
|
||||
let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned();
|
||||
@@ -326,7 +335,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
let pasted = RangeRef::new(at, at.offset_clamped(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1));
|
||||
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
|
||||
paste_validations(cx, src_sheet, src, dest_sheet, pasted, transpose, skip_blanks)?;
|
||||
paste_validations(cx, src_sheet, src, dest_sheet, pasted, PasteMode { transpose, skip_blanks, skip_rows: &skip_rows })?;
|
||||
}
|
||||
if what == What::ColumnWidths {
|
||||
// Each pasted column takes the width of the column copied to it (transposed, the
|
||||
@@ -394,6 +403,31 @@ const MAX_PASTE_LINK_CELLS: u64 = 1_048_576;
|
||||
/// selection makes one range per tile).
|
||||
const MAX_PASTE_VALIDATION_RANGES: usize = 100_000;
|
||||
|
||||
/// How a validation paste lays the rules out: transposed or not, keeping the destination's rule under
|
||||
/// blank source cells, and leaving the rows a filter hides as they are.
|
||||
#[derive(Clone, Copy)]
|
||||
struct PasteMode<'a> {
|
||||
transpose: bool,
|
||||
skip_blanks: bool,
|
||||
skip_rows: &'a std::collections::BTreeSet<u32>,
|
||||
}
|
||||
|
||||
/// `range` less the rows in `skip`: the runs of rows between them, as ranges of its columns.
|
||||
fn without_rows(range: RangeRef, skip: &std::collections::BTreeSet<u32>) -> Vec<RangeRef> {
|
||||
let mut out = Vec::new();
|
||||
let mut next = range.start.row;
|
||||
for &row in skip.range(range.start.row..=range.end.row) {
|
||||
if row > next {
|
||||
out.push(RangeRef::new(CellRef::new(next, range.start.col), CellRef::new(row - 1, range.end.col)));
|
||||
}
|
||||
next = row + 1;
|
||||
}
|
||||
if next <= range.end.row {
|
||||
out.push(RangeRef::new(CellRef::new(next, range.start.col), CellRef::new(range.end.row, range.end.col)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Pasting validation works on ranges, as Excel stores it: the pasted rectangle is cut out of the
|
||||
/// destination's rules (cells whose source had no rule end up with none), then each source rule
|
||||
/// that covers part of the copied block `src` comes along as one rule over the translated parts.
|
||||
@@ -404,9 +438,9 @@ fn paste_validations(
|
||||
src: RangeRef,
|
||||
dest_sheet: usize,
|
||||
pasted: RangeRef,
|
||||
transpose: bool,
|
||||
skip_blanks: bool,
|
||||
mode: PasteMode<'_>,
|
||||
) -> Result<()> {
|
||||
let PasteMode { transpose, skip_blanks, skip_rows } = mode;
|
||||
let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) };
|
||||
let (tiles_r, tiles_c) = (pasted.height() / h.max(1), pasted.width() / w.max(1));
|
||||
let mut rules = Vec::new();
|
||||
@@ -426,7 +460,9 @@ fn paste_validations(
|
||||
let (r, c) = if transpose { (c, r) } else { (r, c) };
|
||||
for tr in 0..tiles_r {
|
||||
for tc in 0..tiles_c {
|
||||
if let Some(d) = pasted.start.offset(i64::from(tr * h + r), i64::from(tc * w + c)) {
|
||||
if let Some(d) = pasted.start.offset(i64::from(tr * h + r), i64::from(tc * w + c))
|
||||
&& !skip_rows.contains(&d.row)
|
||||
{
|
||||
cells.push((d.row, d.col));
|
||||
}
|
||||
}
|
||||
@@ -490,6 +526,8 @@ fn paste_validations(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The rows a filter hides keep what they have.
|
||||
let ranges: Vec<RangeRef> = ranges.into_iter().flat_map(|r| without_rows(r, skip_rows)).collect();
|
||||
let Some(anchor) = ranges.first().map(|r| r.start) else { continue };
|
||||
let mut rule = dv.clone();
|
||||
// Relative references move with the rule, as pasted formulas do: they are written for the
|
||||
@@ -507,7 +545,7 @@ fn paste_validations(
|
||||
cleared.extend(rules.iter().flat_map(|r| r.ranges.iter().copied()));
|
||||
super::super::data::remove_validation_cells(&mut sh.validations, &cleared);
|
||||
} else {
|
||||
super::super::data::remove_validation_cells(&mut sh.validations, &[pasted]);
|
||||
super::super::data::remove_validation_cells(&mut sh.validations, &without_rows(pasted, skip_rows));
|
||||
}
|
||||
sh.validations.extend(rules);
|
||||
Ok(())
|
||||
@@ -529,19 +567,19 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
|
||||
let on_target = moves_arrays && host.eq_ignore_ascii_case(target);
|
||||
let Some(sheet) = wb.sheet_mut(si) else { continue };
|
||||
for (c, f) in keys {
|
||||
let Some(expr) = f.expr() else { continue };
|
||||
let new = gridcraft_formula::adjust::adjust(expr, &host, target, e);
|
||||
let text = gridcraft_formula::print(&new);
|
||||
// Borrows the tree and copies and prints only the formulas the edit changes. A formula
|
||||
// the edit leaves alone keeps its stored text, even if that text is not canonical.
|
||||
let Some(new) = f.with_expr(|x| gridcraft_formula::adjust::adjust_if_changed(x, &host, target, e)) else { continue };
|
||||
let array = match f.array {
|
||||
Some(a) if on_target => crate::cmd::sheet::map_range(a, e),
|
||||
a => a,
|
||||
};
|
||||
if (text != f.text || array != f.array)
|
||||
&& let Some(cell) = sheet.cells.get_mut(c)
|
||||
{
|
||||
let mut nf = Formula::from_expr(new);
|
||||
nf.array = array;
|
||||
nf.dynamic = f.dynamic;
|
||||
let nf = match new {
|
||||
Some(new) => Formula { array, dynamic: f.dynamic, ..Formula::from_expr(new) },
|
||||
None if array != f.array => Formula { array, ..(*f).clone() },
|
||||
None => continue,
|
||||
};
|
||||
if let Some(cell) = sheet.cells.get_mut(c) {
|
||||
cell.formula = Some(std::sync::Arc::new(nf));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +55,12 @@ pub(super) fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
cx.wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
|
||||
let Some(sh) = cx.wb.sheet_mut(si) else { continue };
|
||||
for (c, f) in keys {
|
||||
if let Some(e) = f.expr() {
|
||||
let ne = gridcraft_formula::adjust::delete_sheet(e, &name, &order);
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne)));
|
||||
}
|
||||
// Only formulas whose tree changes are rewritten; the others keep their text.
|
||||
let changed = f.with_expr(|e| Some(gridcraft_formula::adjust::delete_sheet(e.clone(), &name, &order)).filter(|ne| ne != e));
|
||||
if let Some(Some(ne)) = changed
|
||||
&& let Some(cell) = sh.cells.get_mut(c)
|
||||
{
|
||||
cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,14 +110,12 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
cx.wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
|
||||
let Some(sh) = cx.wb.sheet_mut(si) else { continue };
|
||||
for (c, f) in keys {
|
||||
if let Some(e) = f.expr() {
|
||||
let ne = gridcraft_formula::adjust::rename_sheet(e, &old, &name);
|
||||
let text = gridcraft_formula::print(&ne);
|
||||
if text != f.text
|
||||
&& let Some(cell) = sh.cells.get_mut(c)
|
||||
{
|
||||
cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne)));
|
||||
}
|
||||
// Compares trees, so unchanged formulas are not printed; they keep their text.
|
||||
let changed = f.with_expr(|e| Some(gridcraft_formula::adjust::rename_sheet(e.clone(), &old, &name)).filter(|ne| ne != e));
|
||||
if let Some(Some(ne)) = changed
|
||||
&& let Some(cell) = sh.cells.get_mut(c)
|
||||
{
|
||||
cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne)));
|
||||
}
|
||||
}
|
||||
for ch in sh.charts.iter_mut() {
|
||||
|
||||
@@ -87,6 +87,18 @@ pub fn holds_source_in_corner(src: RangeRef, target: RangeRef) -> bool {
|
||||
|
||||
/// Fills `target` (which contains `src` at one edge) from the pattern in `src`.
|
||||
pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: FillMode) -> Result<()> {
|
||||
fill_except(cx, sheet, src, target, mode, &std::collections::BTreeSet::new())
|
||||
}
|
||||
|
||||
/// [`fill`], leaving the rows in `skip_rows` untouched (Fill Down over a filtered range).
|
||||
pub fn fill_except(
|
||||
cx: &mut Ctx,
|
||||
sheet: usize,
|
||||
src: RangeRef,
|
||||
target: RangeRef,
|
||||
mode: FillMode,
|
||||
skip_rows: &std::collections::BTreeSet<u32>,
|
||||
) -> Result<()> {
|
||||
let custom = cx.wb.custom_lists.clone();
|
||||
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
|
||||
// Direction: down/up if target extends rows, else right/left.
|
||||
@@ -119,7 +131,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
(c, sh.cell(c).cloned())
|
||||
})
|
||||
.collect();
|
||||
let targets: Vec<CellRef> = if vertical {
|
||||
let mut targets: Vec<CellRef> = if vertical {
|
||||
let rows: Vec<u32> =
|
||||
if forward { (src.end.row + 1..=target.end.row).collect() } else { (target.start.row..src.start.row).rev().collect() };
|
||||
rows.into_iter().map(|r| CellRef::new(r, lane)).collect()
|
||||
@@ -128,6 +140,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
if forward { (src.end.col + 1..=target.end.col).collect() } else { (target.start.col..src.start.col).rev().collect() };
|
||||
cols.into_iter().map(|c| CellRef::new(lane, c)).collect()
|
||||
};
|
||||
targets.retain(|t| !skip_rows.contains(&t.row));
|
||||
let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();
|
||||
let pattern = if mode.extends_series() || mode == FillMode::Alternate {
|
||||
let first_fmt = src_cells.first().map(fmt).unwrap_or_default();
|
||||
|
||||
@@ -5090,6 +5090,246 @@ fn precision_as_displayed_rounds_numbers_written_to_a_new_sheet() {
|
||||
assert!(rounded > 0, "the histogram's formatted cells should be rounded");
|
||||
}
|
||||
|
||||
/// #1101: repeating one cell or row down a filtered selection (paste, Ctrl+Enter, Fill Down) skips
|
||||
/// the rows the filter hides, as in Excel. A taller pasted block goes straight down, and rows
|
||||
/// hidden by hand are written, also as in Excel (each case measured in Excel for Microsoft 365).
|
||||
#[test]
|
||||
fn fills_and_single_row_pastes_skip_filtered_rows() {
|
||||
let fresh = s;
|
||||
let filtered = || {
|
||||
let mut s = fresh();
|
||||
let mut rows = vec![json!(["Val", "Tag"])];
|
||||
for i in 2..=11 {
|
||||
rows.push(json!([format!("v{i}"), if [3, 5, 7, 9].contains(&i) { "hide" } else { "keep" }]));
|
||||
}
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "E1", "values": [["X", "W"], ["Y", ""]]})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
|
||||
s.execute("data.filter", json!({})).unwrap();
|
||||
assert_eq!(s.execute("data.filterBy", json!({"column": "B", "values": ["keep"]})).unwrap()["hiddenRows"], 4);
|
||||
s
|
||||
};
|
||||
let col = |s: &Session, c: &str| -> Vec<Value> { (2..=11).map(|r| v(s, &format!("{c}{r}"))).collect() };
|
||||
let skipped =
|
||||
|x: &str| -> Vec<Value> { (2..=11).map(|r| Value::from(if [3, 5, 7, 9].contains(&r) { format!("v{r}") } else { x.to_string() })).collect() };
|
||||
|
||||
// One cell pasted over the filtered rows.
|
||||
let mut s = filtered();
|
||||
s.execute("edit.copy", json!({"range": "E1"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A2:A11"})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(col(&s, "A"), skipped("X"));
|
||||
|
||||
// One row (two cells) pasted over the filtered rows.
|
||||
let mut s = filtered();
|
||||
s.execute("edit.copy", json!({"range": "E1:F1"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A2:B11"})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(col(&s, "A"), skipped("X"));
|
||||
assert_eq!(v(&s, "B3"), Value::from("hide"));
|
||||
assert_eq!(v(&s, "B4"), Value::from("W"));
|
||||
|
||||
// A two-row block is pasted straight down, hidden rows included.
|
||||
let mut s = filtered();
|
||||
s.execute("edit.copy", json!({"range": "E1:E2"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A2:A11"})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(col(&s, "A"), ["X", "Y", "X", "Y", "X", "Y", "X", "Y", "X", "Y"].map(Value::from));
|
||||
|
||||
// Ctrl+Enter.
|
||||
let mut s = filtered();
|
||||
s.execute("selection.set", json!({"range": "A2:A11"})).unwrap();
|
||||
s.execute("range.fill", json!({"input": "Z"})).unwrap();
|
||||
assert_eq!(col(&s, "A"), skipped("Z"));
|
||||
|
||||
// Fill Down, and a formula filled down still adjusts to the row it lands in.
|
||||
let mut s = filtered();
|
||||
s.execute("edit.fillDown", json!({"range": "A2:A11"})).unwrap();
|
||||
assert_eq!(col(&s, "A"), skipped("v2"));
|
||||
s.execute("cell.set", json!({"cell": "C2", "input": "=ROW()"})).unwrap();
|
||||
s.execute("edit.fillDown", json!({"range": "C2:C11"})).unwrap();
|
||||
assert_eq!(v(&s, "C4"), Value::Number(4.0));
|
||||
assert!(v(&s, "C3").is_empty());
|
||||
|
||||
// Fill Up and Fill Right skip them too.
|
||||
let mut s = filtered();
|
||||
s.execute("edit.fillUp", json!({"range": "A2:A11"})).unwrap();
|
||||
assert_eq!(col(&s, "A"), skipped("v11"));
|
||||
let mut s = filtered();
|
||||
s.execute("range.setValues", json!({"range": "C2", "values": [["c2"], ["c3"], ["c4"]]})).unwrap();
|
||||
s.execute("edit.fillRight", json!({"range": "A2:C4"})).unwrap();
|
||||
assert_eq!([v(&s, "C2"), v(&s, "C3"), v(&s, "C4")], [Value::from("v2"), Value::from("c3"), Value::from("v4")]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "C4"), Value::from("c4"));
|
||||
|
||||
// Copying the filtered range takes only the rows showing, formulas as their values; a cut
|
||||
// still moves every row.
|
||||
let mut s = filtered();
|
||||
s.execute("cell.set", json!({"cell": "C2", "input": "=ROW()"})).unwrap();
|
||||
s.execute("edit.fillDown", json!({"range": "C2:C11"})).unwrap();
|
||||
let r = s.execute("edit.copy", json!({"range": "A1:C11"})).unwrap();
|
||||
assert_eq!(
|
||||
r["text"],
|
||||
"Val Tag
|
||||
v2 keep 2
|
||||
v4 keep 4
|
||||
v6 keep 6
|
||||
v8 keep 8
|
||||
v10 keep 10
|
||||
v11 keep 11
|
||||
"
|
||||
);
|
||||
s.execute("edit.paste", json!({"at": "H20"})).unwrap();
|
||||
let pasted: Vec<Value> = (20..=27).map(|r| v(&s, &format!("H{r}"))).collect();
|
||||
assert_eq!(pasted, ["Val", "v2", "v4", "v6", "v8", "v10", "v11"].map(Value::from).into_iter().chain([Value::Empty]).collect::<Vec<_>>());
|
||||
assert_eq!(v(&s, "J22"), Value::Number(4.0));
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("J22").unwrap()).unwrap().formula.is_none());
|
||||
let mut s = filtered();
|
||||
s.execute("edit.cut", json!({"range": "A1:B11"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "H20"})).unwrap();
|
||||
assert_eq!(v(&s, "H22"), Value::from("v3"));
|
||||
|
||||
// Rows hidden by hand (no filter) are written, as in Excel.
|
||||
let mut s = fresh();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"]]})).unwrap();
|
||||
s.execute("home.hideRows", json!({"rows": "2:2"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A1:A3"})).unwrap();
|
||||
s.execute("range.fill", json!({"input": "Z"})).unwrap();
|
||||
assert_eq!(v(&s, "A2"), Value::from("Z"));
|
||||
}
|
||||
|
||||
/// A table of ten rows (A1:B11) with a link on every value cell, four of its rows hidden by a
|
||||
/// filter (3, 5, 7, 9), and a spare corner at E1 to copy from.
|
||||
fn filtered_rows_with_links() -> Session {
|
||||
let mut s = s();
|
||||
let mut rows = vec![json!(["Val", "Tag"])];
|
||||
for i in 2..=11 {
|
||||
rows.push(json!([format!("v{i}"), if [3, 5, 7, 9].contains(&i) { "hide" } else { "keep" }]));
|
||||
}
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "E1", "values": [["X"]]})).unwrap();
|
||||
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
|
||||
for r in 2..=11u32 {
|
||||
sh.hyperlinks
|
||||
.insert(CellRef::new(r - 1, 0), gridcraft_model::Hyperlink { target: format!("https://example.com/{r}"), tooltip: None, external: true });
|
||||
}
|
||||
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
|
||||
s.execute("data.filter", json!({})).unwrap();
|
||||
s.execute("data.filterBy", json!({"column": "B", "values": ["keep"]})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn cell_link_target(s: &Session, cell: &str) -> Option<String> {
|
||||
s.doc().unwrap().wb.active().unwrap().hyperlinks.get(&CellRef::parse(cell).unwrap()).map(|h| h.target.clone())
|
||||
}
|
||||
|
||||
fn cell_validation(s: &Session, cell: &str) -> Option<String> {
|
||||
let c = CellRef::parse(cell).unwrap();
|
||||
s.doc().unwrap().wb.active().unwrap().validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(c))).map(|d| d.f1.clone())
|
||||
}
|
||||
|
||||
/// Copying a filtered range packs the rows showing into one block; their links and validation
|
||||
/// rules travel with them, and those of the hidden rows are left behind.
|
||||
#[test]
|
||||
fn filtered_copy_pastes_links_and_validation_of_the_rows_showing_only() {
|
||||
let mut s = filtered_rows_with_links();
|
||||
for (cell, source) in [("A3", "\"hidden\""), ("A4", "\"showing\""), ("A9", "\"hidden too\"")] {
|
||||
s.execute("data.validation", json!({"range": cell, "type": "list", "formula1": source})).unwrap();
|
||||
}
|
||||
s.execute("edit.copy", json!({"range": "A1:B11"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "H20"})).unwrap();
|
||||
let links: Vec<Option<String>> = (20..=26).map(|r| cell_link_target(&s, &format!("H{r}"))).collect();
|
||||
let expected: Vec<Option<String>> =
|
||||
[None, Some(2u32), Some(4), Some(6), Some(8), Some(10), Some(11)].iter().map(|n| n.map(|n| format!("https://example.com/{n}"))).collect();
|
||||
assert_eq!(links, expected);
|
||||
let rules: Vec<Option<String>> = (20..=26).map(|r| cell_validation(&s, &format!("H{r}"))).collect();
|
||||
assert_eq!(rules, [None, None, Some("\"showing\"".to_string()), None, None, None, None]);
|
||||
// The source keeps its own links and rules where they were.
|
||||
assert_eq!(cell_link_target(&s, "A3").as_deref(), Some("https://example.com/3"));
|
||||
assert_eq!(cell_validation(&s, "A3").as_deref(), Some("\"hidden\""));
|
||||
}
|
||||
|
||||
/// One cell pasted over a filtered column leaves the rules of the rows the filter hides alone, and
|
||||
/// does not give them the copied rule, with or without Skip Blanks.
|
||||
#[test]
|
||||
fn single_row_paste_over_filtered_rows_leaves_their_validation_alone() {
|
||||
for skip_blanks in [false, true] {
|
||||
let mut s = filtered_rows_with_links();
|
||||
s.execute("data.validation", json!({"range": "E1", "type": "list", "formula1": "\"copied\""})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "A2:A5", "type": "list", "formula1": "\"old\""})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "E1"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A2:A11"})).unwrap();
|
||||
s.execute("edit.pasteSpecial", json!({"what": "all", "skipBlanks": skip_blanks})).unwrap();
|
||||
let rules: Vec<Option<String>> = (2..=11).map(|r| cell_validation(&s, &format!("A{r}"))).collect();
|
||||
let showing = Some("\"copied\"".to_string());
|
||||
let old = Some("\"old\"".to_string());
|
||||
assert_eq!(
|
||||
rules,
|
||||
[showing.clone(), old.clone(), showing.clone(), old, showing.clone(), None, showing.clone(), None, showing.clone(), showing],
|
||||
"skip blanks: {skip_blanks}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert Copied Cells after a filtered copy of whole rows makes room for the rows copied, not for
|
||||
/// the rows that were selected.
|
||||
#[test]
|
||||
fn insert_copied_cells_after_a_filtered_row_copy_inserts_only_the_rows_showing() {
|
||||
let mut s = filtered_rows_with_links();
|
||||
s.execute("cell.set", json!({"cell": "A30", "input": "below"})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "1:11"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A20"})).unwrap();
|
||||
s.execute("home.insertCopiedCells", json!({})).unwrap();
|
||||
// Seven rows were copied (the header and six rows showing).
|
||||
assert_eq!(v(&s, "A37"), Value::from("below"));
|
||||
assert_eq!(v(&s, "A20"), Value::from("Val"));
|
||||
assert_eq!(v(&s, "A26"), Value::from("v11"));
|
||||
assert_eq!(v(&s, "A27"), Value::Empty);
|
||||
}
|
||||
|
||||
/// Copying a filtered range with many separate runs of rows showing moves each row's note, link and
|
||||
/// merge with it, and leaves the hidden rows' behind.
|
||||
#[test]
|
||||
fn filtered_copy_with_many_runs_moves_notes_and_merges_with_their_rows() {
|
||||
let mut s = s();
|
||||
let mut rows = vec![json!(["Val", "Tag"])];
|
||||
for i in 2..=2001 {
|
||||
rows.push(json!([format!("v{i}"), if i % 2 == 1 { "hide" } else { "keep" }]));
|
||||
}
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
|
||||
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
|
||||
for r in 2..=2001u32 {
|
||||
let note = gridcraft_model::Comment {
|
||||
author: "a".into(),
|
||||
text: format!("note {r}"),
|
||||
replies: vec![],
|
||||
threaded: false,
|
||||
resolved: false,
|
||||
visible: false,
|
||||
dates: vec![],
|
||||
note_box: None,
|
||||
};
|
||||
sh.comments.insert(CellRef::new(r - 1, 0), note);
|
||||
}
|
||||
sh.merges.push(gridcraft_core::RangeRef::new(CellRef::new(3, 1), CellRef::new(3, 2)));
|
||||
sh.merges.push(gridcraft_core::RangeRef::new(CellRef::new(2, 1), CellRef::new(2, 2)));
|
||||
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
|
||||
s.execute("data.filter", json!({})).unwrap();
|
||||
s.execute("data.filterBy", json!({"column": "B", "values": ["keep"]})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A1:C2001"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "H1"})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
let note = |row: u32| sh.comments.get(&CellRef::new(row - 1, 7)).map(|c| c.text.clone());
|
||||
assert_eq!(note(1), None);
|
||||
assert_eq!(note(2).as_deref(), Some("note 2"));
|
||||
assert_eq!(note(3).as_deref(), Some("note 4"));
|
||||
assert_eq!(note(1001).as_deref(), Some("note 2000"));
|
||||
assert_eq!(sh.comments.keys().filter(|c| c.col == 7).count(), 1000);
|
||||
// Only the merge on a row showing (row 4) came along, packed to the row it landed in (row 3).
|
||||
assert!(sh.merges.contains(&gridcraft_core::RangeRef::new(CellRef::new(2, 8), CellRef::new(2, 9))));
|
||||
assert_eq!(sh.merges.iter().filter(|m| m.start.col >= 7).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_quote_prefixed_text_keeps_it_text() {
|
||||
let mut s = s();
|
||||
@@ -5350,6 +5590,32 @@ fn paste_validation_skip_blanks_large_populated_paste() {
|
||||
assert!(validation_areas(&s).is_empty());
|
||||
}
|
||||
|
||||
/// A whole-column copy of a filtered range takes only the rows showing, also when they form one
|
||||
/// block (the hidden rows are all at the end) and the selection covers the whole column.
|
||||
#[test]
|
||||
fn whole_column_copy_of_a_filtered_range_leaves_the_hidden_rows_out() {
|
||||
for hidden_tail in [false, true] {
|
||||
let mut s = s();
|
||||
let mut rows = vec![json!(["Val", "Tag"])];
|
||||
for i in 2..=11 {
|
||||
let hide = if hidden_tail { i >= 8 } else { [3, 5, 7, 9].contains(&i) };
|
||||
rows.push(json!([format!("v{i}"), if hide { "hide" } else { "keep" }]));
|
||||
}
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
|
||||
s.execute("data.filter", json!({})).unwrap();
|
||||
s.execute("data.filterBy", json!({"column": "B", "values": ["keep"]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "H30", "input": "old"})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A:B"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "H1"})).unwrap();
|
||||
let col: Vec<Value> = (1..=11).map(|r| v(&s, &format!("H{r}"))).collect();
|
||||
let want: Vec<&str> =
|
||||
if hidden_tail { vec!["Val", "v2", "v3", "v4", "v5", "v6", "v7"] } else { vec!["Val", "v2", "v4", "v6", "v8", "v10", "v11"] };
|
||||
let want: Vec<Value> = want.into_iter().map(Value::from).chain(std::iter::repeat(Value::Empty)).take(11).collect();
|
||||
assert_eq!(col, want, "hidden rows stay out of a whole-column copy (hidden tail: {hidden_tail})");
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a workbook by path, and `CELL("filename")` names the file it came from, as Excel does;
|
||||
/// Save As makes the new file the one it names. The sheet-name formula of #1489 depends on it.
|
||||
#[test]
|
||||
|
||||
@@ -29,6 +29,7 @@ mod sheet_copy_spill;
|
||||
mod sheet_name_quoting;
|
||||
mod sparse_clear_invalidation;
|
||||
mod stored_list_shape;
|
||||
mod structural_edit_rewrite;
|
||||
mod table_resize_overlap;
|
||||
mod table_total_row_last_row;
|
||||
mod trace_error;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Structural edits (insert and delete of lines, sheet rename and delete) rewrite only the formulas
|
||||
//! whose tree they change. A formula they leave alone keeps its stored text and its flags.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_model::{Cell, Formula};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Json) {
|
||||
s.execute(command, params).unwrap();
|
||||
}
|
||||
|
||||
fn workbook() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
run(&mut s, "home.insertSheet", json!({"name": "Other"}));
|
||||
run(&mut s, "home.insertSheet", json!({"name": "Spare"}));
|
||||
run(&mut s, "sheet.activate", json!({"sheet": "Sheet1"}));
|
||||
s
|
||||
}
|
||||
|
||||
fn formula(s: &Session, sheet: &str, cell: &str) -> Arc<Formula> {
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
wb.sheet(wb.sheet_index(sheet).unwrap()).unwrap().cell(CellRef::parse(cell).unwrap()).unwrap().formula.clone().unwrap()
|
||||
}
|
||||
|
||||
/// Stores a formula with this exact text and no cached tree, as a JSON workbook loads it.
|
||||
fn put_raw(s: &mut Session, cell: &str, text: &str) {
|
||||
let wb = Arc::make_mut(&mut s.doc_mut().unwrap().wb);
|
||||
let i = wb.sheet_index("Sheet1").unwrap();
|
||||
let f = Formula { text: text.to_string(), expr: None, array: None, dynamic: false };
|
||||
wb.sheet_mut(i).unwrap().cells.set(CellRef::parse(cell).unwrap(), Cell::formula(f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_that_leaves_a_formula_alone_keeps_its_stored_text() {
|
||||
// The rewrite compares trees, not printed text, so a stored non-canonical text is not
|
||||
// re-printed by an edit that does not change the formula. An edit that changes it prints it.
|
||||
let mut s = workbook();
|
||||
put_raw(&mut s, "C1", "sum(a1, Other!b2)");
|
||||
run(&mut s, "home.insertRows", json!({"rows": "5:5"}));
|
||||
run(&mut s, "home.insertColumns", json!({"cols": "F:F"}));
|
||||
run(&mut s, "sheet.rename", json!({"sheet": "Spare", "name": "Spare2"}));
|
||||
run(&mut s, "home.deleteSheet", json!({"sheet": "Spare2"}));
|
||||
assert_eq!(formula(&s, "Sheet1", "C1").text, "sum(a1, Other!b2)");
|
||||
|
||||
run(&mut s, "home.insertRows", json!({"rows": "1:1"}));
|
||||
assert_eq!(formula(&s, "Sheet1", "C2").text, "SUM(A2,Other!B2)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_referenced_sheet_rewrites_the_formula() {
|
||||
let mut s = workbook();
|
||||
put_raw(&mut s, "C1", "sum(a1, Other!b2)");
|
||||
run(&mut s, "sheet.rename", json!({"sheet": "Other", "name": "Data"}));
|
||||
assert_eq!(formula(&s, "Sheet1", "C1").text, "SUM(A1,Data!B2)");
|
||||
run(&mut s, "home.deleteSheet", json!({"sheet": "Data"}));
|
||||
assert_eq!(formula(&s, "Sheet1", "C1").text, "SUM(A1,#REF!)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_rows_above_a_legacy_array_moves_its_range_when_the_formula_is_unchanged() {
|
||||
let mut s = workbook();
|
||||
run(&mut s, "range.setValues", json!({"range": "B1", "values": [[1], [2]]}));
|
||||
run(&mut s, "selection.set", json!({"range": "C5:C6"}));
|
||||
run(&mut s, "cell.set", json!({"cell": "C5", "input": "=B1:B2*2", "array": true}));
|
||||
assert_eq!(formula(&s, "Sheet1", "C5").array, Some(RangeRef::parse("C5:C6").unwrap()));
|
||||
run(&mut s, "home.insertRows", json!({"rows": "3:3"}));
|
||||
let f = formula(&s, "Sheet1", "C6");
|
||||
assert_eq!(f.text, "B1:B2*2");
|
||||
assert_eq!(f.array, Some(RangeRef::parse("C6:C7").unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_an_unrelated_sheet_keeps_legacy_arrays() {
|
||||
let mut s = workbook();
|
||||
run(&mut s, "range.setValues", json!({"range": "B1", "values": [[1], [2]]}));
|
||||
run(&mut s, "selection.set", json!({"range": "C1:C2"}));
|
||||
run(&mut s, "cell.set", json!({"cell": "C1", "input": "=B1:B2*2", "array": true}));
|
||||
run(&mut s, "home.deleteSheet", json!({"sheet": "Spare"}));
|
||||
assert_eq!(formula(&s, "Sheet1", "C1").array, Some(RangeRef::parse("C1:C2").unwrap()));
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use crate::ast::{Anchor, Expr, RefKind, Reference, SheetSel, clamp_anchor};
|
||||
/// Copy/fill: relative parts move by `(dr, dc)`. References pushed off the sheet become `#REF!`.
|
||||
pub fn shift_relative(e: Expr, dr: i64, dc: i64) -> Expr {
|
||||
e.map(&mut |x| match x {
|
||||
Expr::Ref(r) => match shift_ref(&r, dr, dc) {
|
||||
Expr::Ref(r) => match shift_ref(r, dr, dc) {
|
||||
Some(r) => Expr::Ref(r),
|
||||
None => Expr::Error(CellError::Ref),
|
||||
},
|
||||
@@ -60,14 +60,14 @@ fn shift_line(v: u32, abs: bool, d: i64, max: u32) -> Option<u32> {
|
||||
if (0..max as i64).contains(&n) { Some(n as u32) } else { None }
|
||||
}
|
||||
|
||||
fn shift_ref(r: &Reference, dr: i64, dc: i64) -> Option<Reference> {
|
||||
fn shift_ref(r: Reference, dr: i64, dc: i64) -> Option<Reference> {
|
||||
let kind = match &r.kind {
|
||||
RefKind::Cell(a) => RefKind::Cell(shift_anchor(a, dr, dc)?),
|
||||
RefKind::Range(a, b) => RefKind::Range(shift_anchor(a, dr, dc)?, shift_anchor(b, dr, dc)?),
|
||||
RefKind::Rows(r0, a0, r1, a1) => RefKind::Rows(shift_line(*r0, *a0, dr, MAX_ROWS)?, *a0, shift_line(*r1, *a1, dr, MAX_ROWS)?, *a1),
|
||||
RefKind::Cols(c0, a0, c1, a1) => RefKind::Cols(shift_line(*c0, *a0, dc, MAX_COLS)?, *a0, shift_line(*c1, *a1, dc, MAX_COLS)?, *a1),
|
||||
};
|
||||
Some(Reference { sheet: r.sheet.clone(), kind })
|
||||
Some(Reference { sheet: r.sheet, kind })
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -95,27 +95,54 @@ pub enum Edit {
|
||||
MoveToSheet { from: RangeRef, to_sheet: String, to_row: u32, to_col: u32 },
|
||||
}
|
||||
|
||||
/// Whether `r`, in a formula living on sheet `host`, points into sheet `target`.
|
||||
fn on_target(r: &Reference, host: &str, target: &str) -> bool {
|
||||
match &r.sheet {
|
||||
SheetSel::Current => host.eq_ignore_ascii_case(target),
|
||||
SheetSel::Named(n) => n.eq_ignore_ascii_case(target),
|
||||
SheetSel::Span(..) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [`adjust`] would change `r`. It runs the same [`adjust_ref`] on a copy without the
|
||||
/// sheet name, so no name is copied: the adjustment reads only the position, and the sheet a cut
|
||||
/// to another sheet assigns is compared afterwards.
|
||||
fn ref_changes(r: &Reference, edit: &Edit) -> bool {
|
||||
let probe = Reference { sheet: SheetSel::Current, kind: r.kind.clone() };
|
||||
match adjust_ref(probe, edit) {
|
||||
None => true,
|
||||
Some(p) => p.kind != r.kind || (p.sheet != SheetSel::Current && p.sheet != r.sheet),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [`adjust`] would change the formula `e` at all, without copying or rebuilding it.
|
||||
fn changes(e: &Expr, host: &str, target: &str, edit: &Edit) -> bool {
|
||||
let mut changed = false;
|
||||
e.walk(&mut |x| {
|
||||
if !changed && let Expr::Ref(r) = x {
|
||||
changed = on_target(r, host, target) && ref_changes(r, edit);
|
||||
}
|
||||
});
|
||||
changed
|
||||
}
|
||||
|
||||
/// Adjusts a formula living on sheet `host` after `edit` on sheet `target`.
|
||||
pub fn adjust(e: Expr, host: &str, target: &str, edit: &Edit) -> Expr {
|
||||
e.map(&mut |x| match x {
|
||||
Expr::Ref(r) => {
|
||||
let on_target = match &r.sheet {
|
||||
SheetSel::Current => host.eq_ignore_ascii_case(target),
|
||||
SheetSel::Named(n) => n.eq_ignore_ascii_case(target),
|
||||
SheetSel::Span(..) => false,
|
||||
};
|
||||
if !on_target {
|
||||
return Expr::Ref(r);
|
||||
}
|
||||
match adjust_ref(&r, edit) {
|
||||
Some(r) => Expr::Ref(r),
|
||||
None => Expr::Error(CellError::Ref),
|
||||
}
|
||||
}
|
||||
Expr::Ref(r) if on_target(&r, host, target) => match adjust_ref(r, edit) {
|
||||
Some(r) => Expr::Ref(r),
|
||||
None => Expr::Error(CellError::Ref),
|
||||
},
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`adjust`] for a borrowed formula: `None` when the edit leaves it exactly as it is, so a
|
||||
/// caller can skip unaffected formulas without copying, rebuilding or printing them.
|
||||
pub fn adjust_if_changed(e: &Expr, host: &str, target: &str, edit: &Edit) -> Option<Expr> {
|
||||
changes(e, host, target, edit).then(|| adjust(e.clone(), host, target, edit))
|
||||
}
|
||||
|
||||
/// New position of an index after an insert (`None` never) or delete (`None` = deleted).
|
||||
fn map_index(v: u32, at: u32, count: u32, insert: bool, max: u32) -> Option<u32> {
|
||||
if insert {
|
||||
@@ -168,7 +195,7 @@ fn map_span(lo: u32, hi: u32, at: u32, count: u32, insert: bool, max: u32) -> Op
|
||||
}
|
||||
}
|
||||
|
||||
fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
|
||||
fn adjust_ref(r: Reference, edit: &Edit) -> Option<Reference> {
|
||||
match edit {
|
||||
Edit::Insert { axis, at, count } | Edit::Delete { axis, at, count } => {
|
||||
let insert = matches!(edit, Edit::Insert { .. });
|
||||
@@ -193,7 +220,7 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
|
||||
}
|
||||
(k, _) => k.clone(),
|
||||
};
|
||||
Some(Reference { sheet: r.sheet.clone(), kind })
|
||||
Some(Reference { sheet: r.sheet, kind })
|
||||
}
|
||||
Edit::InsertCells { axis, range } | Edit::DeleteCells { axis, range } => {
|
||||
// Like inserting/deleting lines, for references that lie within the shifted
|
||||
@@ -204,7 +231,7 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
|
||||
Axis::Cols => (range.start.row <= span.start.row && span.end.row <= range.end.row, range.start.col, range.width()),
|
||||
};
|
||||
if !inside {
|
||||
return Some(r.clone());
|
||||
return Some(r);
|
||||
}
|
||||
let axis = *axis;
|
||||
let lines = if matches!(edit, Edit::InsertCells { .. }) { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } };
|
||||
@@ -215,7 +242,7 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
|
||||
let dc = *to_col as i64 - from.start.col as i64;
|
||||
let range = r.range();
|
||||
if !from.contains_range(&range) {
|
||||
return Some(r.clone());
|
||||
return Some(r);
|
||||
}
|
||||
let mv = |a: &Anchor| -> Option<Anchor> {
|
||||
let (row, col) = clamp_anchor(a.row as i64 + dr, a.col as i64 + dc)?;
|
||||
@@ -233,11 +260,11 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
|
||||
RefKind::Cols(shift_line(*a, false, dc, MAX_COLS)?, *aa, shift_line(*b, false, dc, MAX_COLS)?, *ba)
|
||||
}
|
||||
};
|
||||
Some(Reference { sheet: r.sheet.clone(), kind })
|
||||
Some(Reference { sheet: r.sheet, kind })
|
||||
}
|
||||
Edit::MoveToSheet { from, to_sheet, to_row, to_col } => {
|
||||
if !from.contains_range(&r.range()) {
|
||||
return Some(r.clone());
|
||||
return Some(r);
|
||||
}
|
||||
let mut moved = adjust_ref(r, &Edit::Move { from: *from, to_row: *to_row, to_col: *to_col })?;
|
||||
moved.sheet = SheetSel::Named(to_sheet.clone());
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
//! `adjust`, `adjust_if_changed` and `shift_relative` against a verbatim copy of the previous
|
||||
//! implementation, which cloned and rebuilt every formula. The rewrite must give the same tree
|
||||
//! (and so the same text) for every formula and edit, and `adjust_if_changed` must say
|
||||
//! "unchanged" exactly when the old code returned the formula as it was.
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
|
||||
use gridcraft_formula::adjust::{Axis, Edit, adjust, adjust_if_changed, shift_relative};
|
||||
use gridcraft_formula::{Expr, parse, print};
|
||||
|
||||
/// The implementation before the borrowed check, kept as the test oracle.
|
||||
mod old {
|
||||
use gridcraft_core::{CellError, MAX_COLS, MAX_ROWS};
|
||||
use gridcraft_formula::adjust::{Axis, Edit};
|
||||
use gridcraft_formula::{Anchor, Expr, RefKind, Reference, SheetSel};
|
||||
|
||||
fn clamp_anchor(row: i64, col: i64) -> Option<(u32, u32)> {
|
||||
if (0..MAX_ROWS as i64).contains(&row) && (0..MAX_COLS as i64).contains(&col) { Some((row as u32, col as u32)) } else { None }
|
||||
}
|
||||
|
||||
pub fn shift_relative(e: Expr, dr: i64, dc: i64) -> Expr {
|
||||
e.map(&mut |x| match x {
|
||||
Expr::Ref(r) => match shift_ref(&r, dr, dc) {
|
||||
Some(r) => Expr::Ref(r),
|
||||
None => Expr::Error(CellError::Ref),
|
||||
},
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
|
||||
fn shift_anchor(a: &Anchor, dr: i64, dc: i64) -> Option<Anchor> {
|
||||
let row = if a.row_abs { a.row as i64 } else { a.row as i64 + dr };
|
||||
let col = if a.col_abs { a.col as i64 } else { a.col as i64 + dc };
|
||||
let (row, col) = clamp_anchor(row, col)?;
|
||||
Some(Anchor { row, col, ..*a })
|
||||
}
|
||||
|
||||
fn shift_line(v: u32, abs: bool, d: i64, max: u32) -> Option<u32> {
|
||||
if abs {
|
||||
return Some(v);
|
||||
}
|
||||
let n = v as i64 + d;
|
||||
if (0..max as i64).contains(&n) { Some(n as u32) } else { None }
|
||||
}
|
||||
|
||||
fn shift_ref(r: &Reference, dr: i64, dc: i64) -> Option<Reference> {
|
||||
let kind = match &r.kind {
|
||||
RefKind::Cell(a) => RefKind::Cell(shift_anchor(a, dr, dc)?),
|
||||
RefKind::Range(a, b) => RefKind::Range(shift_anchor(a, dr, dc)?, shift_anchor(b, dr, dc)?),
|
||||
RefKind::Rows(r0, a0, r1, a1) => RefKind::Rows(shift_line(*r0, *a0, dr, MAX_ROWS)?, *a0, shift_line(*r1, *a1, dr, MAX_ROWS)?, *a1),
|
||||
RefKind::Cols(c0, a0, c1, a1) => RefKind::Cols(shift_line(*c0, *a0, dc, MAX_COLS)?, *a0, shift_line(*c1, *a1, dc, MAX_COLS)?, *a1),
|
||||
};
|
||||
Some(Reference { sheet: r.sheet.clone(), kind })
|
||||
}
|
||||
|
||||
pub fn adjust(e: Expr, host: &str, target: &str, edit: &Edit) -> Expr {
|
||||
e.map(&mut |x| match x {
|
||||
Expr::Ref(r) => {
|
||||
let on_target = match &r.sheet {
|
||||
SheetSel::Current => host.eq_ignore_ascii_case(target),
|
||||
SheetSel::Named(n) => n.eq_ignore_ascii_case(target),
|
||||
SheetSel::Span(..) => false,
|
||||
};
|
||||
if !on_target {
|
||||
return Expr::Ref(r);
|
||||
}
|
||||
match adjust_ref(&r, edit) {
|
||||
Some(r) => Expr::Ref(r),
|
||||
None => Expr::Error(CellError::Ref),
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_index(v: u32, at: u32, count: u32, insert: bool, max: u32) -> Option<u32> {
|
||||
if insert {
|
||||
if v >= at {
|
||||
let n = v as u64 + count as u64;
|
||||
if n >= max as u64 { None } else { Some(n as u32) }
|
||||
} else {
|
||||
Some(v)
|
||||
}
|
||||
} else if v < at {
|
||||
Some(v)
|
||||
} else if v >= at.saturating_add(count) {
|
||||
Some(v - count)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn map_span(lo: u32, hi: u32, at: u32, count: u32, insert: bool, max: u32) -> Option<(u32, u32)> {
|
||||
if insert {
|
||||
let lo2 = if lo >= at { lo as u64 + count as u64 } else { lo as u64 };
|
||||
let hi2 = if hi >= at { hi as u64 + count as u64 } else { hi as u64 };
|
||||
let hi2 = hi2.min(max as u64 - 1);
|
||||
if lo2 > hi2 {
|
||||
return None;
|
||||
}
|
||||
Some((lo2 as u32, hi2 as u32))
|
||||
} else {
|
||||
let end = at.saturating_add(count);
|
||||
if lo >= at && hi < end {
|
||||
return None;
|
||||
}
|
||||
let lo2 = if lo < at {
|
||||
lo
|
||||
} else if lo >= end {
|
||||
lo - count
|
||||
} else {
|
||||
at
|
||||
};
|
||||
let hi2 = if hi < at {
|
||||
hi
|
||||
} else if hi >= end {
|
||||
hi - count
|
||||
} else {
|
||||
at.saturating_sub(1)
|
||||
};
|
||||
Some((lo2, hi2.max(lo2)))
|
||||
}
|
||||
}
|
||||
|
||||
fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
|
||||
match edit {
|
||||
Edit::Insert { axis, at, count } | Edit::Delete { axis, at, count } => {
|
||||
let insert = matches!(edit, Edit::Insert { .. });
|
||||
let kind = match (&r.kind, axis) {
|
||||
(RefKind::Cell(a), Axis::Rows) => RefKind::Cell(Anchor { row: map_index(a.row, *at, *count, insert, MAX_ROWS)?, ..*a }),
|
||||
(RefKind::Cell(a), Axis::Cols) => RefKind::Cell(Anchor { col: map_index(a.col, *at, *count, insert, MAX_COLS)?, ..*a }),
|
||||
(RefKind::Range(a, b), Axis::Rows) => {
|
||||
let (lo, hi) = map_span(a.row.min(b.row), a.row.max(b.row), *at, *count, insert, MAX_ROWS)?;
|
||||
RefKind::Range(Anchor { row: lo, ..*a }, Anchor { row: hi, ..*b })
|
||||
}
|
||||
(RefKind::Range(a, b), Axis::Cols) => {
|
||||
let (lo, hi) = map_span(a.col.min(b.col), a.col.max(b.col), *at, *count, insert, MAX_COLS)?;
|
||||
RefKind::Range(Anchor { col: lo, ..*a }, Anchor { col: hi, ..*b })
|
||||
}
|
||||
(RefKind::Rows(r0, a0, r1, a1), Axis::Rows) => {
|
||||
let (lo, hi) = map_span(*r0.min(r1), *r0.max(r1), *at, *count, insert, MAX_ROWS)?;
|
||||
RefKind::Rows(lo, *a0, hi, *a1)
|
||||
}
|
||||
(RefKind::Cols(c0, a0, c1, a1), Axis::Cols) => {
|
||||
let (lo, hi) = map_span(*c0.min(c1), *c0.max(c1), *at, *count, insert, MAX_COLS)?;
|
||||
RefKind::Cols(lo, *a0, hi, *a1)
|
||||
}
|
||||
(k, _) => k.clone(),
|
||||
};
|
||||
Some(Reference { sheet: r.sheet.clone(), kind })
|
||||
}
|
||||
Edit::InsertCells { axis, range } | Edit::DeleteCells { axis, range } => {
|
||||
let span = r.range();
|
||||
let (inside, at, count) = match axis {
|
||||
Axis::Rows => (range.start.col <= span.start.col && span.end.col <= range.end.col, range.start.row, range.height()),
|
||||
Axis::Cols => (range.start.row <= span.start.row && span.end.row <= range.end.row, range.start.col, range.width()),
|
||||
};
|
||||
if !inside {
|
||||
return Some(r.clone());
|
||||
}
|
||||
let axis = *axis;
|
||||
let lines =
|
||||
if matches!(edit, Edit::InsertCells { .. }) { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } };
|
||||
adjust_ref(r, &lines)
|
||||
}
|
||||
Edit::Move { from, to_row, to_col } => {
|
||||
let dr = *to_row as i64 - from.start.row as i64;
|
||||
let dc = *to_col as i64 - from.start.col as i64;
|
||||
let range = r.range();
|
||||
if !from.contains_range(&range) {
|
||||
return Some(r.clone());
|
||||
}
|
||||
let mv = |a: &Anchor| -> Option<Anchor> {
|
||||
let (row, col) = clamp_anchor(a.row as i64 + dr, a.col as i64 + dc)?;
|
||||
Some(Anchor { row, col, ..*a })
|
||||
};
|
||||
let kind = match &r.kind {
|
||||
RefKind::Cell(a) => RefKind::Cell(mv(a)?),
|
||||
RefKind::Range(a, b) => RefKind::Range(mv(a)?, mv(b)?),
|
||||
RefKind::Rows(a, aa, b, ba) => {
|
||||
let dr = *to_row as i64 - from.start.row as i64;
|
||||
RefKind::Rows(shift_line(*a, false, dr, MAX_ROWS)?, *aa, shift_line(*b, false, dr, MAX_ROWS)?, *ba)
|
||||
}
|
||||
RefKind::Cols(a, aa, b, ba) => {
|
||||
let dc = *to_col as i64 - from.start.col as i64;
|
||||
RefKind::Cols(shift_line(*a, false, dc, MAX_COLS)?, *aa, shift_line(*b, false, dc, MAX_COLS)?, *ba)
|
||||
}
|
||||
};
|
||||
Some(Reference { sheet: r.sheet.clone(), kind })
|
||||
}
|
||||
Edit::MoveToSheet { from, to_sheet, to_row, to_col } => {
|
||||
if !from.contains_range(&r.range()) {
|
||||
return Some(r.clone());
|
||||
}
|
||||
let mut moved = adjust_ref(r, &Edit::Move { from: *from, to_row: *to_row, to_col: *to_col })?;
|
||||
moved.sheet = SheetSel::Named(to_sheet.clone());
|
||||
Some(moved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formulas with every reference shape the adjustment distinguishes.
|
||||
const FORMULAS: &[&str] = &[
|
||||
"A1",
|
||||
"B2+C3*D4",
|
||||
"$B$2+B$2+$B2",
|
||||
"SUM(A1:A10)+SUM(B2:C3)+SUM($B$2:$C$3)",
|
||||
"SUM(C3:B2)",
|
||||
"SUM(A:A)+SUM($B:$C)+SUM(2:2)+SUM($3:$5)",
|
||||
"SUM(A1:.A7)+SUM(B:.B)+ROWS(2.:.6)+SUM(A1.:B1)",
|
||||
"S!B2+s!C3+'S'!A1:B9",
|
||||
"Other!B2+Other!A:A+Other!2:3",
|
||||
"'My Sheet'!B2+'my sheet'!C3:D4",
|
||||
"Dest!B2+S!B2",
|
||||
"SUM(S:Other!B2)+SUM(Other:S!A1:C3)",
|
||||
"XFD1048576+XFD1+A1048576+SUM(XFC1048575:XFD1048576)",
|
||||
"SUM(1048576:1048576)+SUM(XFD:XFD)",
|
||||
"VLOOKUP($B2,Lookup!$A$1:$D$500,3,FALSE)",
|
||||
"IF(AND(C2>0,D2<100),C2*1.07,IF(E2=\"x\",\"yes\",\"no\"))",
|
||||
"Table1[@Qty]*Table1[@Price]+SUM(Table1[[#Data],[Qty]])",
|
||||
"SUMPRODUCT(A2:C2,{1,2,3})",
|
||||
"LET(x,A2*2,y,B2+x,x*y)",
|
||||
"LAMBDA(x,x+B2)(C3)",
|
||||
"IFERROR(INDEX(Lookup!$A:$A,MATCH(F2,Lookup!$B:$B,0)),\"\")",
|
||||
"-B2%+(C3)^2&\"a\"",
|
||||
"A1#+S!B2#",
|
||||
"S!MyName+MyName+#REF!+S!#REF!",
|
||||
"COUNTIFS(Data!$A:$A,A2,Data!$C:$C,\">=\"&$G$2)",
|
||||
"1+2",
|
||||
"\"text\"",
|
||||
"B2:C3 B3:D4",
|
||||
"(B2:C3,E5)",
|
||||
"INDIRECT(\"B2\")+OFFSET(B2,1,1)",
|
||||
// From the parser edge cases (prefixes and LET parameter names).
|
||||
"LET(_xlpm.TRUE,1,_xlpm.TRUE+1)",
|
||||
"LET(_xlpm.A1,1,_xlpm.A1)",
|
||||
"LAMBDA(_xlpm.x,_xlpm.x+1)(2)",
|
||||
"_xlfn.LET(_xlpm.R1C1,1,_xlpm.R1C1*2)",
|
||||
"LET(_xlpm.FALSE,2,_xlpm.FALSE)",
|
||||
"_xlpm.SUM(1)",
|
||||
"LET(_xlpm.x_1,1,_xlpm.x_1)",
|
||||
"_xlfn.ANCHORARRAY(A1)+_xlfn.SINGLE(B1:B3)",
|
||||
"SUM(_xlfn._TRO_ALL(A1:E10))",
|
||||
];
|
||||
|
||||
fn col(c: u32) -> String {
|
||||
gridcraft_core::addr::col_to_letters(c)
|
||||
}
|
||||
|
||||
/// The realistic mix the structural-edit benchmark uses, row-dependent.
|
||||
fn mix(i: usize, r: u32) -> String {
|
||||
let r1 = r + 1;
|
||||
match i % 12 {
|
||||
0 => format!("SUM(A{r1}:A{})", r1 + 9),
|
||||
1 => format!("VLOOKUP($B{r1},Lookup!$A$1:$D$500,3,FALSE)"),
|
||||
2 => format!("IF(AND(C{r1}>0,D{r1}<100),C{r1}*1.07,IF(E{r1}=\"x\",\"yes\",\"no\"))"),
|
||||
3 => format!("'My Sheet'!B{r1}+Data!C{r1}*2-1"),
|
||||
4 => "Table1[@Qty]*Table1[@Price]".to_string(),
|
||||
5 => format!("SUMPRODUCT(A{r1}:C{r1},{{1,2,3}})+SUM(Table1[[#Data],[Qty]])"),
|
||||
6 => format!("\"Lorem ipsum\"&A{r1}&\" units\""),
|
||||
7 => format!("IFERROR(INDEX(Lookup!$A:$A,MATCH(F{r1},Lookup!$B:$B,0)),\"\")"),
|
||||
8 => (0..20).map(|k| format!("{}{r1}", col(k))).collect::<Vec<_>>().join("+"),
|
||||
9 => format!("LET(x,A{r1}*2,y,B{r1}+x,x*y)"),
|
||||
10 => format!("ROUND(AVERAGE(B{r1}:F{r1})*(1+$H$1)^2,2)"),
|
||||
_ => format!("COUNTIFS(Data!$A:$A,A{r1},Data!$C:$C,\">=\"&$G$2)"),
|
||||
}
|
||||
}
|
||||
|
||||
fn range(r0: u32, c0: u32, r1: u32, c1: u32) -> RangeRef {
|
||||
RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1))
|
||||
}
|
||||
|
||||
/// Every edit kind, at the sheet's start, middle and edges, with counts that run past the end.
|
||||
fn edits() -> Vec<Edit> {
|
||||
let mut v = Vec::new();
|
||||
for axis in [Axis::Rows, Axis::Cols] {
|
||||
let max = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
|
||||
for at in [0, 1, 2, 3, 9, max - 2, max - 1] {
|
||||
for count in [0, 1, 2, 5, max - 1, max, u32::MAX] {
|
||||
v.push(Edit::Insert { axis, at, count });
|
||||
v.push(Edit::Delete { axis, at, count });
|
||||
}
|
||||
}
|
||||
for r in [
|
||||
range(1, 1, 2, 2),
|
||||
range(0, 0, 0, 0),
|
||||
range(1, 0, 1, MAX_COLS - 1),
|
||||
range(0, 1, MAX_ROWS - 1, 1),
|
||||
range(MAX_ROWS - 2, MAX_COLS - 2, MAX_ROWS - 1, MAX_COLS - 1),
|
||||
] {
|
||||
v.push(Edit::InsertCells { axis, range: r });
|
||||
v.push(Edit::DeleteCells { axis, range: r });
|
||||
}
|
||||
}
|
||||
let blocks = [
|
||||
range(1, 1, 2, 2),
|
||||
range(0, 0, 0, 0),
|
||||
range(0, 0, MAX_ROWS - 1, MAX_COLS - 1),
|
||||
range(0, 0, MAX_ROWS - 1, 2),
|
||||
range(1, 0, 4, MAX_COLS - 1),
|
||||
range(0, 0, 20, 10),
|
||||
];
|
||||
let targets = [(9, 3), (0, 0), (1, 1), (MAX_ROWS - 1, MAX_COLS - 1), (MAX_ROWS - 2, 0)];
|
||||
for from in blocks {
|
||||
for (to_row, to_col) in targets {
|
||||
v.push(Edit::Move { from, to_row, to_col });
|
||||
for to_sheet in ["Dest", "S", "s", "Other"] {
|
||||
v.push(Edit::MoveToSheet { from, to_sheet: to_sheet.to_string(), to_row, to_col });
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Compares the new and the old adjustment of `src` for every host and edit; returns the
|
||||
/// number of (formula, host, edit) cases checked, or 0 when `src` does not parse.
|
||||
fn check(src: &str, edits: &[Edit]) -> usize {
|
||||
let Ok(e) = parse(src) else { return 0 };
|
||||
let mut n = 0;
|
||||
for host in ["S", "s", "Other", "My Sheet"] {
|
||||
for edit in edits {
|
||||
let want = old::adjust(e.clone(), host, "S", edit);
|
||||
let got = adjust(e.clone(), host, "S", edit);
|
||||
assert!(got == want, "{src} on {host} after {edit:?}: {} vs {}", print(&got), print(&want));
|
||||
assert_eq!(print(&got), print(&want), "{src} on {host} after {edit:?}");
|
||||
match adjust_if_changed(&e, host, "S", edit) {
|
||||
None => assert!(want == e, "{src} on {host} after {edit:?}: reported unchanged, old gives {}", print(&want)),
|
||||
Some(x) => {
|
||||
assert!(want != e, "{src} on {host} after {edit:?}: reported changed but the old code keeps it");
|
||||
assert!(x == want, "{src} on {host} after {edit:?}: {} vs {}", print(&x), print(&want));
|
||||
}
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for (dr, dc) in [(0, 0), (1, 0), (0, 1), (-1, -1), (5, 3), (-3, 2), (MAX_ROWS as i64, 0), (0, -(MAX_COLS as i64)), (1_048_575, 16_383)] {
|
||||
let want = old::shift_relative(e.clone(), dr, dc);
|
||||
let got = shift_relative(e.clone(), dr, dc);
|
||||
assert!(got == want, "{src} shifted by ({dr}, {dc}): {} vs {}", print(&got), print(&want));
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_matches_the_previous_implementation_for_every_edit_kind() {
|
||||
let edits = edits();
|
||||
let mut cases = 0;
|
||||
for src in FORMULAS {
|
||||
let n = check(src, &edits);
|
||||
assert!(n > 0, "{src} does not parse");
|
||||
cases += n;
|
||||
}
|
||||
for i in 0..48 {
|
||||
// Rows 1-2 and 1,048,565-1,048,566 of the mix (its longest range reaches 9 rows further
|
||||
// down, to the last row): near both edges of the sheet.
|
||||
let row = if i < 24 { (i / 12) as u32 } else { MAX_ROWS - 12 + (i / 12 - 2) as u32 };
|
||||
let src = mix(i, row);
|
||||
let n = check(&src, &edits);
|
||||
assert!(n > 0, "{src} does not parse");
|
||||
cases += n;
|
||||
}
|
||||
assert!(cases > 100_000, "{cases}");
|
||||
}
|
||||
|
||||
/// A small deterministic generator: a failing case is reproduced by rerunning the test, and
|
||||
/// its message names the case's index in the stream.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
self.0 >> 33
|
||||
}
|
||||
fn below(&mut self, n: u64) -> u64 {
|
||||
self.next() % n
|
||||
}
|
||||
fn line(&mut self, max: u32) -> u32 {
|
||||
match self.below(3) {
|
||||
0 => self.below(12) as u32,
|
||||
1 => max - 1 - self.below(12) as u32,
|
||||
_ => self.below(max as u64) as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn random_ref(rng: &mut Rng) -> String {
|
||||
let sheet = ["", "", "S!", "s!", "Other!", "'My Sheet'!", "S:Other!"][rng.below(7) as usize];
|
||||
let dollar = |rng: &mut Rng| if rng.below(3) == 0 { "$" } else { "" };
|
||||
let cell = |rng: &mut Rng| format!("{}{}{}{}", dollar(rng), col(rng.line(MAX_COLS)), dollar(rng), rng.line(MAX_ROWS) + 1);
|
||||
let body = match rng.below(4) {
|
||||
0 => cell(rng),
|
||||
1 => format!("{}:{}", cell(rng), cell(rng)),
|
||||
2 => {
|
||||
let (a, b) = (rng.line(MAX_ROWS) + 1, rng.line(MAX_ROWS) + 1);
|
||||
format!("{}{a}:{}{b}", dollar(rng), dollar(rng))
|
||||
}
|
||||
_ => {
|
||||
let (a, b) = (rng.line(MAX_COLS), rng.line(MAX_COLS));
|
||||
format!("{}{}:{}{}", dollar(rng), col(a), dollar(rng), col(b))
|
||||
}
|
||||
};
|
||||
format!("{sheet}{body}")
|
||||
}
|
||||
|
||||
fn random_edit(rng: &mut Rng) -> Edit {
|
||||
let axis = if rng.below(2) == 0 { Axis::Rows } else { Axis::Cols };
|
||||
let max = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
|
||||
let block = |rng: &mut Rng| {
|
||||
let (r0, r1) = (rng.line(MAX_ROWS), rng.line(MAX_ROWS));
|
||||
let (c0, c1) = (rng.line(MAX_COLS), rng.line(MAX_COLS));
|
||||
range(r0.min(r1), c0.min(c1), r0.max(r1), c0.max(c1))
|
||||
};
|
||||
let count = if rng.below(8) == 0 { rng.below(u32::MAX as u64) as u32 } else { rng.below(20) as u32 };
|
||||
match rng.below(7) {
|
||||
0 => Edit::Insert { axis, at: rng.line(max), count },
|
||||
1 => Edit::Delete { axis, at: rng.line(max), count },
|
||||
2 => Edit::InsertCells { axis, range: block(rng) },
|
||||
3 => Edit::DeleteCells { axis, range: block(rng) },
|
||||
4 => Edit::Move { from: block(rng), to_row: rng.line(MAX_ROWS), to_col: rng.line(MAX_COLS) },
|
||||
_ => Edit::MoveToSheet {
|
||||
from: block(rng),
|
||||
to_sheet: ["Dest", "S", "other"][rng.below(3) as usize].to_string(),
|
||||
to_row: rng.line(MAX_ROWS),
|
||||
to_col: rng.line(MAX_COLS),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Random references near the sheet's edges and random edits: "unchanged" is reported exactly
|
||||
/// when the previous implementation leaves the formula as it was, and a reported change is the
|
||||
/// old result.
|
||||
#[test]
|
||||
fn adjust_if_changed_reports_unchanged_exactly_when_nothing_moves() {
|
||||
let mut rng = Rng(0x5eed);
|
||||
let (mut unchanged, mut changed) = (0, 0);
|
||||
for case in 0..20_000 {
|
||||
let refs: Vec<String> = (0..1 + rng.below(4)).map(|_| random_ref(&mut rng)).collect();
|
||||
let src = format!("SUM({})", refs.join(","));
|
||||
let Ok(e) = parse(&src) else { panic!("case {case}: {src} does not parse") };
|
||||
let edit = random_edit(&mut rng);
|
||||
let host = ["S", "Other"][rng.below(2) as usize];
|
||||
let want = old::adjust(e.clone(), host, "S", &edit);
|
||||
assert!(adjust(e.clone(), host, "S", &edit) == want, "case {case}: {src} on {host} after {edit:?}");
|
||||
match adjust_if_changed(&e, host, "S", &edit) {
|
||||
None => {
|
||||
assert!(want == e, "case {case}: {src} on {host} after {edit:?}: reported unchanged, old gives {}", print(&want));
|
||||
unchanged += 1;
|
||||
}
|
||||
Some(x) => {
|
||||
assert!(want != e && x == want, "case {case}: {src} on {host} after {edit:?}");
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Both outcomes are exercised in quantity.
|
||||
assert!(unchanged > 2_000 && changed > 2_000, "unchanged {unchanged}, changed {changed}");
|
||||
}
|
||||
|
||||
/// The whole formula corpus (one formula per line, with or without `=`) when
|
||||
/// `GRIDCRAFT_ADJUST_CORPUS` names a file: `cargo test -p gridcraft-formula --test it -- --ignored adjust_corpus`.
|
||||
#[test]
|
||||
#[ignore = "needs GRIDCRAFT_ADJUST_CORPUS"]
|
||||
fn adjust_corpus_matches_the_previous_implementation() {
|
||||
let Ok(path) = std::env::var("GRIDCRAFT_ADJUST_CORPUS") else { return };
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||
let edits = edits();
|
||||
let (mut parsed, mut skipped, mut cases) = (0, 0, 0);
|
||||
for line in text.lines().filter(|l| !l.trim().is_empty()) {
|
||||
match check(line, &edits) {
|
||||
0 => skipped += 1,
|
||||
n => {
|
||||
parsed += 1;
|
||||
cases += n;
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("corpus: {parsed} formulas parsed, {skipped} skipped (do not parse), {cases} cases");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unaffected_formula_is_returned_as_is() {
|
||||
let e: Expr = parse("Other!B2+SUM(A1:A3)").unwrap();
|
||||
let ins = Edit::Insert { axis: Axis::Rows, at: 5, count: 1 };
|
||||
// Row 6 inserted: A1:A3 lies above it and Other!B2 is not on S, so nothing moves, whichever
|
||||
// sheet the formula lives on (on Other, A1:A3 is on Other too).
|
||||
assert!(adjust_if_changed(&e, "S", "S", &ins).is_none());
|
||||
assert!(adjust_if_changed(&e, "Other", "S", &ins).is_none());
|
||||
// Row 1 inserted: A1:A3 on S moves, Other!B2 does not.
|
||||
let top = Edit::Insert { axis: Axis::Rows, at: 0, count: 1 };
|
||||
assert_eq!(adjust_if_changed(&e, "S", "S", &top).map(|x| print(&x)).as_deref(), Some("Other!B2+SUM(A2:A4)"));
|
||||
// A cut that moves a block onto the same place still qualifies its references with the sheet.
|
||||
let same_place = Edit::MoveToSheet { from: range(0, 0, 2, 0), to_sheet: "S".into(), to_row: 0, to_col: 0 };
|
||||
assert_eq!(adjust_if_changed(&e, "S", "S", &same_place).map(|x| print(&x)).as_deref(), Some("Other!B2+SUM(S!A1:A3)"));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! The crate's integration tests, one module per file, built as one test binary.
|
||||
|
||||
mod adjust_oracle;
|
||||
mod printer_roundtrip;
|
||||
mod spanish_input;
|
||||
@@ -88,11 +88,17 @@ pub(crate) fn reg_gamma_p(a: f64, x: f64) -> f64 {
|
||||
if x.is_infinite() {
|
||||
return 1.0;
|
||||
}
|
||||
if x < a + 1.0 { gamma_series(a, x) } else { 1.0 - gamma_cf(a, x) }
|
||||
if x < a + 1.0 { gamma_series(a, x, front_of(a, x)) } else { 1.0 - gamma_cf(a, x, front_of(a, x)) }
|
||||
}
|
||||
|
||||
/// Regularised upper incomplete gamma Q(a, x) = 1 − P(a, x).
|
||||
pub(crate) fn reg_gamma_q(a: f64, x: f64) -> f64 {
|
||||
let front = if a > 0.0 && x > 0.0 && x.is_finite() { (-x + a * x.ln() - ln_gamma(a)).exp() } else { 0.0 };
|
||||
reg_gamma_q_front(a, x, front)
|
||||
}
|
||||
|
||||
/// Q(a, x) given the prefactor `e^-x x^a / Γ(a)` (the Poisson mass at a - 1, times x).
|
||||
pub(crate) fn reg_gamma_q_front(a: f64, x: f64, front: f64) -> f64 {
|
||||
if a.is_nan() || a <= 0.0 || x.is_nan() || x < 0.0 || !a.is_finite() {
|
||||
return f64::NAN;
|
||||
}
|
||||
@@ -102,10 +108,15 @@ pub(crate) fn reg_gamma_q(a: f64, x: f64) -> f64 {
|
||||
if x.is_infinite() {
|
||||
return 0.0;
|
||||
}
|
||||
if x < a + 1.0 { 1.0 - gamma_series(a, x) } else { gamma_cf(a, x) }
|
||||
if x < a + 1.0 { 1.0 - gamma_series(a, x, front) } else { gamma_cf(a, x, front) }
|
||||
}
|
||||
|
||||
fn gamma_series(a: f64, x: f64) -> f64 {
|
||||
/// `e^-x x^a / Γ(a)`.
|
||||
fn front_of(a: f64, x: f64) -> f64 {
|
||||
(-x + a * x.ln() - ln_gamma(a)).exp()
|
||||
}
|
||||
|
||||
fn gamma_series(a: f64, x: f64, front: f64) -> f64 {
|
||||
let mut ap = a;
|
||||
let mut sum = 1.0 / a;
|
||||
let mut del = sum;
|
||||
@@ -117,10 +128,10 @@ fn gamma_series(a: f64, x: f64) -> f64 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
sum * (-x + a * x.ln() - ln_gamma(a)).exp()
|
||||
sum * front
|
||||
}
|
||||
|
||||
fn gamma_cf(a: f64, x: f64) -> f64 {
|
||||
fn gamma_cf(a: f64, x: f64, front: f64) -> f64 {
|
||||
let tiny = 1e-300;
|
||||
let mut b = x + 1.0 - a;
|
||||
let mut c = 1.0 / tiny;
|
||||
@@ -144,7 +155,7 @@ fn gamma_cf(a: f64, x: f64) -> f64 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(-x + a * x.ln() - ln_gamma(a)).exp() * h
|
||||
front * h
|
||||
}
|
||||
|
||||
/// Error function.
|
||||
@@ -187,6 +198,17 @@ pub(crate) fn erfc(x: f64) -> f64 {
|
||||
|
||||
/// Regularised incomplete beta I_x(a, b).
|
||||
pub(crate) fn reg_inc_beta(x: f64, a: f64, b: f64) -> f64 {
|
||||
if x > 0.0 && x < 1.0 && a > 0.0 && b > 0.0 {
|
||||
let ln_bt = -ln_beta(a, b) + a * x.ln() + b * (1.0 - x).ln();
|
||||
return reg_inc_beta_front(x, a, b, ln_bt.exp());
|
||||
}
|
||||
reg_inc_beta_front(x, a, b, 0.0)
|
||||
}
|
||||
|
||||
/// I_x(a, b) given the prefactor `bt = x^a (1-x)^b / B(a, b)` (symmetric in the two sides). A
|
||||
/// caller that knows `bt` more accurately than the log-gamma route gives (the binomial mass, for
|
||||
/// instance) passes it here.
|
||||
pub(crate) fn reg_inc_beta_front(x: f64, a: f64, b: f64, bt: f64) -> f64 {
|
||||
if a.is_nan() || a <= 0.0 || b.is_nan() || b <= 0.0 || !(0.0..=1.0).contains(&x) {
|
||||
return f64::NAN;
|
||||
}
|
||||
@@ -196,8 +218,6 @@ pub(crate) fn reg_inc_beta(x: f64, a: f64, b: f64) -> f64 {
|
||||
if x == 1.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let ln_bt = -ln_beta(a, b) + a * x.ln() + b * (1.0 - x).ln();
|
||||
let bt = ln_bt.exp();
|
||||
if x < (a + 1.0) / (a + b + 2.0) { bt * beta_cf(a, b, x) / a } else { 1.0 - bt * beta_cf(b, a, 1.0 - x) / b }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
use gridcraft_core::{CellError, Value};
|
||||
|
||||
use crate::special::{erf, gamma, invert, ln_beta, ln_gamma, norm_cdf, norm_inv, norm_pdf, reg_gamma_p, reg_gamma_q, reg_inc_beta};
|
||||
use crate::special::{
|
||||
erf, gamma, invert, ln_beta, ln_gamma, norm_cdf, norm_inv, norm_pdf, reg_gamma_p, reg_gamma_q, reg_gamma_q_front, reg_inc_beta,
|
||||
reg_inc_beta_front,
|
||||
};
|
||||
use crate::util::{A, R, S, arg, array_numbers, array_val, as_array, boolean, has, num, num_val, numbers, numbers_a, opt_num};
|
||||
use crate::{Arg, Ctx, FnSpec, VAR};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::descriptive::{arg_nums, arg_pairs, mean, var_s};
|
||||
use super::{
|
||||
Arg, C, CellError, R, Value, arg, as_array, boolean, erf, gamma, has, invert, ln_beta, ln_gamma, norm_cdf, norm_inv, norm_pdf, num, num_val,
|
||||
reg_gamma_p, reg_gamma_q, reg_inc_beta,
|
||||
reg_gamma_p, reg_gamma_q, reg_gamma_q_front, reg_inc_beta, reg_inc_beta_front,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -93,6 +93,97 @@ fn f_pdf(x: f64, d1: f64, d2: f64) -> f64 {
|
||||
|
||||
const MAX_BINOM_TRIALS: f64 = 2_147_483_646.0;
|
||||
|
||||
/// Stirling-series error `ln n! - ((n + 1/2) ln n - n + ln(2π)/2)` for integer `n >= 1`: exact
|
||||
/// table (computed with 60-digit Decimal arithmetic) up to 30, then the series, whose first
|
||||
/// omitted term is below 1e-19 there.
|
||||
fn stirlerr(n: f64) -> f64 {
|
||||
const TABLE: [f64; 31] = [
|
||||
0.0,
|
||||
0.08106146679532726,
|
||||
0.0413406959554093,
|
||||
0.02767792568499834,
|
||||
0.020790672103765093,
|
||||
0.016644691189821193,
|
||||
0.013876128823070748,
|
||||
0.01189670994589177,
|
||||
0.010411265261972096,
|
||||
0.009255462182712733,
|
||||
0.00833056343336287,
|
||||
0.007573675487951841,
|
||||
0.00694284010720953,
|
||||
0.006408994188004207,
|
||||
0.0059513701127588475,
|
||||
0.005554733551962801,
|
||||
0.0052076559196096404,
|
||||
0.004901395948434738,
|
||||
0.004629153749334028,
|
||||
0.004385560249232324,
|
||||
0.004166319691996922,
|
||||
0.00396795421864086,
|
||||
0.0037876180684444346,
|
||||
0.0036229602246830948,
|
||||
0.003472021382978767,
|
||||
0.003333155636728093,
|
||||
0.003204970228055038,
|
||||
0.0030862786826087773,
|
||||
0.002976063983550409,
|
||||
0.0028734493623524663,
|
||||
0.0027776749297526936,
|
||||
];
|
||||
if n <= 30.0 {
|
||||
return TABLE.get(n as usize).copied().unwrap_or(0.0);
|
||||
}
|
||||
let r = 1.0 / n;
|
||||
let r2 = r * r;
|
||||
r * (1.0 / 12.0 - r2 * (1.0 / 360.0 - r2 * (1.0 / 1260.0 - r2 * (1.0 / 1680.0 - r2 * (1.0 / 1188.0)))))
|
||||
}
|
||||
|
||||
/// `x ln(x/np) + np - x` without the cancellation of the direct formula when `x` is near `np`
|
||||
/// (Loader, 2000).
|
||||
fn bd0(x: f64, np: f64) -> f64 {
|
||||
if (x - np).abs() < 0.1 * (x + np) {
|
||||
let v = (x - np) / (x + np);
|
||||
let mut s = (x - np) * v;
|
||||
if s.abs() < f64::MIN_POSITIVE {
|
||||
return s;
|
||||
}
|
||||
let mut ej = 2.0 * x * v;
|
||||
let v2 = v * v;
|
||||
for j in 1..1000 {
|
||||
ej *= v2;
|
||||
let s1 = s + ej / f64::from(2 * j + 1);
|
||||
if s1 == s {
|
||||
return s1;
|
||||
}
|
||||
s = s1;
|
||||
}
|
||||
}
|
||||
x * (x / np).ln() + np - x
|
||||
}
|
||||
|
||||
/// `(1 - p)^e` with the rounding error of `1 - p` (it is not exact for `p < 1/2`) folded back in,
|
||||
/// which `e` would otherwise multiply.
|
||||
fn pow_one_minus(p: f64, e: f64) -> f64 {
|
||||
let hi = 1.0 - p;
|
||||
let lo = (1.0 - hi) - p;
|
||||
let r = hi.powf(e) * (e * (lo / hi).ln_1p()).exp();
|
||||
// An exponent so large that the two factors under- and overflow: the plain form is enough.
|
||||
if r.is_nan() { (e * (-p).ln_1p()).exp() } else { r }
|
||||
}
|
||||
|
||||
/// `C(n, k)` for a small `k` as a product of `k` ratios.
|
||||
fn choose_small(n: f64, k: f64) -> f64 {
|
||||
let mut c = 1.0;
|
||||
let mut j = 0.0;
|
||||
while j < k {
|
||||
c *= (n - j) / (j + 1.0);
|
||||
j += 1.0;
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Binomial mass at integer `k`, `0 <= k <= n`, by Loader's saddle-point formula: no log-gamma
|
||||
/// of large numbers, so the relative error stays near 1e-15 for any `n`.
|
||||
fn binom_pmf(k: f64, n: f64, p: f64) -> f64 {
|
||||
if k < 0.0 || k > n {
|
||||
return 0.0;
|
||||
@@ -103,7 +194,67 @@ fn binom_pmf(k: f64, n: f64, p: f64) -> f64 {
|
||||
if p == 1.0 {
|
||||
return if k == n { 1.0 } else { 0.0 };
|
||||
}
|
||||
(ln_comb(n, k) + k * p.ln() + (n - k) * (1.0 - p).ln()).exp()
|
||||
if k == 0.0 {
|
||||
return if n == 0.0 { 1.0 } else { pow_one_minus(p, n) };
|
||||
}
|
||||
if k == n {
|
||||
return p.powf(n);
|
||||
}
|
||||
// A few successes or a few failures: the product form has no large cancelling terms.
|
||||
// (Not for astronomically large n, or when either power underflows: the
|
||||
// saddle-point form below handles those through logs.)
|
||||
if n < 1e30 && p.powf(k) > 1e-280 {
|
||||
if k < 16.0 {
|
||||
let rest = pow_one_minus(p, n - k);
|
||||
if rest >= f64::MIN_POSITIVE {
|
||||
return choose_small(n, k) * p.powi(k as i32) * rest;
|
||||
}
|
||||
}
|
||||
if n - k < 16.0 {
|
||||
return choose_small(n, n - k) * p.powf(k) * pow_one_minus(p, n - k);
|
||||
}
|
||||
}
|
||||
let q = 1.0 - p;
|
||||
let lc = stirlerr(n) - stirlerr(k) - stirlerr(n - k) - bd0(k, n * p) - bd0(n - k, n * q);
|
||||
let lf = std::f64::consts::TAU.ln() + k.ln() + (-k / n).ln_1p();
|
||||
(lc - 0.5 * lf).exp()
|
||||
}
|
||||
|
||||
/// Sum of the binomial masses 0..=k for `n <= 10_000`: starts at the term nearest the mode
|
||||
/// (accurate by the formula above) and walks away from it by the ratio of neighbouring masses,
|
||||
/// one multiply per term, stopping once the terms no longer matter. Above the mode it sums the
|
||||
/// upper tail instead and subtracts it from 1.
|
||||
fn binom_cdf_sum(k: f64, n: f64, p: f64) -> f64 {
|
||||
let q = 1.0 - p;
|
||||
if p == 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
if p == 1.0 {
|
||||
return 0.0;
|
||||
}
|
||||
if k <= n * p {
|
||||
// mass(i - 1) = mass(i) * i / (n - i + 1) * q / p
|
||||
let mut t = binom_pmf(k, n, p);
|
||||
let mut s = t;
|
||||
let mut i = k;
|
||||
while i >= 1.0 && t > s * 1e-18 {
|
||||
t *= i / (n - i + 1.0) * (q / p);
|
||||
s += t;
|
||||
i -= 1.0;
|
||||
}
|
||||
s.min(1.0)
|
||||
} else {
|
||||
// mass(i + 1) = mass(i) * (n - i) / (i + 1) * p / q
|
||||
let mut t = binom_pmf(k + 1.0, n, p);
|
||||
let mut s = t;
|
||||
let mut i = k + 1.0;
|
||||
while i < n && t > s * 1e-18 {
|
||||
t *= (n - i) / (i + 1.0) * (p / q);
|
||||
s += t;
|
||||
i += 1.0;
|
||||
}
|
||||
(1.0 - s).max(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn binom_cdf(k: f64, n: f64, p: f64) -> f64 {
|
||||
@@ -114,13 +265,7 @@ fn binom_cdf(k: f64, n: f64, p: f64) -> f64 {
|
||||
return 1.0;
|
||||
}
|
||||
if n <= 10_000.0 {
|
||||
let mut s = 0.0;
|
||||
let mut i = 0.0;
|
||||
while i <= k {
|
||||
s += binom_pmf(i, n, p);
|
||||
i += 1.0;
|
||||
}
|
||||
return s.min(1.0);
|
||||
return binom_cdf_sum(k, n, p);
|
||||
}
|
||||
if p == 0.0 {
|
||||
return 1.0;
|
||||
@@ -128,14 +273,19 @@ fn binom_cdf(k: f64, n: f64, p: f64) -> f64 {
|
||||
if p == 1.0 {
|
||||
return 0.0;
|
||||
}
|
||||
reg_inc_beta(1.0 - p, n - k, k + 1.0)
|
||||
// I_(1-p)(n-k, k+1) has prefactor (n-k) p mass(k).
|
||||
reg_inc_beta_front(1.0 - p, n - k, k + 1.0, (n - k) * p * binom_pmf(k, n, p))
|
||||
}
|
||||
|
||||
/// Poisson mass at integer `x >= 0` by Loader's formula.
|
||||
fn poisson_pmf(x: f64, m: f64) -> f64 {
|
||||
if m == 0.0 {
|
||||
return if x == 0.0 { 1.0 } else { 0.0 };
|
||||
}
|
||||
(-m + x * m.ln() - ln_gamma(x + 1.0)).exp()
|
||||
if x == 0.0 {
|
||||
return (-m).exp();
|
||||
}
|
||||
(-stirlerr(x) - bd0(x, m)).exp() / (std::f64::consts::TAU * x).sqrt()
|
||||
}
|
||||
|
||||
fn poisson_cdf(x: f64, m: f64) -> f64 {
|
||||
@@ -143,15 +293,31 @@ fn poisson_cdf(x: f64, m: f64) -> f64 {
|
||||
return 1.0;
|
||||
}
|
||||
if x <= 1000.0 {
|
||||
let mut s = 0.0;
|
||||
let mut i = 0.0;
|
||||
while i <= x {
|
||||
s += poisson_pmf(i, m);
|
||||
if x <= m {
|
||||
// mass(i - 1) = mass(i) * i / m, downwards from x while the terms matter
|
||||
let mut t = poisson_pmf(x, m);
|
||||
let mut s = t;
|
||||
let mut i = x;
|
||||
while i >= 1.0 && t > s * 1e-18 {
|
||||
t *= i / m;
|
||||
s += t;
|
||||
i -= 1.0;
|
||||
}
|
||||
return s.min(1.0);
|
||||
}
|
||||
// Above the mean: 1 minus the upper tail, mass(i + 1) = mass(i) * m / (i + 1).
|
||||
let mut t = poisson_pmf(x + 1.0, m);
|
||||
let mut s = t;
|
||||
let mut i = x + 1.0;
|
||||
while t > s * 1e-18 {
|
||||
t *= m / (i + 1.0);
|
||||
s += t;
|
||||
i += 1.0;
|
||||
}
|
||||
return s.min(1.0);
|
||||
return (1.0 - s).max(0.0);
|
||||
}
|
||||
reg_gamma_q(x + 1.0, m)
|
||||
// Q(x+1, m) has prefactor m mass(x).
|
||||
reg_gamma_q_front(x + 1.0, m, m * poisson_pmf(x, m))
|
||||
}
|
||||
|
||||
pub(super) fn norm_dist(a: &[Arg], _: C) -> R<Value> {
|
||||
|
||||
@@ -740,6 +740,248 @@ fn aggregate_helper() {
|
||||
is_err(aggregate_values_k(14, &vals, &nv(10.0)), CellError::Num);
|
||||
}
|
||||
|
||||
// B1: BINOM.DIST and POISSON.DIST went through ln_gamma differences (relative error 1.1e-13 at
|
||||
// n = 100, 1.5e-11 at n = 10,000). Reference values: 70-digit Decimal arithmetic on the exact
|
||||
// double `p` (mass by the neighbour-ratio recurrence from q^n, cdf as the running sum), rounded
|
||||
// once to f64. Columns: k, n, p, mass, cdf.
|
||||
#[rustfmt::skip]
|
||||
const BINOM_REF: &[(f64, f64, f64, f64, f64)] = &[
|
||||
(0.0, 10.0, 0.5, 0.0009765625, 0.0009765625),
|
||||
(1.0, 10.0, 0.5, 0.009765625, 0.0107421875),
|
||||
(3.0, 10.0, 0.5, 0.1171875, 0.171875),
|
||||
(5.0, 10.0, 0.5, 0.24609375, 0.623046875),
|
||||
(7.0, 10.0, 0.5, 0.1171875, 0.9453125),
|
||||
(9.0, 10.0, 0.5, 0.009765625, 0.9990234375),
|
||||
(0.0, 10.0, 0.3, 0.028247524900000005, 0.028247524900000005),
|
||||
(1.0, 10.0, 0.3, 0.12106082100000001, 0.14930834590000003),
|
||||
(2.0, 10.0, 0.3, 0.23347444050000002, 0.3827827864),
|
||||
(3.0, 10.0, 0.3, 0.266827932, 0.6496107184000001),
|
||||
(4.0, 10.0, 0.3, 0.200120949, 0.8497316674000001),
|
||||
(5.0, 10.0, 0.3, 0.10291934519999998, 0.9526510126),
|
||||
(7.0, 10.0, 0.3, 0.009001691999999999, 0.9984096136),
|
||||
(9.0, 10.0, 0.3, 0.00013778099999999996, 0.9999940951),
|
||||
(0.0, 10.0, 0.999, 1.0000000000000088e-30, 1.0000000000000088e-30),
|
||||
(1.0, 10.0, 0.999, 9.99000000000008e-27, 9.99100000000008e-27),
|
||||
(5.0, 10.0, 0.999, 2.507425174812609e-13, 2.50951798425701e-13),
|
||||
(9.0, 10.0, 0.999, 0.00991035916125875, 0.009955119790251798),
|
||||
(0.0, 100.0, 0.5, 7.888609052210118e-31, 7.888609052210118e-31),
|
||||
(1.0, 100.0, 0.5, 7.888609052210118e-29, 7.967495142732219e-29),
|
||||
(35.0, 100.0, 0.5, 0.0008638556657416528, 0.0017588208614850792),
|
||||
(45.0, 100.0, 0.5, 0.048474296626430755, 0.18410080866334813),
|
||||
(50.0, 100.0, 0.5, 0.07958923738717877, 0.5397946186935894),
|
||||
(55.0, 100.0, 0.5, 0.048474296626430755, 0.8643734879630827),
|
||||
(65.0, 100.0, 0.5, 0.0008638556657416528, 0.9991050348042566),
|
||||
(99.0, 100.0, 0.5, 7.888609052210118e-29, 1.0),
|
||||
(0.0, 100.0, 0.01, 0.3660323412732295, 0.3660323412732295),
|
||||
(1.0, 100.0, 0.01, 0.3697296376497268, 0.7357619789229563),
|
||||
(2.0, 100.0, 0.01, 0.1848648188248634, 0.9206267977478196),
|
||||
(4.0, 100.0, 0.01, 0.014941714856895142, 0.9965676784122455),
|
||||
(50.0, 100.0, 0.01, 6.103987557173001e-72, 1.0),
|
||||
(99.0, 100.0, 0.01, 9.900000000000021e-197, 1.0),
|
||||
(0.0, 100.0, 0.7, 5.15377520732019e-53, 5.15377520732019e-53),
|
||||
(1.0, 100.0, 0.7, 1.2025475483747105e-50, 1.207701323582031e-50),
|
||||
(50.0, 100.0, 0.7, 1.302622713144541e-05, 2.2060913327166086e-05),
|
||||
(56.0, 100.0, 0.7, 0.001028871216633018, 0.0021146172813184595),
|
||||
(65.0, 100.0, 0.7, 0.04677968235272982, 0.16285828837178737),
|
||||
(70.0, 100.0, 0.7, 0.08678386475342809, 0.5376602639846381),
|
||||
(75.0, 100.0, 0.7, 0.04955992276217012, 0.8864298182958186),
|
||||
(84.0, 100.0, 0.7, 0.0005638655029474704, 0.9995950004580563),
|
||||
(99.0, 100.0, 0.7, 1.3862042184106021e-14, 0.9999999999999997),
|
||||
(0.0, 1000.0, 0.5, 9.332636185032189e-302, 9.332636185032189e-302),
|
||||
(1.0, 1000.0, 0.5, 9.332636185032189e-299, 9.341968821217221e-299),
|
||||
(453.0, 1000.0, 0.5, 0.00030353498700958767, 0.00162629990658169),
|
||||
(484.0, 1000.0, 0.5, 0.015123665829158384, 0.1634683412157977),
|
||||
(500.0, 1000.0, 0.5, 0.0252250181783608, 0.5126125090891804),
|
||||
(516.0, 1000.0, 0.5, 0.015123665829158384, 0.8516553246133607),
|
||||
(547.0, 1000.0, 0.5, 0.00030353498700958767, 0.9986772350804279),
|
||||
(999.0, 1000.0, 0.5, 9.332636185032189e-299, 1.0),
|
||||
(0.0, 1000.0, 0.3, 1.2532566399657381e-155, 1.2532566399657381e-155),
|
||||
(1.0, 1000.0, 0.3, 5.371099885567449e-153, 5.383632451967106e-153),
|
||||
(257.0, 1000.0, 0.3, 0.00030959977077928017, 0.0014835731103312242),
|
||||
(286.0, 1000.0, 0.3, 0.0174263984921581, 0.1759288949498673),
|
||||
(300.0, 1000.0, 0.3, 0.027521003821268385, 0.5155935198141203),
|
||||
(314.0, 1000.0, 0.3, 0.017109129819617666, 0.8414952343056957),
|
||||
(343.0, 1000.0, 0.3, 0.00036310112605077133, 0.9985016810663147),
|
||||
(500.0, 1000.0, 0.3, 3.4791609391626546e-40, 1.0),
|
||||
(999.0, 1000.0, 0.3, 0.0, 1.0),
|
||||
(0.0, 1000.0, 0.001, 0.36769542477096406, 0.36769542477096406),
|
||||
(1.0, 1000.0, 0.001, 0.36806348825922325, 0.7357589130301873),
|
||||
(2.0, 1000.0, 0.001, 0.18403174412961162, 0.9197906571597989),
|
||||
(4.0, 1000.0, 0.001, 0.01528995542083862, 0.9963631219704782),
|
||||
(500.0, 1000.0, 0.001, 0.0, 1.0),
|
||||
(999.0, 1000.0, 0.001, 0.0, 1.0),
|
||||
(0.0, 10000.0, 0.5, 0.0, 0.0),
|
||||
(1.0, 10000.0, 0.5, 0.0, 0.0),
|
||||
(4850.0, 10000.0, 0.5, 8.861480551702583e-05, 0.0013939837588563594),
|
||||
(4950.0, 10000.0, 0.5, 0.004839495142316416, 0.161087099897656),
|
||||
(5000.0, 10000.0, 0.5, 0.007978646139382154, 0.5039893230696911),
|
||||
(5050.0, 10000.0, 0.5, 0.004839495142316416, 0.8437523952446604),
|
||||
(5150.0, 10000.0, 0.5, 8.861480551702583e-05, 0.9986946310466607),
|
||||
(9999.0, 10000.0, 0.5, 0.0, 1.0),
|
||||
(0.0, 10000.0, 0.3, 0.0, 0.0),
|
||||
(1.0, 10000.0, 0.3, 0.0, 0.0),
|
||||
(2863.0, 10000.0, 0.3, 9.71726742023389e-05, 0.0013927715709303387),
|
||||
(2954.0, 10000.0, 0.3, 0.0052756073908582085, 0.16038574844431697),
|
||||
(3000.0, 10000.0, 0.3, 0.008705361365067056, 0.504932983819298),
|
||||
(3046.0, 10000.0, 0.3, 0.005244996814931252, 0.8448693061622872),
|
||||
(3137.0, 10000.0, 0.3, 0.00010232641238302388, 0.9986013755801396),
|
||||
(5000.0, 10000.0, 0.3, 0.0, 1.0),
|
||||
(9999.0, 10000.0, 0.3, 0.0, 1.0),
|
||||
(0.0, 10000.0, 0.0001, 0.3678610464329299, 0.3678610464329299),
|
||||
(1.0, 10000.0, 0.0001, 0.3678978362165516, 0.7357588826494815),
|
||||
(2.0, 10000.0, 0.0001, 0.1839489181082758, 0.9197078007577573),
|
||||
(4.0, 10000.0, 0.0001, 0.015324477632794846, 0.996342452182816),
|
||||
(5000.0, 10000.0, 0.0001, 0.0, 1.0),
|
||||
(9999.0, 10000.0, 0.0001, 0.0, 1.0),
|
||||
(0.0, 10000.0, 0.999, 0.0, 0.0),
|
||||
(1.0, 10000.0, 0.999, 0.0, 0.0),
|
||||
(5000.0, 10000.0, 0.999, 0.0, 0.0),
|
||||
(9981.0, 10000.0, 0.999, 0.0037205945263045544, 0.007158161342006954),
|
||||
(9987.0, 10000.0, 0.999, 0.07292252318084555, 0.20834867055478343),
|
||||
(9990.0, 10000.0, 0.999, 0.12517263665023892, 0.5421328760209826),
|
||||
(9993.0, 10000.0, 0.999, 0.09007019224850273, 0.8699847268232276),
|
||||
(9999.0, 10000.0, 0.999, 0.00045218564541589875, 0.9999548266540229),
|
||||
(0.0, 100000.0, 0.5, 0.0, 0.0),
|
||||
(1.0, 100000.0, 0.5, 0.0, 0.0),
|
||||
(49526.0, 100000.0, 0.5, 2.821098952769392e-05, 0.00137358872189177),
|
||||
(49842.0, 100000.0, 0.5, 0.0015314620321580088, 0.15959653953914368),
|
||||
(50000.0, 100000.0, 0.5, 0.00252312621419674, 0.5012615631070984),
|
||||
(50158.0, 100000.0, 0.5, 0.0015314620321580088, 0.8419349224930143),
|
||||
(50474.0, 100000.0, 0.5, 2.821098952769392e-05, 0.9986546222676359),
|
||||
(99999.0, 100000.0, 0.5, 0.0, 1.0),
|
||||
(0.0, 100000.0, 0.3, 0.0, 0.0),
|
||||
(1.0, 100000.0, 0.3, 0.0, 0.0),
|
||||
(29565.0, 100000.0, 0.3, 3.016598258904529e-05, 0.0013408650955754554),
|
||||
(29855.0, 100000.0, 0.3, 0.0016703029966719045, 0.15934769308464344),
|
||||
(30000.0, 100000.0, 0.3, 0.002752954648397428, 0.5015600059172672),
|
||||
(30145.0, 100000.0, 0.3, 0.0016672321665529399, 0.8423208126454166),
|
||||
(30435.0, 100000.0, 0.3, 3.067097885268188e-05, 0.9986570625478413),
|
||||
(50000.0, 100000.0, 0.3, 0.0, 1.0),
|
||||
(99999.0, 100000.0, 0.3, 0.0, 1.0),
|
||||
(0.0, 100000.0, 0.99, 0.0, 0.0),
|
||||
(1.0, 100000.0, 0.99, 0.0, 0.0),
|
||||
(50000.0, 100000.0, 0.99, 0.0, 0.0),
|
||||
(98906.0, 100000.0, 0.99, 0.00015962501729101418, 0.001680685850467731),
|
||||
(98969.0, 100000.0, 0.99, 0.007723834924565287, 0.1660975153530963),
|
||||
(99000.0, 100000.0, 0.99, 0.012678161323544589, 0.5042686879721229),
|
||||
(99031.0, 100000.0, 0.99, 0.007885853372194001, 0.8416321140066325),
|
||||
(99094.0, 100000.0, 0.99, 0.00013276363518518057, 0.9988439452168577),
|
||||
(99999.0, 100000.0, 0.99, 0.0, 1.0),
|
||||
// Small k where (1 - p)^(n - k) underflows although the mass is a normal number.
|
||||
(10.0, 1100.0, 0.5, 5.050689010687844e-308, 5.097367491037505e-308),
|
||||
(14.0, 1100.0, 0.5, 2.9513324041838846e-300, 2.989803284040754e-300),
|
||||
(15.0, 1100.0, 0.5, 2.1367646606291324e-298, 2.16666269346954e-298),
|
||||
(16.0, 1100.0, 0.5, 1.4489935354891303e-296, 1.4706601624238258e-296),
|
||||
(20.0, 1100.0, 0.5, 1.711087104904223e-289, 1.7433098849775326e-289),
|
||||
(25.0, 1100.0, 0.5, 3.906996229178926e-281, 3.999839082741416e-281),
|
||||
];
|
||||
|
||||
// Columns: x, mean, mass, cdf. The last block has x > 1000, which takes the incomplete-gamma branch.
|
||||
#[rustfmt::skip]
|
||||
const POISSON_REF: &[(f64, f64, f64, f64)] = &[
|
||||
(0.0, 0.5, 0.6065306597126334, 0.6065306597126334),
|
||||
(1.0, 0.5, 0.3032653298563167, 0.9097959895689501),
|
||||
(3.0, 0.5, 0.012636055410679864, 0.9982483774437092),
|
||||
(0.0, 5.0, 0.006737946999085467, 0.006737946999085467),
|
||||
(2.0, 5.0, 0.08422433748856833, 0.12465201948308115),
|
||||
(5.0, 5.0, 0.1754673697678507, 0.6159606548330632),
|
||||
(10.0, 5.0, 0.018132788707821874, 0.986304731401617),
|
||||
(25.0, 5.0, 1.294589320478486e-10, 0.9999999999695003),
|
||||
(70.0, 100.0, 0.00031056089812001654, 0.0009714440282773882),
|
||||
(100.0, 100.0, 0.039860996809147134, 0.5265621985299984),
|
||||
(130.0, 100.0, 0.000575252683815463, 0.9982931596294985),
|
||||
(900.0, 1000.0, 7.516954352125952e-05, 0.0006977673277963068),
|
||||
(1000.0, 1000.0, 0.012614611348721499, 0.508409367168506),
|
||||
(1100.0, 1000.0, 9.498944242299507e-05, 0.9991323590365564),
|
||||
(1050.0, 1000.0, 0.003599490404803648, 0.9439711616363267),
|
||||
(1200.0, 1000.0, 7.992642848843571e-11, 0.9999999996115061),
|
||||
(1500.0, 2000.0, 1.878037542328086e-32, 7.468034373687017e-32),
|
||||
(2000.0, 2000.0, 0.00892024889598624, 0.5059467004517837),
|
||||
(2100.0, 2000.0, 0.0007442234118916615, 0.9872092799080792),
|
||||
(2300.0, 2000.0, 4.011826753210622e-12, 0.999999999973895),
|
||||
(4800.0, 5000.0, 9.987733294132511e-05, 0.002269403878343364),
|
||||
(5000.0, 5000.0, 0.005641801804664022, 0.5037611677708467),
|
||||
];
|
||||
|
||||
fn rel_err(got: Value, want: f64) -> f64 {
|
||||
// An error or a non-finite result is the worst possible miss, not a skipped one.
|
||||
let Some(g) = got.as_f64().filter(|g| g.is_finite()) else { return f64::INFINITY };
|
||||
if want == 0.0 { g.abs() } else { ((g - want) / want).abs() }
|
||||
}
|
||||
|
||||
/// Error bound the grid below must meet: 1e-13, plus 2e-16 per unit of |ln value| for far-tail
|
||||
/// results (an exp of a sum of large logs cannot do better in double precision). Measured: at
|
||||
/// most 9e-14 on the grid (the n = 100,000 cumulative cases), 2e-14 for n <= 10,000 away from the
|
||||
/// far tails, 2e-15 on the Excel-sized central cases. The log-gamma code was off by 1.5e-11.
|
||||
fn allowed(want: f64) -> f64 {
|
||||
// A reference that underflows to 0 must come back as (almost) 0, not as any finite value.
|
||||
if want == 0.0 {
|
||||
return 1e-300;
|
||||
}
|
||||
1e-13 + 2e-16 * want.abs().ln().abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binom_and_poisson_match_exact_decimal_references() {
|
||||
// Worst error relative to the allowance (above 1 is a failure) and plain worst error.
|
||||
let (mut worst, mut worst_plain) = (0.0f64, 0.0f64);
|
||||
let mut check = |what: &str, got: Value, want: f64| {
|
||||
let e = rel_err(got, want);
|
||||
let ratio = e / allowed(want);
|
||||
if ratio > 1.0 {
|
||||
println!("{what}: error {e:e}, allowed {:e}", allowed(want));
|
||||
}
|
||||
worst = worst.max(ratio);
|
||||
if want > 1e-3 {
|
||||
worst_plain = worst_plain.max(e);
|
||||
}
|
||||
};
|
||||
for &(k, nn, p, pmf, cdf) in BINOM_REF {
|
||||
check(&format!("BINOM.DIST({k},{nn},{p},FALSE)"), ev("BINOM.DIST", vec![n(k), n(nn), n(p), b(false)]), pmf);
|
||||
check(&format!("BINOM.DIST({k},{nn},{p},TRUE)"), ev("BINOM.DIST", vec![n(k), n(nn), n(p), b(true)]), cdf);
|
||||
}
|
||||
for &(x, m, pmf, cdf) in POISSON_REF {
|
||||
check(&format!("POISSON.DIST({x},{m},FALSE)"), ev("POISSON.DIST", vec![n(x), n(m), b(false)]), pmf);
|
||||
check(&format!("POISSON.DIST({x},{m},TRUE)"), ev("POISSON.DIST", vec![n(x), n(m), b(true)]), cdf);
|
||||
}
|
||||
println!("worst error / allowance {worst:.3}; worst relative error where the result is above 1e-3: {worst_plain:e}");
|
||||
assert!(worst <= 1.0);
|
||||
assert!(worst_plain < 1e-13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binom_and_poisson_edge_cases() {
|
||||
for cum in [false, true] {
|
||||
let pm = |k: f64, nn: f64, p: f64| ev("BINOM.DIST", vec![n(k), n(nn), n(p), b(cum)]);
|
||||
// p = 0 and p = 1
|
||||
close(pm(0.0, 50.0, 0.0), 1.0);
|
||||
close(pm(3.0, 50.0, 1.0), 0.0);
|
||||
close(pm(50.0, 50.0, 1.0), 1.0);
|
||||
// n = 0
|
||||
close(pm(0.0, 0.0, 0.3), 1.0);
|
||||
// k = n is certain when cumulative and p^n otherwise
|
||||
close(pm(20.0, 20.0, 0.5), if cum { 1.0 } else { 0.5f64.powi(20) });
|
||||
close_tol(pm(10_000.0, 10_000.0, 0.999), if cum { 1.0 } else { 0.999f64.powi(10_000) }, 1e-12);
|
||||
// k = 0 is q^n
|
||||
close_tol(pm(0.0, 1000.0, 0.001), 0.999f64.powi(1000), 1e-12);
|
||||
close_tol(pm(0.0, 100_000.0, 0.00001), (1.0f64 - 1e-5).powi(100_000), 1e-9);
|
||||
}
|
||||
close(ev("POISSON.DIST", vec![n(0.0), n(0.0), b(false)]), 1.0);
|
||||
close(ev("POISSON.DIST", vec![n(4.0), n(0.0), b(false)]), 0.0);
|
||||
close(ev("POISSON.DIST", vec![n(4.0), n(0.0), b(true)]), 1.0);
|
||||
close_tol(ev("POISSON.DIST", vec![n(0.0), n(3.0), b(true)]), (-3.0f64).exp(), 1e-15);
|
||||
// Fractions truncate as before.
|
||||
close(ev("BINOM.DIST", vec![n(6.9), n(10.9), n(0.5), b(false)]), 0.205078125);
|
||||
// The 10,000 trial switch of the method no longer shows: neighbours agree to 1e-14.
|
||||
let lo = ev("BINOM.DIST", vec![n(5000.0), n(10_000.0), n(0.5), b(true)]).as_f64().unwrap();
|
||||
let hi = ev("BINOM.DIST", vec![n(5000.0), n(10_001.0), n(0.5), b(true)]).as_f64().unwrap();
|
||||
let pmf = ev("BINOM.DIST", vec![n(5000.0), n(10_000.0), n(0.5), b(false)]).as_f64().unwrap();
|
||||
// cdf(5000; 10,001) = cdf(5000; 10,000) - p * mass(5000; 10,000) by the recurrence on n
|
||||
assert!(((hi - (lo - 0.5 * pmf)) / hi).abs() < 1e-13, "{lo} {hi}");
|
||||
// Inverse stays consistent: the smallest k with cdf >= alpha.
|
||||
close(ev("BINOM.INV", vec![n(10_000.0), n(0.5), n(0.5)]), 5000.0);
|
||||
}
|
||||
|
||||
/// Security: FREQUENCY finds each value's bin by binary search, and MODE counts distinct numbers
|
||||
/// through an index, so neither is quadratic in its inputs. Results are as before: unsorted and
|
||||
/// duplicate bins keep their positions (a value goes to the first of equal bins), and tied modes
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::style::StyleId;
|
||||
|
||||
/// A parsed formula. `text` is the canonical formula text without the leading `=`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(from = "SavedFormula")]
|
||||
pub struct Formula {
|
||||
pub text: String,
|
||||
#[serde(skip)]
|
||||
@@ -39,15 +40,15 @@ impl Formula {
|
||||
pub fn from_expr(e: Expr) -> Formula {
|
||||
Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None, dynamic: false }
|
||||
}
|
||||
/// The parsed expression, re-parsing after deserialization.
|
||||
/// The parsed expression, re-parsing when no tree is cached.
|
||||
pub fn expr(&self) -> Option<Expr> {
|
||||
self.expr.as_deref().cloned().or_else(|| gridcraft_formula::parse(&self.text).ok())
|
||||
}
|
||||
/// The parsed expression without copying it (re-parses after deserialization).
|
||||
/// The parsed expression without copying it (re-parses when no tree is cached).
|
||||
pub fn expr_arc(&self) -> Option<Arc<Expr>> {
|
||||
self.expr.clone().or_else(|| gridcraft_formula::parse(&self.text).ok().map(Arc::new))
|
||||
}
|
||||
/// Runs `f` on the parsed expression, borrowing the cached tree or, after deserialization,
|
||||
/// Runs `f` on the parsed expression, borrowing the cached tree or, when none is cached,
|
||||
/// a freshly parsed one that is dropped afterwards. Unlike [`expr`](Self::expr) it never copies
|
||||
/// the tree, and unlike [`expr_arc`](Self::expr_arc) it needs no reference count or `Arc`
|
||||
/// allocation. `None` when the text does not parse.
|
||||
@@ -59,6 +60,25 @@ impl Formula {
|
||||
}
|
||||
}
|
||||
|
||||
/// A formula as a saved workbook holds it: the tree is not saved. Reading one parses its text
|
||||
/// once and caches the tree, so a workbook opened from JSON does not parse every formula again on
|
||||
/// each recalculation. The text is kept as saved, and text that doesn't parse caches nothing, as
|
||||
/// [`Formula::new`] does.
|
||||
#[derive(Deserialize)]
|
||||
struct SavedFormula {
|
||||
text: String,
|
||||
array: Option<RangeRef>,
|
||||
#[serde(default)]
|
||||
dynamic: bool,
|
||||
}
|
||||
|
||||
impl From<SavedFormula> for Formula {
|
||||
fn from(f: SavedFormula) -> Formula {
|
||||
let expr = gridcraft_formula::parse(&f.text).ok().map(Arc::new);
|
||||
Formula { text: f.text, expr, array: f.array, dynamic: f.dynamic }
|
||||
}
|
||||
}
|
||||
|
||||
/// A static picture stored as cell content, independent of floating drawing anchors.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CellPicture {
|
||||
@@ -131,3 +151,59 @@ impl Cell {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gridcraft_core::CellRef;
|
||||
|
||||
#[test]
|
||||
fn formulas_read_from_json_keep_their_text_and_cache_the_tree() -> Result<(), serde_json::Error> {
|
||||
let mut array = Formula::new("=A1:B2*2");
|
||||
array.array = Some(RangeRef::new(CellRef::new(0, 2), CellRef::new(1, 3)));
|
||||
array.dynamic = true;
|
||||
for f in [Formula::new("=SUM(A1:A3)+'My Sheet'!B2"), Formula::new("=LET(x,A1*2,x+1)"), array] {
|
||||
let back: Formula = serde_json::from_str(&serde_json::to_string(&f)?)?;
|
||||
assert!(back.expr.is_some(), "{}: the tree is cached after reading", f.text);
|
||||
assert_eq!(back, f);
|
||||
}
|
||||
// Saved text is kept as written, even when the canonical print differs, and its tree is
|
||||
// the one the text parses to.
|
||||
let back: Formula = serde_json::from_str(r#"{"text": "sum( a1 , 2 )", "array": null}"#)?;
|
||||
assert_eq!(back.text, "sum( a1 , 2 )");
|
||||
assert!(!back.dynamic);
|
||||
assert_eq!(back.expr.as_deref(), gridcraft_formula::parse("sum( a1 , 2 )").ok().as_ref());
|
||||
assert!(back.expr.is_some());
|
||||
// Text that doesn't parse caches nothing, as `Formula::new` does.
|
||||
let bad = Formula::new("=SUM(");
|
||||
let back: Formula = serde_json::from_str(&serde_json::to_string(&bad)?)?;
|
||||
assert_eq!(back, bad);
|
||||
assert!(back.expr.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_workbook_read_from_json_has_every_formula_tree() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut wb = crate::Workbook::new();
|
||||
let mut array = Formula::new("=A1:A3*2");
|
||||
array.array = Some(RangeRef::new(CellRef::new(0, 3), CellRef::new(2, 3)));
|
||||
let sheet = wb.sheet_mut(0).ok_or("no sheet")?;
|
||||
sheet.cells.set(CellRef::new(0, 0), Cell::value(Value::Number(1.0)));
|
||||
sheet.cells.set(CellRef::new(0, 1), Cell::formula(Formula::new("=SUM(A1:A3)")));
|
||||
sheet.cells.set(CellRef::new(1, 1), Cell::formula(Formula::new("=IF(A1>0,\"yes\",\"no\")")));
|
||||
sheet.cells.set(CellRef::new(2, 1), Cell::formula(Formula::new("=SUM(")));
|
||||
sheet.cells.set(CellRef::new(0, 3), Cell::formula(array));
|
||||
let json = serde_json::to_string(&wb)?;
|
||||
let back: crate::Workbook = serde_json::from_str(&json)?;
|
||||
let (before, after) = (wb.sheet(0).ok_or("no sheet")?, back.sheet(0).ok_or("no sheet")?);
|
||||
let mut formulas = 0;
|
||||
for (c, cell) in before.cells.iter() {
|
||||
let (Some(f), Some(read)) = (&cell.formula, after.cell(c).and_then(|cell| cell.formula.as_ref())) else { continue };
|
||||
assert_eq!(read, f, "{c:?}: text, tree and flags as saved");
|
||||
assert_eq!(read.expr.is_some(), gridcraft_formula::parse(&f.text).is_ok(), "{c:?}: every formula that parses has its tree");
|
||||
formulas += 1;
|
||||
}
|
||||
assert_eq!(formulas, 4);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -491,12 +491,14 @@ impl Sheet {
|
||||
/// sheet AutoFilter while it has criteria, or a hidden data row of a filtered table.
|
||||
/// Other hidden rows were hidden by hand.
|
||||
pub fn is_row_filtered(&self, row: u32) -> bool {
|
||||
if !self.is_row_hidden(row) {
|
||||
return false;
|
||||
}
|
||||
let in_sheet_filter =
|
||||
self.autofilter.as_ref().is_some_and(|af| !af.criteria.is_empty() && row > af.range.start.row && row <= af.range.end.row);
|
||||
in_sheet_filter || self.tables.iter().any(|t| !t.filter.is_empty() && t.data_range().is_some_and(|r| row >= r.start.row && row <= r.end.row))
|
||||
self.is_row_hidden(row) && self.filter_row_spans().iter().any(|&(first, last)| row >= first && row <= last)
|
||||
}
|
||||
/// The row spans (first, last) an applied filter can hide: the data rows of the sheet
|
||||
/// AutoFilter while it has criteria, and of each filtered table.
|
||||
pub fn filter_row_spans(&self) -> Vec<(u32, u32)> {
|
||||
let sheet = self.autofilter.as_ref().filter(|af| !af.criteria.is_empty()).map(|af| (af.range.start.row.saturating_add(1), af.range.end.row));
|
||||
let tables = self.tables.iter().filter(|t| !t.filter.is_empty()).filter_map(|t| t.data_range()).map(|r| (r.start.row, r.end.row));
|
||||
sheet.into_iter().chain(tables).collect()
|
||||
}
|
||||
pub fn is_col_hidden(&self, col: u32) -> bool {
|
||||
self.cols.get(&col).is_some_and(|r| r.hidden)
|
||||
|
||||
+367
-35
@@ -4,6 +4,8 @@
|
||||
//! copies only the band pointers; editing a cell copies just its band.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::btree_map::Entry;
|
||||
use std::ops::RangeInclusive;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
@@ -266,64 +268,187 @@ impl CellStore {
|
||||
}
|
||||
bytes
|
||||
}
|
||||
/// Removes every cell in `r` and returns them.
|
||||
/// Removes every cell in `r` and returns them, row-major.
|
||||
pub fn take_range(&mut self, r: RangeRef) -> Vec<(CellRef, Cell)> {
|
||||
let keys: Vec<CellRef> = self.iter_range(r).map(|(c, _)| c).collect();
|
||||
keys.into_iter().filter_map(|c| self.remove(c).map(|cell| (c, cell))).collect()
|
||||
if r.end.col < gridcraft_core::MAX_COLS - 1 {
|
||||
// A block with columns to its right: cutting it out of each row map measured slower
|
||||
// than removing its cells one by one (#3575).
|
||||
let keys: Vec<CellRef> = self.iter_range(r).map(|(c, _)| c).collect();
|
||||
return keys.into_iter().filter_map(|c| self.remove(c).map(|cell| (c, cell))).collect();
|
||||
}
|
||||
let rows = self.take_rows(r.start.row..=r.end.row, r.start.col..=r.end.col);
|
||||
rows.into_iter().flat_map(|(row, cols)| cols.into_iter().map(move |(col, cell)| (CellRef::new(row, col), cell))).collect()
|
||||
}
|
||||
/// Rows strictly greater than or equal to `from` moved by `delta` (positive = down). Rows that
|
||||
/// would land before `from`... callers delete the vacated block first.
|
||||
pub fn shift_rows(&mut self, from: u32, delta: i64) {
|
||||
let moved: Vec<(CellRef, Cell)> = {
|
||||
let keys: Vec<CellRef> = self.iter().filter(|(c, _)| c.row >= from).map(|(c, _)| c).collect();
|
||||
keys.into_iter().filter_map(|c| self.remove(c).map(|cell| (c, cell))).collect()
|
||||
};
|
||||
for (c, cell) in moved {
|
||||
let nr = c.row as i64 + delta;
|
||||
if (0..gridcraft_core::MAX_ROWS as i64).contains(&nr) {
|
||||
self.set(CellRef::new(nr as u32, c.col), cell);
|
||||
}
|
||||
}
|
||||
let moved = self.take_rows(from..=u32::MAX, 0..=u32::MAX);
|
||||
self.put_rows(moved.into_iter().filter_map(|(row, cols)| Some((shifted(row, delta, gridcraft_core::MAX_ROWS)?, cols))));
|
||||
}
|
||||
pub fn shift_cols(&mut self, from: u32, delta: i64) {
|
||||
let keys: Vec<CellRef> = self.iter().filter(|(c, _)| c.col >= from).map(|(c, _)| c).collect();
|
||||
let moved: Vec<(CellRef, Cell)> = keys.into_iter().filter_map(|c| self.remove(c).map(|cell| (c, cell))).collect();
|
||||
for (c, cell) in moved {
|
||||
let nc = c.col as i64 + delta;
|
||||
if (0..gridcraft_core::MAX_COLS as i64).contains(&nc) {
|
||||
self.set(CellRef::new(c.row, nc as u32), cell);
|
||||
}
|
||||
}
|
||||
let moved = self.take_rows(0..=u32::MAX, from..=u32::MAX);
|
||||
self.put_rows(moved.into_iter().map(|(row, cols)| (row, shift_keys(cols, delta))));
|
||||
}
|
||||
/// Shifts cells within rows `r0..=r1` and columns `>= from` horizontally (Insert/Delete Cells
|
||||
/// with shift right/left).
|
||||
pub fn shift_cols_in_rows(&mut self, r0: u32, r1: u32, from: u32, delta: i64) {
|
||||
let range = RangeRef::new(CellRef::new(r0, from), CellRef::new(r1, gridcraft_core::MAX_COLS - 1));
|
||||
let moved = self.take_range(range);
|
||||
for (c, cell) in moved {
|
||||
let nc = c.col as i64 + delta;
|
||||
if (0..gridcraft_core::MAX_COLS as i64).contains(&nc) {
|
||||
self.set(CellRef::new(c.row, nc as u32), cell);
|
||||
}
|
||||
}
|
||||
let r = RangeRef::new(CellRef::new(r0, from), CellRef::new(r1, gridcraft_core::MAX_COLS - 1));
|
||||
let moved = self.take_rows(r.start.row..=r.end.row, r.start.col..=r.end.col);
|
||||
self.put_rows(moved.into_iter().map(|(row, cols)| (row, shift_keys(cols, delta))));
|
||||
}
|
||||
/// Shifts cells within columns `c0..=c1` and rows `>= from` vertically.
|
||||
pub fn shift_rows_in_cols(&mut self, c0: u32, c1: u32, from: u32, delta: i64) {
|
||||
// Cell by cell: the moved cells sit between columns that stay, so moving row maps
|
||||
// measured no faster here (#3575).
|
||||
let range = RangeRef::new(CellRef::new(from, c0), CellRef::new(gridcraft_core::MAX_ROWS - 1, c1));
|
||||
let moved = self.take_range(range);
|
||||
for (c, cell) in moved {
|
||||
let nr = c.row as i64 + delta;
|
||||
if (0..gridcraft_core::MAX_ROWS as i64).contains(&nr) {
|
||||
self.set(CellRef::new(nr as u32, c.col), cell);
|
||||
for (c, cell) in self.take_range(range) {
|
||||
if let Some(nr) = shifted(c.row, delta, gridcraft_core::MAX_ROWS) {
|
||||
self.set(CellRef::new(nr, c.col), cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Removes the cells in `rows` x `cols` and returns them grouped by row, in row order. Moves
|
||||
/// whole column maps (or the split-off part of one) instead of removing cell by cell, and only
|
||||
/// copies bands that hold a cell in the block, so snapshots keep sharing the others.
|
||||
fn take_rows(&mut self, rows: RangeInclusive<u32>, cols: RangeInclusive<u32>) -> Vec<(u32, BTreeMap<u32, Cell>)> {
|
||||
let (r0, r1, c0, c1) = (*rows.start(), *rows.end(), *cols.start(), *cols.end());
|
||||
let mut out = Vec::new();
|
||||
if r0 > r1 || c0 > c1 {
|
||||
return out;
|
||||
}
|
||||
let whole_rows = c0 == 0 && c1 == u32::MAX;
|
||||
let keys: Vec<u32> = self
|
||||
.bands
|
||||
.range(r0 / BAND..=r1 / BAND)
|
||||
.filter(|(_, band)| band.rows.range(r0..=r1).any(|(_, row)| row.range(c0..=c1).next().is_some()))
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
for key in keys {
|
||||
let Some(band) = self.bands.get_mut(&key) else { continue };
|
||||
let inside = |row: Option<&u32>| row.is_some_and(|r| (r0..=r1).contains(r));
|
||||
if whole_rows && inside(band.rows.keys().next()) && inside(band.rows.keys().next_back()) {
|
||||
// The whole band moves: no splitting.
|
||||
let Some(band) = self.bands.remove(&key) else { continue };
|
||||
for (row, taken) in Arc::unwrap_or_clone(band).rows {
|
||||
self.len -= taken.len();
|
||||
out.push((row, taken));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let band = Arc::make_mut(band);
|
||||
let mut block = band.rows.split_off(&r0);
|
||||
let mut tail = match r1.checked_add(1) {
|
||||
Some(next) => block.split_off(&next),
|
||||
None => BTreeMap::new(),
|
||||
};
|
||||
for (row, mut kept) in block {
|
||||
let taken = if whole_rows {
|
||||
std::mem::take(&mut kept)
|
||||
} else if c1.checked_add(1).is_none_or(|next| kept.range(next..).next().is_none()) {
|
||||
// Nothing right of the block: one split.
|
||||
kept.split_off(&c0)
|
||||
} else {
|
||||
let mut taken = BTreeMap::new();
|
||||
for (col, cell) in kept.extract_if(c0..=c1, |_, _| true) {
|
||||
taken.insert(col, cell);
|
||||
}
|
||||
taken
|
||||
};
|
||||
if !kept.is_empty() {
|
||||
band.rows.insert(row, kept);
|
||||
}
|
||||
if !taken.is_empty() {
|
||||
self.len -= taken.len();
|
||||
out.push((row, taken));
|
||||
}
|
||||
}
|
||||
band.rows.append(&mut tail);
|
||||
if band.rows.is_empty() {
|
||||
self.bands.remove(&key);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Stores rows of cells (each row at most once) the way `set` stores each cell: a cell
|
||||
/// replaces what is there, and a blank cell clears its destination instead of being stored.
|
||||
/// A row with nothing stored yet takes the whole map. Rows in order share one band lookup.
|
||||
fn put_rows(&mut self, rows: impl Iterator<Item = (u32, BTreeMap<u32, Cell>)>) {
|
||||
let mut rows = rows.peekable();
|
||||
let mut group = Vec::new();
|
||||
while let Some((first, cols)) = rows.next() {
|
||||
let key = first / BAND;
|
||||
group.clear();
|
||||
group.push((first, cols));
|
||||
while let Some(next) = rows.next_if(|(row, _)| row / BAND == key) {
|
||||
group.push(next);
|
||||
}
|
||||
// Leave the band shared when only blank cells land on empty positions.
|
||||
let existing = self.bands.get(&key);
|
||||
let writes = group.iter().any(|(row, cols)| {
|
||||
let dest = existing.and_then(|band| band.rows.get(row));
|
||||
cols.iter().any(|(col, cell)| !cell.is_blank() || dest.is_some_and(|d| d.contains_key(col)))
|
||||
});
|
||||
if !writes {
|
||||
continue;
|
||||
}
|
||||
let band = Arc::make_mut(self.bands.entry(key).or_default());
|
||||
for (row, mut cols) in group.drain(..) {
|
||||
match band.rows.entry(row) {
|
||||
Entry::Vacant(slot) => {
|
||||
if cols.values().any(Cell::is_blank) {
|
||||
cols.retain(|_, cell| !cell.is_blank());
|
||||
}
|
||||
if !cols.is_empty() {
|
||||
self.len += cols.len();
|
||||
slot.insert(cols);
|
||||
}
|
||||
}
|
||||
Entry::Occupied(mut slot) => {
|
||||
let dest = slot.get_mut();
|
||||
for (col, cell) in cols {
|
||||
if cell.is_blank() {
|
||||
if dest.remove(&col).is_some() {
|
||||
self.len -= 1;
|
||||
}
|
||||
} else if dest.insert(col, cell).is_none() {
|
||||
self.len += 1;
|
||||
}
|
||||
}
|
||||
if dest.is_empty() {
|
||||
slot.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if band.rows.is_empty() {
|
||||
self.bands.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `at + delta` when it lies in `0..limit`.
|
||||
fn shifted(at: u32, delta: i64, limit: u32) -> Option<u32> {
|
||||
let to = (at as i64).checked_add(delta)?;
|
||||
if (0..limit as i64).contains(&to) { Some(to as u32) } else { None }
|
||||
}
|
||||
|
||||
/// The cells of one row with their columns moved by `delta`; cells that would leave the sheet are
|
||||
/// dropped.
|
||||
fn shift_keys(cols: BTreeMap<u32, Cell>, delta: i64) -> BTreeMap<u32, Cell> {
|
||||
let mut out = BTreeMap::new();
|
||||
for (col, cell) in cols {
|
||||
if let Some(c) = shifted(col, delta, gridcraft_core::MAX_COLS) {
|
||||
out.insert(c, cell);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gridcraft_core::Value;
|
||||
use gridcraft_core::{MAX_COLS, MAX_ROWS, Value};
|
||||
|
||||
fn c(r: u32, col: u32) -> CellRef {
|
||||
CellRef::new(r, col)
|
||||
@@ -396,4 +521,211 @@ mod tests {
|
||||
s.shift_cols(0, 1);
|
||||
assert!(s.get(c(7, 1)).is_some());
|
||||
}
|
||||
|
||||
/// The per-cell algorithms the shifts used before they moved whole rows (#3575), kept as the
|
||||
/// reference the structural versions must match cell for cell.
|
||||
mod per_cell {
|
||||
use super::*;
|
||||
|
||||
fn reset(s: &mut CellStore, moved: Vec<(CellRef, Cell)>, to: impl Fn(CellRef) -> Option<CellRef>) {
|
||||
for (at, cell) in moved {
|
||||
if let Some(at) = to(at) {
|
||||
s.set(at, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn row_by(delta: i64) -> impl Fn(CellRef) -> Option<CellRef> {
|
||||
move |at| {
|
||||
let r = at.row as i64 + delta;
|
||||
(0..MAX_ROWS as i64).contains(&r).then(|| CellRef::new(r as u32, at.col))
|
||||
}
|
||||
}
|
||||
fn col_by(delta: i64) -> impl Fn(CellRef) -> Option<CellRef> {
|
||||
move |at| {
|
||||
let c = at.col as i64 + delta;
|
||||
(0..MAX_COLS as i64).contains(&c).then(|| CellRef::new(at.row, c as u32))
|
||||
}
|
||||
}
|
||||
pub fn take_range(s: &mut CellStore, r: RangeRef) -> Vec<(CellRef, Cell)> {
|
||||
let keys: Vec<CellRef> = s.iter_range(r).map(|(c, _)| c).collect();
|
||||
keys.into_iter().filter_map(|c| s.remove(c).map(|cell| (c, cell))).collect()
|
||||
}
|
||||
pub fn shift_rows(s: &mut CellStore, from: u32, delta: i64) {
|
||||
let keys: Vec<CellRef> = s.iter().filter(|(c, _)| c.row >= from).map(|(c, _)| c).collect();
|
||||
let moved = keys.into_iter().filter_map(|c| s.remove(c).map(|cell| (c, cell))).collect();
|
||||
reset(s, moved, row_by(delta));
|
||||
}
|
||||
pub fn shift_cols(s: &mut CellStore, from: u32, delta: i64) {
|
||||
let keys: Vec<CellRef> = s.iter().filter(|(c, _)| c.col >= from).map(|(c, _)| c).collect();
|
||||
let moved = keys.into_iter().filter_map(|c| s.remove(c).map(|cell| (c, cell))).collect();
|
||||
reset(s, moved, col_by(delta));
|
||||
}
|
||||
pub fn shift_cols_in_rows(s: &mut CellStore, r0: u32, r1: u32, from: u32, delta: i64) {
|
||||
let moved = take_range(s, RangeRef::new(CellRef::new(r0, from), CellRef::new(r1, MAX_COLS - 1)));
|
||||
reset(s, moved, col_by(delta));
|
||||
}
|
||||
pub fn shift_rows_in_cols(s: &mut CellStore, c0: u32, c1: u32, from: u32, delta: i64) {
|
||||
let moved = take_range(s, RangeRef::new(CellRef::new(from, c0), CellRef::new(MAX_ROWS - 1, c1)));
|
||||
reset(s, moved, row_by(delta));
|
||||
}
|
||||
}
|
||||
|
||||
struct Lcg(u64);
|
||||
impl Lcg {
|
||||
fn below(&mut self, n: u64) -> u64 {
|
||||
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(self.0 >> 33) % n.max(1)
|
||||
}
|
||||
fn pick<T: Copy>(&mut self, xs: &[T]) -> T {
|
||||
xs[self.below(xs.len() as u64) as usize]
|
||||
}
|
||||
}
|
||||
|
||||
/// A position near a band edge, the sheet edge, or anywhere below `max`.
|
||||
fn coord(rng: &mut Lcg, max: u32, edges: &[u32]) -> u32 {
|
||||
match rng.below(3) {
|
||||
0 => rng.pick(edges).min(max - 1),
|
||||
1 => max - 1 - rng.below(70.min(max as u64)) as u32,
|
||||
_ => rng.below(max as u64) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Random store with values, styled-only cells, a few cells off the grid, and blank cells left
|
||||
/// behind by `get_mut` (which `set` never stores, but the old shifts dropped and let clear
|
||||
/// their destination).
|
||||
fn random_store(rng: &mut Lcg, max_r: u32, max_c: u32) -> CellStore {
|
||||
let row_edges = [0, 1, 62, 63, 64, 65, 127, 128, 129, 191, 192, MAX_ROWS - 64, MAX_ROWS - 1];
|
||||
let col_edges = [0, 1, 2, 63, 64, 65, MAX_COLS - 2, MAX_COLS - 1];
|
||||
let mut s = CellStore::new();
|
||||
for _ in 0..rng.below(300) {
|
||||
let mut at = CellRef::new(coord(rng, max_r, &row_edges), coord(rng, max_c, &col_edges));
|
||||
// Now and then a cell outside the grid (`set` does not check): the shifts must treat
|
||||
// it as the old code did.
|
||||
match rng.below(60) {
|
||||
0 => at.row = MAX_ROWS + rng.below(3) as u32,
|
||||
1 => at.col = MAX_COLS + rng.below(3) as u32,
|
||||
_ => {}
|
||||
}
|
||||
let cell = match rng.below(5) {
|
||||
0 => Cell { style: crate::style::StyleId(1 + rng.below(3) as u32), ..Cell::default() },
|
||||
1 => Cell::value(Value::from("t")),
|
||||
_ => Cell::value(Value::Number(rng.below(1000) as f64)),
|
||||
};
|
||||
s.set(at, cell);
|
||||
if rng.below(8) == 0
|
||||
&& let Some(cell) = s.get_mut(at)
|
||||
{
|
||||
*cell = Cell::default();
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn assert_same(new: &CellStore, old: &CellStore, base: &CellStore, base_copy: &CellStore, what: &str) {
|
||||
assert_eq!(new, old, "{what}");
|
||||
assert_eq!(new.len(), new.iter().count(), "{what}: len");
|
||||
assert!(new.iter().map(|(c, x)| (c, x.clone())).eq(old.iter().map(|(c, x)| (c, x.clone()))), "{what}: iteration");
|
||||
assert!(new.bands.values().all(|b| !b.rows.is_empty() && b.rows.values().all(|r| !r.is_empty())), "{what}: empty band or row");
|
||||
// Copy-on-write: every band the old code left shared with the snapshot stays shared.
|
||||
for (k, b) in &base.bands {
|
||||
if old.bands.get(k).is_some_and(|o| Arc::ptr_eq(o, b)) {
|
||||
assert!(new.bands.get(k).is_some_and(|n| Arc::ptr_eq(n, b)), "{what}: band {k} copied");
|
||||
}
|
||||
}
|
||||
assert_eq!(base, base_copy, "{what}: snapshot changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_shifts_match_per_cell_shifts() {
|
||||
let mut rng = Lcg(3575);
|
||||
let deltas = [0i64, 1, -1, 2, -2, 63, -63, 64, -64, 65, -65, 128, -128, 1000, -1000, MAX_ROWS as i64, -(MAX_ROWS as i64)];
|
||||
let row_edges = [0, 1, 63, 64, 65, 127, 128, MAX_ROWS - 1, MAX_ROWS];
|
||||
let col_edges = [0, 1, 63, 64, 65, MAX_COLS - 1, MAX_COLS];
|
||||
for trial in 0..3000 {
|
||||
let max_r = [200, 5000, MAX_ROWS][trial % 3];
|
||||
let max_c = [30, MAX_COLS][trial / 3 % 2];
|
||||
let mut base = random_store(&mut rng, max_r, max_c);
|
||||
for step in 0..4 {
|
||||
let snapshot = base.clone();
|
||||
let (mut new, mut old) = (base.clone(), base.clone());
|
||||
let delta = if rng.below(2) == 0 { rng.pick(&deltas) } else { rng.below(301) as i64 - 150 };
|
||||
let row = if rng.below(2) == 0 { rng.pick(&row_edges) } else { rng.below(max_r as u64 + 1) as u32 };
|
||||
let col = if rng.below(2) == 0 { rng.pick(&col_edges) } else { rng.below(max_c as u64 + 1) as u32 };
|
||||
let (row2, col2) = (rng.below(max_r as u64 + 1) as u32, rng.below(max_c as u64 + 1) as u32);
|
||||
let op = rng.below(5);
|
||||
match op {
|
||||
0 => {
|
||||
new.shift_rows(row, delta);
|
||||
per_cell::shift_rows(&mut old, row, delta);
|
||||
}
|
||||
1 => {
|
||||
new.shift_cols(col, delta);
|
||||
per_cell::shift_cols(&mut old, col, delta);
|
||||
}
|
||||
2 => {
|
||||
new.shift_cols_in_rows(row, row2, col, delta);
|
||||
per_cell::shift_cols_in_rows(&mut old, row, row2, col, delta);
|
||||
}
|
||||
3 => {
|
||||
new.shift_rows_in_cols(col, col2, row, delta);
|
||||
per_cell::shift_rows_in_cols(&mut old, col, col2, row, delta);
|
||||
}
|
||||
_ => {
|
||||
let r = RangeRef::new(CellRef::new(row, col), CellRef::new(row2, col2));
|
||||
assert_eq!(new.take_range(r), per_cell::take_range(&mut old, r), "trial {trial} step {step}: taken cells");
|
||||
}
|
||||
}
|
||||
let what = format!("trial {trial} step {step} op {op} row {row}..{row2} col {col}..{col2} delta {delta}");
|
||||
assert_same(&new, &old, &base, &snapshot, &what);
|
||||
base = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_grid_cells_right_of_a_block_stay_put() {
|
||||
let mut s = CellStore::new();
|
||||
for col in [0, 5, MAX_COLS - 1, MAX_COLS + 1] {
|
||||
s.set(c(3, col), Cell::value((col as f64).into()));
|
||||
}
|
||||
let mut old = s.clone();
|
||||
s.shift_cols_in_rows(3, 3, 0, 1);
|
||||
per_cell::shift_cols_in_rows(&mut old, 3, 3, 0, 1);
|
||||
assert_eq!(s, old);
|
||||
assert_eq!(s.get(c(3, MAX_COLS + 1)).map(|x| x.value.clone()), Some(Value::Number((MAX_COLS + 1) as f64)));
|
||||
let r = RangeRef::rows(3, 3);
|
||||
assert_eq!(s.take_range(r), per_cell::take_range(&mut old, r));
|
||||
assert_eq!(s, old);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_blank_cell_clears_its_destination() {
|
||||
let mut s = CellStore::new();
|
||||
s.set(c(3, 0), Cell::value(1.0.into()));
|
||||
s.set(c(5, 0), Cell::value(2.0.into()));
|
||||
s.set(c(5, 1), Cell::value(3.0.into()));
|
||||
if let Some(cell) = s.get_mut(c(5, 0)) {
|
||||
*cell = Cell::default();
|
||||
}
|
||||
s.shift_rows(5, -2);
|
||||
assert!(s.get(c(3, 0)).is_none());
|
||||
assert_eq!(s.get(c(3, 1)).map(|x| x.value.clone()), Some(Value::Number(3.0)));
|
||||
assert_eq!(s.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifting_keeps_untouched_bands_shared() {
|
||||
let mut s = CellStore::new();
|
||||
for r in 0..256 {
|
||||
s.set(c(r, 0), Cell::value((r as f64).into()));
|
||||
}
|
||||
s.set(c(200, 5), Cell::value("x".into()));
|
||||
let snap = s.clone();
|
||||
s.shift_rows(130, 1);
|
||||
assert!(Arc::ptr_eq(&s.bands[&0], &snap.bands[&0]));
|
||||
assert!(Arc::ptr_eq(&s.bands[&1], &snap.bands[&1]));
|
||||
s.shift_cols(5, 1);
|
||||
assert!(Arc::ptr_eq(&s.bands[&0], &snap.bands[&0]));
|
||||
assert_eq!(s.get(c(201, 6)).map(|x| x.value.clone()), Some(Value::from("x")));
|
||||
}
|
||||
}
|
||||
@@ -589,3 +589,30 @@ pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Re
|
||||
pub(crate) fn ink_color(line: Color32, width_pt: f32) -> Color32 {
|
||||
if width_pt > 8.0 { line.gamma_multiply(0.45) } else { line }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gridcraft_chart::Measure;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The production measure is not monotone for labels with a line break: egui lays the text out
|
||||
/// line by line, so ending a prefix with `\n…` can be narrower than the shorter `iii…`. The
|
||||
/// chart's label fitting must not assume otherwise (#1152).
|
||||
#[test]
|
||||
fn egui_measure_shrinks_when_a_prefix_ends_in_a_line_break() {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(theme::pinned_font_definitions());
|
||||
let mut checked = false;
|
||||
// Fonts are ready from the second pass on.
|
||||
for _ in 0..2 {
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
let m = EguiMeasure { painter: ui.painter(), z: 1.0 };
|
||||
let (short, long) = (m.text_width("iii…", 11.0, false), m.text_width("iiii\n…", 11.0, false));
|
||||
checked = long < short;
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
assert!(checked);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,12 @@ fn navigator_items(sheets: &[Arc<Sheet>]) -> Vec<(String, String)> {
|
||||
sheets.iter().enumerate().filter(|(_, s)| s.visibility == Visibility::Visible).map(|(i, s)| (i.to_string(), s.name.clone())).collect()
|
||||
}
|
||||
|
||||
/// Workbook index of the first (or, with `last`, the last) visible sheet: where Ctrl-clicking a tab arrow lands.
|
||||
fn edge_sheet(sheets: &[Arc<Sheet>], last: bool) -> Option<usize> {
|
||||
let mut visible = sheets.iter().enumerate().filter(|(_, s)| s.visibility == Visibility::Visible).map(|(i, _)| i);
|
||||
if last { visible.next_back() } else { visible.next() }
|
||||
}
|
||||
|
||||
/// Switch to sheet `i` from the tab strip or the navigator. In point mode the formula being typed keeps going on the
|
||||
/// other sheet; otherwise the edit is committed first.
|
||||
fn activate_sheet(app: &mut SheetApp, i: usize) {
|
||||
@@ -55,6 +61,18 @@ fn tab_drop(i: usize, steps: i64, copy: bool) -> Option<serde_json::Value> {
|
||||
Some(json!({"sheet": i, "to": to}))
|
||||
}
|
||||
|
||||
/// Where a request to scroll the active tab into view is kept until the tabs are drawn.
|
||||
fn reveal_request_id(scope: egui::Id) -> egui::Id {
|
||||
scope.with("worksheet_tabs_reveal_request")
|
||||
}
|
||||
|
||||
/// Asks the tab strip to bring the active tab into view in this frame, even when the active sheet
|
||||
/// did not change (a Ctrl-click on an arrow whose edge sheet is already active).
|
||||
fn request_reveal(ui: &Ui) {
|
||||
let id = reveal_request_id(ui.scope_id());
|
||||
ui.ctx().data_mut(|data| data.insert_temp(id, true));
|
||||
}
|
||||
|
||||
/// The New sheet (+) button and the gap before it.
|
||||
const NEW_SHEET_WIDTH: f32 = 32.0;
|
||||
/// The handle between the tabs and the horizontal scrollbar.
|
||||
@@ -117,10 +135,28 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
||||
ui.add_space(8.0);
|
||||
if view.show_sheet_tabs {
|
||||
if crate::widgets::icon_button(ui, Icon::Left, t.text_dim, app.ui.language.tr("Previous sheet"), vec2(24.0, 24.0)).clicked() {
|
||||
app.run_or_alert("sheet.previous", json!({}));
|
||||
// Ctrl-click jumps to the first visible sheet, like Excel's tab scroll buttons.
|
||||
let edge =
|
||||
if ui.input(|i| i.modifiers.command) { app.session.active().and_then(|d| edge_sheet(&d.wb.sheets, false)) } else { None };
|
||||
match edge {
|
||||
Some(i) => {
|
||||
activate_sheet(app, i);
|
||||
request_reveal(ui);
|
||||
}
|
||||
None => app.run_or_alert("sheet.previous", json!({})),
|
||||
}
|
||||
}
|
||||
if crate::widgets::icon_button(ui, Icon::Right, t.text_dim, app.ui.language.tr("Next sheet"), vec2(24.0, 24.0)).clicked() {
|
||||
app.run_or_alert("sheet.next", json!({}));
|
||||
// Ctrl-click jumps to the last visible sheet.
|
||||
let edge =
|
||||
if ui.input(|i| i.modifiers.command) { app.session.active().and_then(|d| edge_sheet(&d.wb.sheets, true)) } else { None };
|
||||
match edge {
|
||||
Some(i) => {
|
||||
activate_sheet(app, i);
|
||||
request_reveal(ui);
|
||||
}
|
||||
None => app.run_or_alert("sheet.next", json!({})),
|
||||
}
|
||||
}
|
||||
let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, app.ui.language.tr("All sheets"), vec2(24.0, 24.0));
|
||||
all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, app.ui.language.tr("All sheets")));
|
||||
@@ -163,10 +199,13 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
||||
// allow browsing other tabs without snapping back on every frame.
|
||||
let reveal_key = egui::Id::unique((d.uid, d.wb.active_sheet(), &sheets, tabs_width.to_bits()));
|
||||
let reveal_id = ui.scope_id().with("worksheet_tabs_reveal");
|
||||
let request_id = reveal_request_id(ui.scope_id());
|
||||
let reveal_active = ui.ctx().data_mut(|data| {
|
||||
let changed = data.get_temp::<egui::Id>(reveal_id) != Some(reveal_key);
|
||||
data.insert_temp(reveal_id, reveal_key);
|
||||
changed
|
||||
// A Ctrl-click on an arrow whose edge sheet is already active changes nothing above,
|
||||
// yet asks to bring that tab into view.
|
||||
changed | data.remove_temp::<bool>(request_id).unwrap_or(false)
|
||||
});
|
||||
if view.show_sheet_tabs {
|
||||
ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
@@ -522,7 +561,8 @@ fn status_bar_menu(app: &mut SheetApp, ui: &mut Ui) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
MIN_TRACK, NEW_SHEET_WIDTH, SPLIT_WIDTH, activate_sheet, dragged_share, drop_copies_sheet, navigator_items, rename_needs_focus, tab_drop,
|
||||
MIN_TRACK, NEW_SHEET_WIDTH, SPLIT_WIDTH, activate_sheet, dragged_share, drop_copies_sheet, edge_sheet, navigator_items, rename_needs_focus,
|
||||
tab_drop,
|
||||
};
|
||||
use crate::SheetApp;
|
||||
use egui::Modifiers;
|
||||
@@ -614,6 +654,16 @@ mod tests {
|
||||
assert_eq!(navigator_items(&sheets), vec![("0".to_string(), "Data".to_string()), ("3".to_string(), "Summary".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_click_on_the_tab_arrows_lands_on_the_first_and_last_visible_sheet() {
|
||||
let mut hidden = Sheet::new("Hidden");
|
||||
hidden.visibility = Visibility::Hidden;
|
||||
let sheets = [hidden.clone(), Sheet::new("A"), Sheet::new("B"), Sheet::new("C"), hidden].map(Arc::new);
|
||||
assert_eq!(edge_sheet(&sheets, false), Some(1));
|
||||
assert_eq!(edge_sheet(&sheets, true), Some(3));
|
||||
assert_eq!(edge_sheet(&[], true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rejected_commit_keeps_the_sheet_and_the_edit() {
|
||||
let mut session = gridcraft_engine::Session::new();
|
||||
|
||||
@@ -58,6 +58,20 @@ fn click(h: &mut Harness, at: egui::Pos2) {
|
||||
h.run_steps(20);
|
||||
}
|
||||
|
||||
fn ctrl_click(h: &mut Harness, at: egui::Pos2) {
|
||||
let m = Modifiers::COMMAND;
|
||||
h.input_mut().events.extend([
|
||||
Event::ModifiersChanged(m),
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: m },
|
||||
]);
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: m });
|
||||
h.run_steps(20);
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_and_activating_late_sheets_reveals_their_tabs() {
|
||||
for width in [500.0, 1000.0] {
|
||||
@@ -85,6 +99,65 @@ fn navigation_arrows_reveal_the_selected_tab() {
|
||||
assert_visible(&h, "Tab29");
|
||||
}
|
||||
|
||||
/// A Ctrl/Cmd-click on the Next arrow jumps to the last sheet; repeated after the strip was scrolled
|
||||
/// away from it, the already active edge tab is brought back into view.
|
||||
#[test]
|
||||
fn repeated_ctrl_click_on_the_arrow_reveals_the_active_edge_tab() {
|
||||
let mut h = harness(700.0, 0);
|
||||
let (_, clip) = tab(&h, "Tab00").unwrap();
|
||||
let next = pos2(clip.left() - 44.0, clip.center().y);
|
||||
ctrl_click(&mut h, next);
|
||||
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 29);
|
||||
assert_visible(&h, "Tab29");
|
||||
// Browse the strip back to the start without changing the selection.
|
||||
h.input_mut().events.extend([Event::PointerMoved(clip.center())]);
|
||||
for _ in 0..3 {
|
||||
h.input_mut().events.push(Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
phase: egui::TouchPhase::Move,
|
||||
delta: vec2(0.0, 600.0),
|
||||
modifiers: Modifiers::NONE,
|
||||
});
|
||||
h.run_steps(10);
|
||||
}
|
||||
h.run_steps(40);
|
||||
assert!(!tab(&h, "Tab29").is_some_and(|(r, c)| c.contains_rect(r)), "the strip was scrolled away from the active tab");
|
||||
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 29);
|
||||
ctrl_click(&mut h, next);
|
||||
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 29);
|
||||
assert_visible(&h, "Tab29");
|
||||
}
|
||||
|
||||
/// The same for the Previous arrow and the first sheet.
|
||||
#[test]
|
||||
fn repeated_ctrl_click_on_the_previous_arrow_reveals_the_first_tab() {
|
||||
let mut h = harness(700.0, 29);
|
||||
let (_, clip) = tab(&h, "Tab29").unwrap();
|
||||
let prev = pos2(clip.left() - 68.0, clip.center().y);
|
||||
ctrl_click(&mut h, prev);
|
||||
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 0);
|
||||
assert_visible(&h, "Tab00");
|
||||
// Browse the strip to its end without changing the selection.
|
||||
h.input_mut().events.extend([Event::PointerMoved(clip.center())]);
|
||||
for _ in 0..3 {
|
||||
h.input_mut().events.push(Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
phase: egui::TouchPhase::Move,
|
||||
delta: vec2(0.0, -600.0),
|
||||
modifiers: Modifiers::NONE,
|
||||
});
|
||||
h.run_steps(10);
|
||||
}
|
||||
h.run_steps(40);
|
||||
assert!(!tab(&h, "Tab00").is_some_and(|(r, c)| c.contains_rect(r)), "the strip was scrolled away from the active tab");
|
||||
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 0);
|
||||
ctrl_click(&mut h, prev);
|
||||
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 0);
|
||||
assert_visible(&h, "Tab00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Horizontal wheel/trackpad browsing leaves the workbook unchanged, does not snap back on idle frames, and activating a sheet still reveals its tab.
|
||||
fn manual_wheel_and_trackpad_browsing_does_not_change_selection_or_snap_back() {
|
||||
|
||||
Reference in new issue
Block a user