mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-28 12:46:48 -06:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43284377d2 | ||
|
|
55b5a45af7 | ||
|
|
e6df6f423b | ||
|
|
6e8de6e724 | ||
|
|
9717ccbac1 | ||
|
|
287acf7ad6 | ||
|
|
84e684e586 | ||
|
|
7fbf3f60c8 | ||
|
|
5a84d9bed8 | ||
|
|
95fcdcc2ca | ||
|
|
7898cc8cfd | ||
|
|
e17f06fb67 | ||
|
|
68e929b1d9 | ||
|
|
fb98f5952b | ||
|
|
ad272ca071 | ||
|
|
11bb67386c | ||
|
|
1decf0ef51 | ||
|
|
d33616dc95 | ||
|
|
89ac453efa | ||
|
|
c596bad63b | ||
|
|
393a524aa3 | ||
|
|
3b71e37b70 | ||
|
|
6303865054 | ||
|
|
4425fed53f | ||
|
|
a4c50a6dac | ||
|
|
a614e683ed | ||
|
|
e7d778046b | ||
|
|
998396ad9f | ||
|
|
df42619b1e | ||
|
|
0b6d243946 |
@@ -21,3 +21,27 @@ jobs:
|
||||
lint:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
||||
windows-install-policy:
|
||||
timeout-minutes: 10
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
|
||||
dotnet tool install --tool-path $tools wix --version 5.0.2
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
|
||||
$tools >> $env:GITHUB_PATH
|
||||
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
|
||||
foreach ($extension in @($policy.extensions)) {
|
||||
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
|
||||
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
|
||||
}
|
||||
- name: Build and check non-executable installer fixtures
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
|
||||
|
||||
@@ -19,8 +19,10 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
with:
|
||||
app-name: gridcraft
|
||||
app-dir: GridCraft
|
||||
install-relative-path: Programs/ArtCraft/GridCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -23,7 +23,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
|
||||
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`.
|
||||
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
|
||||
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): validate indexes and ranges before access, use checked access when validity is not established, use checked/saturating arithmetic, and cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
|
||||
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
|
||||
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
|
||||
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
|
||||
|
||||
@@ -27,7 +27,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
|
||||
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
|
||||
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
|
||||
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; every new workspace crate must also include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation.
|
||||
|
||||
Generated
+25
-22
@@ -889,12 +889,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -917,7 +917,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"miniz_oxide 0.9.1",
|
||||
@@ -930,7 +930,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -950,7 +950,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -975,7 +975,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"objc2 0.6.5",
|
||||
@@ -1935,7 +1935,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1962,7 +1962,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-calc"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1974,7 +1974,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-chart"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-core",
|
||||
@@ -1985,7 +1985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-cli"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
@@ -1997,7 +1997,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-core"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"js-sys",
|
||||
@@ -2008,7 +2008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-engine"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
@@ -2024,12 +2024,13 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-formula"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"serde",
|
||||
@@ -2038,7 +2039,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-functions"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"regex",
|
||||
@@ -2046,7 +2047,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-mcp"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"log",
|
||||
@@ -2055,7 +2056,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-model"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -2067,18 +2068,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-numfmt"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-pdf"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-ui-egui"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -2097,12 +2098,13 @@ dependencies = [
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"web-sys",
|
||||
"web-time",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-web"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -2119,11 +2121,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
"gridcraft-functions",
|
||||
"gridcraft-model",
|
||||
"quick-xml 0.41.0",
|
||||
"sha1 0.11.0",
|
||||
@@ -6399,7 +6402,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+5
-5
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -26,7 +26,7 @@ gridcraft-mcp = { path = "crates/mcp" }
|
||||
gridcraft-pdf = { path = "crates/pdf" }
|
||||
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
|
||||
# the app) comes from one storytold/craft-libs revision.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui"] }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -34,7 +34,8 @@ serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
||||
web-time = "1.1.0"
|
||||
rayon = "1"
|
||||
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
|
||||
quick-xml = "0.41"
|
||||
@@ -61,7 +62,7 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
|
||||
epaint_default_fonts = "0.37"
|
||||
# One native window per workbook: the window list, Window menu titles and the immediate-viewport
|
||||
# driver. Same craft-libs revision as craft-update above.
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", default-features = false, features = ["egui"] }
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false, features = ["egui"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
@@ -72,7 +73,6 @@ too_many_arguments = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
type_complexity = "allow"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::calc::formula_text;
|
||||
use super::package::{REBUILT_KINDS, relationship_kinds, validate_package};
|
||||
use super::*;
|
||||
use super::{BTreeSet, Category, Cell, CellError, CellRef, Diffs, Options, RoundTrip, Sheet, Value, Workbook, number_text, quote_sheet};
|
||||
|
||||
// ---------------------------------------------------------------- round trip
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ fn local_name(name: &[u8]) -> &[u8] {
|
||||
}
|
||||
|
||||
/// `(element local name, attributes)` for every start/empty element of an XML part.
|
||||
fn xml_elements(xml: &[u8]) -> Result<Vec<(String, Vec<(String, String)>)>, String> {
|
||||
type XmlElement = (String, Vec<(String, String)>);
|
||||
|
||||
fn xml_elements(xml: &[u8]) -> Result<Vec<XmlElement>, String> {
|
||||
use quick_xml::events::Event;
|
||||
let mut reader = quick_xml::Reader::from_reader(xml);
|
||||
let mut buf = Vec::new();
|
||||
|
||||
@@ -131,7 +131,7 @@ fn fit(s: &str, w: usize) -> String {
|
||||
if n <= w { s.to_string() } else { format!("…{}", s.chars().skip(n + 1 - w).collect::<String>()) }
|
||||
}
|
||||
|
||||
pub(super) fn print_human(reports: &[FileReport], opts: &Options) {
|
||||
fn print_file_reports(reports: &[FileReport]) {
|
||||
for r in reports {
|
||||
outln!("== {}", r.path);
|
||||
if let Some(e) = &r.error {
|
||||
@@ -186,7 +186,9 @@ pub(super) fn print_human(reports: &[FileReport], opts: &Options) {
|
||||
}
|
||||
}
|
||||
}
|
||||
outln!("");
|
||||
}
|
||||
|
||||
fn print_file_table(reports: &[FileReport]) {
|
||||
let w = reports.iter().map(|r| r.path.chars().count()).max().unwrap_or(4).clamp(4, 50);
|
||||
outln!(
|
||||
"{:<w$} {:>8} {:>8} {:>8} {:>8} {:>9} {:>8} round trip",
|
||||
@@ -220,7 +222,9 @@ pub(super) fn print_human(reports: &[FileReport], opts: &Options) {
|
||||
c.volatile
|
||||
);
|
||||
}
|
||||
let t = totals(reports);
|
||||
}
|
||||
|
||||
fn print_rankings(t: &Totals, opts: &Options) {
|
||||
let top = ranked(&t.functions);
|
||||
if !top.is_empty() {
|
||||
outln!("");
|
||||
@@ -239,6 +243,9 @@ pub(super) fn print_human(reports: &[FileReport], opts: &Options) {
|
||||
outln!(" {n:>8} {sig}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_totals(t: &Totals) {
|
||||
outln!("");
|
||||
outln!("files: {} ({} opened, {} failed to open)", t.files, t.opened, t.failed);
|
||||
outln!(
|
||||
@@ -262,3 +269,12 @@ pub(super) fn print_human(reports: &[FileReport], opts: &Options) {
|
||||
if cats.is_empty() { String::new() } else { format!("; categories (files): {}", cats.join(", ")) }
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn print_human(reports: &[FileReport], opts: &Options) {
|
||||
print_file_reports(reports);
|
||||
outln!("");
|
||||
print_file_table(reports);
|
||||
let t = totals(reports);
|
||||
print_rankings(&t, opts);
|
||||
print_totals(&t);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//! The crate's integration tests, one module per file, built as one test binary.
|
||||
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod cli;
|
||||
mod text_to_columns_quotes;
|
||||
|
||||
@@ -30,7 +30,7 @@ winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# Same craft-libs revision as the workspace's craft-update and craft-window (see the root
|
||||
# Cargo.toml).
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true }
|
||||
|
||||
@@ -268,9 +268,10 @@ impl log::Log for AppLogger {
|
||||
}
|
||||
// Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock.
|
||||
let thread = std::thread::current();
|
||||
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
|
||||
let line =
|
||||
format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
||||
let logged_at = gridcraft_engine::core::date::unix_now_ms()
|
||||
.and_then(|milliseconds| SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(milliseconds.max(0.0) as u64)))
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
let line = format_line(×tamp(logged_at), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
||||
if self.stderr {
|
||||
// No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting.
|
||||
let _ = std::io::stderr().write_all(line.as_bytes());
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
// unit tests compile here too without a harness to run them).
|
||||
#[cfg(target_os = "macos")]
|
||||
#[path = "../src/native_menu.rs"]
|
||||
#[allow(unused_imports, dead_code)]
|
||||
mod native_menu;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@@ -10,4 +10,8 @@ disallowed-methods = [
|
||||
{ path = "std::time::Instant::now", reason = "traps on wasm; read the clock only off wasm" },
|
||||
{ path = "std::time::SystemTime::now", reason = "traps on wasm; read the clock only off wasm" },
|
||||
]
|
||||
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
cognitive-complexity-threshold = 30
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{Array, CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, compare};
|
||||
use gridcraft_formula::{BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp};
|
||||
use gridcraft_formula::{BinOp, Expr, Reference, SheetSel, StructItem, StructRef, UnOp};
|
||||
use gridcraft_functions::{Arg, Ctx};
|
||||
use gridcraft_model::Workbook;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Array, BinOp, CellError, MAX_CELLS, Value, compare};
|
||||
|
||||
/// The aggregates that can fold a reference without building an array.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
+158
-147
@@ -1,40 +1,13 @@
|
||||
use super::array::{Acc, Agg, zip2, zip3};
|
||||
use super::format::same_kind;
|
||||
use super::special::{ev_shape, resized_sum_range, resizes_sum_range};
|
||||
use super::*;
|
||||
use super::{Area, Arg, Array, CellError, Ev, Evaluator, Expr, FnCtx, Lambda, Pipe, Value, compare, err};
|
||||
use std::sync::Arc;
|
||||
|
||||
impl<'h> Evaluator<'h> {
|
||||
pub(super) fn call(&mut self, name: &str, args: &[Expr]) -> Ev {
|
||||
match name {
|
||||
"IF" => {
|
||||
if args.is_empty() || args.len() > 3 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let c = self.arg_val(args, 0).unwrap_or_default();
|
||||
if let Value::Array(arr) = &c {
|
||||
// Array condition: element-wise.
|
||||
let t = self.arg_val(args, 1).unwrap_or(Value::Bool(true));
|
||||
let f = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) };
|
||||
return Ev::V(zip3(arr, &t, &f, |c, t, f| match c.to_bool() {
|
||||
Ok(true) => t.clone(),
|
||||
Ok(false) => f.clone(),
|
||||
Err(e) => Value::Error(e),
|
||||
}));
|
||||
}
|
||||
match c.to_bool() {
|
||||
Ok(true) => match args.get(1) {
|
||||
Some(Expr::Missing) => Ev::V(Value::Number(0.0)),
|
||||
Some(e) => self.eval(e),
|
||||
None => Ev::V(Value::Bool(true)),
|
||||
},
|
||||
Ok(false) => match args.get(2) {
|
||||
Some(Expr::Missing) => Ev::V(Value::Number(0.0)),
|
||||
Some(e) => self.eval(e),
|
||||
None => Ev::V(Value::Bool(false)),
|
||||
},
|
||||
Err(e) => err(e),
|
||||
}
|
||||
}
|
||||
"IF" => self.if_call(args),
|
||||
"IFS" => {
|
||||
if args.len() < 2 || !args.len().is_multiple_of(2) {
|
||||
return err(CellError::Value);
|
||||
@@ -50,101 +23,10 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
err(CellError::NA)
|
||||
}
|
||||
"IFERROR" | "IFNA" => {
|
||||
if args.len() != 2 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let v = self.arg_ev(args, 0).unwrap_or(Ev::V(Value::Empty));
|
||||
let na_only = name == "IFNA";
|
||||
let catch = |e: CellError| if na_only { e == CellError::NA } else { true };
|
||||
match &v {
|
||||
Ev::V(Value::Error(e)) if catch(*e) => self.eval(&args[1]),
|
||||
Ev::V(Value::Array(a)) if a.data.iter().any(|x| x.as_error().is_some_and(catch)) => {
|
||||
let alt = self.arg_val(args, 1).unwrap_or_default();
|
||||
let data = a.data.iter().map(|x| if x.as_error().is_some_and(catch) { alt.scalar() } else { x.clone() }).collect();
|
||||
Ev::V(Array::new(a.rows, a.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)))
|
||||
}
|
||||
Ev::R(_) => {
|
||||
let val = self.deref(v.clone());
|
||||
match val {
|
||||
Value::Error(e) if catch(e) => self.eval(&args[1]),
|
||||
Value::Array(ref a) if a.data.iter().any(|x| x.as_error().is_some_and(catch)) => {
|
||||
let alt = self.arg_val(args, 1).unwrap_or_default();
|
||||
let data = a.data.iter().map(|x| if x.as_error().is_some_and(catch) { alt.scalar() } else { x.clone() }).collect();
|
||||
Ev::V(Array::new(a.rows, a.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)))
|
||||
}
|
||||
_ => v,
|
||||
}
|
||||
}
|
||||
_ => v,
|
||||
}
|
||||
}
|
||||
"CHOOSE" => {
|
||||
if args.len() < 2 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let index = self.value(&args[0]);
|
||||
if let Value::Array(_) = index {
|
||||
// An array of indexes picks element by element: `CHOOSE({1,2},A1:A3,B1:B3)`
|
||||
// is a two-column array. The library CHOOSE lifts over its arguments.
|
||||
let Some(spec) = gridcraft_functions::lookup(name) else { return err(CellError::Name) };
|
||||
let mut evaluated = vec![Arg { value: index, from_ref: false }];
|
||||
for a in &args[1..] {
|
||||
let value = self.value(a);
|
||||
evaluated.push(Arg { value, from_ref: false });
|
||||
}
|
||||
let mut ctx = FnCtx { ev: self, criteria: None, lambda: None };
|
||||
return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V);
|
||||
}
|
||||
let i = match index.to_number() {
|
||||
Ok(n) => n.trunc() as i64,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
if i < 1 || i as usize >= args.len() {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
self.eval(&args[i as usize])
|
||||
}
|
||||
"SWITCH" => {
|
||||
if args.len() < 3 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let v = self.arg_val(args, 0).unwrap_or_default().scalar();
|
||||
if let Value::Error(e) = v {
|
||||
return err(e);
|
||||
}
|
||||
let rest = &args[1..];
|
||||
let mut i = 0;
|
||||
while i + 1 < rest.len() {
|
||||
let cand = self.value(&rest[i]).scalar();
|
||||
if !cand.is_error() && compare(&v, &cand) == std::cmp::Ordering::Equal && same_kind(&v, &cand) {
|
||||
return self.eval(&rest[i + 1]);
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
if rest.len() % 2 == 1 { rest.last().map_or(err(CellError::NA), |d| self.eval(d)) } else { err(CellError::NA) }
|
||||
}
|
||||
"ROW" | "COLUMN" => {
|
||||
let is_row = name == "ROW";
|
||||
if args.is_empty() || matches!(args.first(), Some(Expr::Missing)) {
|
||||
return Ev::V(Value::number(if is_row { self.at.row + 1 } else { self.at.col + 1 } as f64));
|
||||
}
|
||||
match self.eval(&args[0]) {
|
||||
Ev::R(a) => {
|
||||
let Some(a) = a.first() else { return err(CellError::Ref) };
|
||||
let r = self.trim_lines(a);
|
||||
if is_row {
|
||||
let rows: Vec<Value> = (r.start.row..=r.end.row).map(|x| Value::number(x as f64 + 1.0)).collect();
|
||||
if rows.len() == 1 { Ev::V(rows.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::column(rows).into()) }
|
||||
} else {
|
||||
let cols: Vec<Value> = (r.start.col..=r.end.col).map(|x| Value::number(x as f64 + 1.0)).collect();
|
||||
if cols.len() == 1 { Ev::V(cols.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::row(cols).into()) }
|
||||
}
|
||||
}
|
||||
Ev::V(Value::Error(e)) => err(e),
|
||||
_ => err(CellError::Value),
|
||||
}
|
||||
}
|
||||
"IFERROR" | "IFNA" => self.if_error_call(name, args),
|
||||
"CHOOSE" => self.choose_call(args),
|
||||
"SWITCH" => self.switch_call(args),
|
||||
"ROW" | "COLUMN" => self.row_column_call(name, args),
|
||||
"ROWS" | "COLUMNS" => {
|
||||
if args.len() != 1 {
|
||||
return err(CellError::Value);
|
||||
@@ -180,28 +62,7 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
}
|
||||
}
|
||||
"SHEET" | "SHEETS" => {
|
||||
let wb = self.host.workbook();
|
||||
if args.is_empty() {
|
||||
return Ev::V(Value::number(if name == "SHEET" { self.sheet + 1 } else { wb.sheets.len() } as f64));
|
||||
}
|
||||
match self.eval(&args[0]) {
|
||||
Ev::R(a) => {
|
||||
if name == "SHEET" {
|
||||
a.first().map_or(err(CellError::Ref), |a| Ev::V(Value::number(a.sheet as f64 + 1.0)))
|
||||
} else {
|
||||
let mut s: Vec<usize> = a.iter().map(|x| x.sheet).collect();
|
||||
s.dedup();
|
||||
Ev::V(Value::number(s.len() as f64))
|
||||
}
|
||||
}
|
||||
Ev::V(Value::Text(t)) if name == "SHEET" => {
|
||||
let wb = self.host.workbook();
|
||||
wb.sheet_index(&t).map_or(err(CellError::NA), |i| Ev::V(Value::number(i as f64 + 1.0)))
|
||||
}
|
||||
_ => err(CellError::NA),
|
||||
}
|
||||
}
|
||||
"SHEET" | "SHEETS" => self.sheet_call(name, args),
|
||||
"OFFSET" => self.offset(args),
|
||||
"INDIRECT" => self.indirect(args),
|
||||
"INDEX" => self.index(args),
|
||||
@@ -273,6 +134,156 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
}
|
||||
|
||||
fn if_call(&mut self, args: &[Expr]) -> Ev {
|
||||
if args.is_empty() || args.len() > 3 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let condition = self.arg_val(args, 0).unwrap_or_default();
|
||||
if let Value::Array(array) = &condition {
|
||||
let when_true = self.arg_val(args, 1).unwrap_or(Value::Bool(true));
|
||||
let when_false = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) };
|
||||
return Ev::V(zip3(array, &when_true, &when_false, |condition, when_true, when_false| match condition.to_bool() {
|
||||
Ok(true) => when_true.clone(),
|
||||
Ok(false) => when_false.clone(),
|
||||
Err(error) => Value::Error(error),
|
||||
}));
|
||||
}
|
||||
match condition.to_bool() {
|
||||
Ok(true) => match args.get(1) {
|
||||
Some(Expr::Missing) => Ev::V(Value::Number(0.0)),
|
||||
Some(expression) => self.eval(expression),
|
||||
None => Ev::V(Value::Bool(true)),
|
||||
},
|
||||
Ok(false) => match args.get(2) {
|
||||
Some(Expr::Missing) => Ev::V(Value::Number(0.0)),
|
||||
Some(expression) => self.eval(expression),
|
||||
None => Ev::V(Value::Bool(false)),
|
||||
},
|
||||
Err(error) => err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn if_error_call(&mut self, name: &str, args: &[Expr]) -> Ev {
|
||||
if args.len() != 2 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let value = self.arg_ev(args, 0).unwrap_or(Ev::V(Value::Empty));
|
||||
let na_only = name == "IFNA";
|
||||
let catches = |error: CellError| !na_only || error == CellError::NA;
|
||||
match &value {
|
||||
Ev::V(Value::Error(error)) if catches(*error) => self.eval(&args[1]),
|
||||
Ev::V(Value::Array(array)) if array.data.iter().any(|item| item.as_error().is_some_and(catches)) => {
|
||||
let alternate = self.arg_val(args, 1).unwrap_or_default();
|
||||
let data =
|
||||
array.data.iter().map(|item| if item.as_error().is_some_and(catches) { alternate.scalar() } else { item.clone() }).collect();
|
||||
Ev::V(Array::new(array.rows, array.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)))
|
||||
}
|
||||
Ev::R(_) => self.if_error_reference(value.clone(), args, catches),
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
fn if_error_reference(&mut self, value: Ev, args: &[Expr], catches: impl Fn(CellError) -> bool + Copy) -> Ev {
|
||||
match self.deref(value.clone()) {
|
||||
Value::Error(error) if catches(error) => self.eval(&args[1]),
|
||||
Value::Array(ref array) if array.data.iter().any(|item| item.as_error().is_some_and(catches)) => {
|
||||
let alternate = self.arg_val(args, 1).unwrap_or_default();
|
||||
let data =
|
||||
array.data.iter().map(|item| if item.as_error().is_some_and(catches) { alternate.scalar() } else { item.clone() }).collect();
|
||||
Ev::V(Array::new(array.rows, array.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)))
|
||||
}
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
fn choose_call(&mut self, args: &[Expr]) -> Ev {
|
||||
if args.len() < 2 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let index = self.value(&args[0]);
|
||||
if let Value::Array(_) = index {
|
||||
let Some(spec) = gridcraft_functions::lookup("CHOOSE") else { return err(CellError::Name) };
|
||||
let mut evaluated = vec![Arg { value: index, from_ref: false }];
|
||||
for argument in &args[1..] {
|
||||
evaluated.push(Arg { value: self.value(argument), from_ref: false });
|
||||
}
|
||||
let mut context = FnCtx { ev: self, criteria: None, lambda: None };
|
||||
return gridcraft_functions::call(spec, &evaluated, &mut context).pipe(Ev::V);
|
||||
}
|
||||
let index = match index.to_number() {
|
||||
Ok(number) => number.trunc() as i64,
|
||||
Err(error) => return err(error),
|
||||
};
|
||||
if index < 1 || index as usize >= args.len() {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
self.eval(&args[index as usize])
|
||||
}
|
||||
|
||||
fn switch_call(&mut self, args: &[Expr]) -> Ev {
|
||||
if args.len() < 3 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let value = self.arg_val(args, 0).unwrap_or_default().scalar();
|
||||
if let Value::Error(error) = value {
|
||||
return err(error);
|
||||
}
|
||||
let choices = &args[1..];
|
||||
for pair in choices.as_chunks::<2>().0 {
|
||||
let (candidate, result) = (&pair[0], &pair[1]);
|
||||
let candidate = self.value(candidate).scalar();
|
||||
if !candidate.is_error() && compare(&value, &candidate) == std::cmp::Ordering::Equal && same_kind(&value, &candidate) {
|
||||
return self.eval(result);
|
||||
}
|
||||
}
|
||||
if let Some(default) = choices.get(choices.len().saturating_sub(1)).filter(|_| choices.len() % 2 == 1) {
|
||||
self.eval(default)
|
||||
} else {
|
||||
err(CellError::NA)
|
||||
}
|
||||
}
|
||||
|
||||
fn row_column_call(&mut self, name: &str, args: &[Expr]) -> Ev {
|
||||
let is_row = name == "ROW";
|
||||
if args.is_empty() || matches!(args.first(), Some(Expr::Missing)) {
|
||||
return Ev::V(Value::number(if is_row { self.at.row + 1 } else { self.at.col + 1 } as f64));
|
||||
}
|
||||
let areas = match self.eval(&args[0]) {
|
||||
Ev::R(areas) => areas,
|
||||
Ev::V(Value::Error(error)) => return err(error),
|
||||
_ => return err(CellError::Value),
|
||||
};
|
||||
let Some(area) = areas.first() else { return err(CellError::Ref) };
|
||||
let range = self.trim_lines(area);
|
||||
if is_row {
|
||||
let values: Vec<Value> = (range.start.row..=range.end.row).map(|row| Value::number(row as f64 + 1.0)).collect();
|
||||
if values.len() == 1 { Ev::V(values.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::column(values).into()) }
|
||||
} else {
|
||||
let values: Vec<Value> = (range.start.col..=range.end.col).map(|col| Value::number(col as f64 + 1.0)).collect();
|
||||
if values.len() == 1 { Ev::V(values.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::row(values).into()) }
|
||||
}
|
||||
}
|
||||
|
||||
fn sheet_call(&mut self, name: &str, args: &[Expr]) -> Ev {
|
||||
let is_sheet = name == "SHEET";
|
||||
if args.is_empty() {
|
||||
let count = if is_sheet { self.sheet + 1 } else { self.host.workbook().sheets.len() };
|
||||
return Ev::V(Value::number(count as f64));
|
||||
}
|
||||
match self.eval(&args[0]) {
|
||||
Ev::R(areas) if is_sheet => areas.first().map_or(err(CellError::Ref), |area| Ev::V(Value::number(area.sheet as f64 + 1.0))),
|
||||
Ev::R(areas) => {
|
||||
let mut sheets: Vec<usize> = areas.iter().map(|area| area.sheet).collect();
|
||||
sheets.dedup();
|
||||
Ev::V(Value::number(sheets.len() as f64))
|
||||
}
|
||||
Ev::V(Value::Text(name)) if is_sheet => {
|
||||
self.host.workbook().sheet_index(&name).map_or(err(CellError::NA), |index| Ev::V(Value::number(index as f64 + 1.0)))
|
||||
}
|
||||
_ => err(CellError::NA),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev {
|
||||
let Some(spec) = gridcraft_functions::lookup(name) else {
|
||||
// A defined name holding a LAMBDA, or a LET-bound lambda.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use super::{CellError, Value};
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS};
|
||||
|
||||
/// CELL("format") code for a number format.
|
||||
pub(super) fn cell_format_code(code: &str) -> String {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::MAX_LAMBDA_CALLS;
|
||||
use super::array::{broadcast_len, single};
|
||||
use super::*;
|
||||
use super::{Array, CellError, Ev, Evaluator, Expr, Lambda, MAX_CELLS, Value, err};
|
||||
use std::sync::Arc;
|
||||
|
||||
impl<'h> Evaluator<'h> {
|
||||
pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::format::{cell_format_code, r1c1_to_a1};
|
||||
use super::*;
|
||||
use super::{Area, Arg, Array, CellError, Ev, Evaluator, Expr, FnCtx, MAX_CELLS, Value, err};
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
|
||||
use std::sync::Arc;
|
||||
|
||||
impl<'h> Evaluator<'h> {
|
||||
/// Whole rows/columns trimmed for ROW()/COLUMN() arrays.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use super::{Area, BinOp, Ev, Expr, Reference, SheetSel, Value, Workbook};
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
|
||||
|
||||
/// SUMIF and AVERAGEIF with a sum range: Excel sizes the sum range like the criteria range.
|
||||
pub(super) fn resizes_sum_range(name: &str, args: usize) -> bool {
|
||||
@@ -162,6 +163,3 @@ pub const SPECIAL_FUNCTIONS: &[&str] = &[
|
||||
pub fn is_known_function(n: &str) -> bool {
|
||||
is_special(&n.to_ascii_uppercase()) || gridcraft_functions::lookup(n).is_some()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn unused(_: RefKind) {}
|
||||
|
||||
+107
-110
@@ -10,14 +10,13 @@
|
||||
//! and the formulas that read one are evaluated again until the loop settles
|
||||
//! (`PassHost::iterate_loops`).
|
||||
|
||||
use std::collections::{BTreeSet, VecDeque};
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_formula::Expr;
|
||||
use gridcraft_model::{CalcMode, Workbook};
|
||||
|
||||
use crate::eval::{Area, Evaluator, Host, precedents, stack_position};
|
||||
use crate::eval::{Evaluator, Host, precedents, stack_position};
|
||||
|
||||
mod graph;
|
||||
mod pass_host;
|
||||
@@ -368,105 +367,13 @@ impl Calc {
|
||||
self.circular.retain(|k| !evaluated.contains(k));
|
||||
}
|
||||
self.circular.extend(cycles);
|
||||
// Write back.
|
||||
let mut spill_changes: Vec<Key> = Vec::new();
|
||||
let mut freed: Vec<(usize, RangeRef)> = Vec::new();
|
||||
for (k, v) in results {
|
||||
let Some(sheet) = wb.sheet_mut(k.0) else { continue };
|
||||
if let Some(cell) = sheet.cells.get_mut(k.1)
|
||||
&& cell.value != v
|
||||
{
|
||||
cell.value = v;
|
||||
}
|
||||
}
|
||||
for k in &dirty {
|
||||
let new = spills.get(k);
|
||||
let Some(sheet) = wb.sheet_mut(k.0) else { continue };
|
||||
let old = sheet.spill_ranges.get(&k.1).copied();
|
||||
if old.is_none() && new.is_none() {
|
||||
continue;
|
||||
}
|
||||
// An array that spills exactly as before changes nothing its dependents read, so
|
||||
// they aren't queued again. Otherwise two arrays that read each other's spill areas
|
||||
// queue each other in every round, up to the round limit.
|
||||
if let (Some(r), Some(arr)) = (old, new)
|
||||
&& spills_as_before(sheet, k.1, r, arr)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = old {
|
||||
for c in r.iter() {
|
||||
sheet.spill.remove(&c);
|
||||
}
|
||||
sheet.spill_ranges.remove(&k.1);
|
||||
}
|
||||
let mut changed_range = old;
|
||||
if let Some(arr) = new {
|
||||
let end = CellRef::new(k.1.row.saturating_add(arr.rows as u32 - 1), k.1.col.saturating_add(arr.cols as u32 - 1));
|
||||
let range = RangeRef::new(k.1, end);
|
||||
let blocked = end.row >= gridcraft_core::MAX_ROWS
|
||||
|| end.col >= gridcraft_core::MAX_COLS
|
||||
|| range.iter().any(|c| c != k.1 && (sheet.cells.has(c) || sheet.spill.contains_key(&c)))
|
||||
|| sheet.merges.iter().any(|m| m.intersects(&range))
|
||||
|| sheet.tables.iter().any(|t| t.range.contains(k.1));
|
||||
if blocked {
|
||||
if let Some(cell) = sheet.cells.get_mut(k.1) {
|
||||
cell.value = Value::Error(CellError::Spill);
|
||||
}
|
||||
// Not blocked while the pass ran (it would have been #SPILL! there), so
|
||||
// blocked by another array of this pass: the formulas that read this one,
|
||||
// at its anchor or over the area it would have spilled into, read #SPILL!
|
||||
// or empty cells now.
|
||||
let mut deps = Vec::new();
|
||||
self.graph.dependents_of_range(k.0, range, &mut deps);
|
||||
spill_changes.extend(deps.into_iter().filter(|d| d != k));
|
||||
} else {
|
||||
for (i, c) in range.iter().enumerate() {
|
||||
if c != k.1 {
|
||||
sheet.spill.insert(c, arr.data.get(i).cloned().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
sheet.spill_ranges.insert(k.1, range);
|
||||
changed_range = Some(changed_range.map_or(range, |o| o.union(&range)));
|
||||
}
|
||||
}
|
||||
if let Some(r) = changed_range {
|
||||
let mut deps = Vec::new();
|
||||
self.graph.dependents_of_range(k.0, r, &mut deps);
|
||||
// Not the anchor itself (that would read its own array), unless its old array
|
||||
// is gone or partly freed: its new formula may have read the old values, still
|
||||
// laid out while it was evaluated.
|
||||
let freed = match (old, sheet.spill_ranges.get(&k.1)) {
|
||||
(Some(o), Some(n)) => !(n.contains(o.start) && n.contains(o.end)),
|
||||
(_, None) => true,
|
||||
(None, Some(_)) => false,
|
||||
};
|
||||
spill_changes.extend(deps.into_iter().filter(|d| d != k || freed));
|
||||
}
|
||||
if let Some(o) = old
|
||||
&& !sheet.spill_ranges.get(&k.1).is_some_and(|n| n.contains(o.start) && n.contains(o.end))
|
||||
{
|
||||
freed.push((k.0, o));
|
||||
}
|
||||
}
|
||||
let mut spill_changes = self.write_back(wb, &dirty, results, &spills);
|
||||
// Formulas with references only known while evaluating (INDIRECT, OFFSET) may have read
|
||||
// a spill area before it was laid out: they are evaluated once more.
|
||||
if dynamic_again && !spills.is_empty() {
|
||||
dynamic_again = false;
|
||||
spill_changes.extend(self.graph.dynamic.iter().copied());
|
||||
}
|
||||
// An area a formula no longer spills over may unblock another formula's array.
|
||||
for (si, o) in freed {
|
||||
for &(s, c) in self.graph.nodes.keys() {
|
||||
if s == si
|
||||
&& c.row <= o.end.row
|
||||
&& c.col <= o.end.col
|
||||
&& wb.sheet(s).is_some_and(|sh| sh.value(c) == Value::Error(CellError::Spill))
|
||||
{
|
||||
spill_changes.push((s, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
if full {
|
||||
// Everything was already evaluated once; spill dependents need another pass.
|
||||
}
|
||||
@@ -488,6 +395,105 @@ impl Calc {
|
||||
self.circular.retain(|k| self.graph.nodes.contains_key(k));
|
||||
}
|
||||
}
|
||||
|
||||
fn write_back(
|
||||
&mut self,
|
||||
wb: &mut Workbook,
|
||||
dirty: &[Key],
|
||||
results: HashMap<Key, Value>,
|
||||
spills: &HashMap<Key, std::sync::Arc<Array>>,
|
||||
) -> Vec<Key> {
|
||||
let mut spill_changes = Vec::new();
|
||||
let mut freed = Vec::new();
|
||||
for (key, value) in results {
|
||||
let Some(sheet) = wb.sheet_mut(key.0) else { continue };
|
||||
if let Some(cell) = sheet.cells.get_mut(key.1)
|
||||
&& cell.value != value
|
||||
{
|
||||
cell.value = value;
|
||||
}
|
||||
}
|
||||
for key in dirty {
|
||||
let new = spills.get(key);
|
||||
let Some(sheet) = wb.sheet_mut(key.0) else { continue };
|
||||
let old = sheet.spill_ranges.get(&key.1).copied();
|
||||
if old.is_none() && new.is_none() {
|
||||
continue;
|
||||
}
|
||||
// An unchanged spill does not alter any values its dependents read.
|
||||
if let (Some(range), Some(array)) = (old, new)
|
||||
&& spills_as_before(sheet, key.1, range, array)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(range) = old {
|
||||
for cell in range.iter() {
|
||||
sheet.spill.remove(&cell);
|
||||
}
|
||||
sheet.spill_ranges.remove(&key.1);
|
||||
}
|
||||
let mut changed_range = old;
|
||||
if let Some(array) = new {
|
||||
let end = CellRef::new(key.1.row.saturating_add(array.rows as u32 - 1), key.1.col.saturating_add(array.cols as u32 - 1));
|
||||
let range = RangeRef::new(key.1, end);
|
||||
let blocked = spill_is_blocked(sheet, key.1, range, end);
|
||||
if blocked {
|
||||
if let Some(cell) = sheet.cells.get_mut(key.1) {
|
||||
cell.value = Value::Error(CellError::Spill);
|
||||
}
|
||||
let mut blocked_dependents = Vec::new();
|
||||
self.graph.dependents_of_range(key.0, range, &mut blocked_dependents);
|
||||
spill_changes.extend(blocked_dependents.into_iter().filter(|dependent| dependent != key));
|
||||
} else {
|
||||
fill_spill(sheet, key.1, range, array);
|
||||
changed_range = Some(changed_range.map_or(range, |previous| previous.union(&range)));
|
||||
}
|
||||
}
|
||||
if let Some(range) = changed_range {
|
||||
let mut dependents = Vec::new();
|
||||
self.graph.dependents_of_range(key.0, range, &mut dependents);
|
||||
let freed_old_range = match (old, sheet.spill_ranges.get(&key.1)) {
|
||||
(Some(previous), Some(current)) => !(current.contains(previous.start) && current.contains(previous.end)),
|
||||
(_, None) => true,
|
||||
(None, Some(_)) => false,
|
||||
};
|
||||
spill_changes.extend(dependents.into_iter().filter(|dependent| dependent != key || freed_old_range));
|
||||
}
|
||||
if let Some(previous) = old
|
||||
&& !sheet.spill_ranges.get(&key.1).is_some_and(|current| current.contains(previous.start) && current.contains(previous.end))
|
||||
{
|
||||
freed.push((key.0, previous));
|
||||
}
|
||||
}
|
||||
for (sheet_index, range) in freed {
|
||||
for &(sheet, cell) in self.graph.nodes.keys() {
|
||||
let was_blocked_cell = cell.row <= range.end.row
|
||||
&& cell.col <= range.end.col
|
||||
&& wb.sheet(sheet).is_some_and(|sheet| sheet.value(cell) == Value::Error(CellError::Spill));
|
||||
if sheet == sheet_index && was_blocked_cell {
|
||||
spill_changes.push((sheet, cell));
|
||||
}
|
||||
}
|
||||
}
|
||||
spill_changes
|
||||
}
|
||||
}
|
||||
|
||||
fn spill_is_blocked(sheet: &gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, end: CellRef) -> bool {
|
||||
end.row >= gridcraft_core::MAX_ROWS
|
||||
|| end.col >= gridcraft_core::MAX_COLS
|
||||
|| range.iter().any(|cell| cell != anchor && (sheet.cells.has(cell) || sheet.spill.contains_key(&cell)))
|
||||
|| sheet.merges.iter().any(|merge| merge.intersects(&range))
|
||||
|| sheet.tables.iter().any(|table| table.range.contains(anchor))
|
||||
}
|
||||
|
||||
fn fill_spill(sheet: &mut gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, array: &Array) {
|
||||
for (index, cell) in range.iter().enumerate() {
|
||||
if cell != anchor {
|
||||
sheet.spill.insert(cell, array.data.get(index).cloned().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
sheet.spill_ranges.insert(anchor, range);
|
||||
}
|
||||
|
||||
/// Current date-time as a serial (1900 system), from the system clock (browser clock on wasm).
|
||||
@@ -513,21 +519,13 @@ pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> V
|
||||
ev.value(expr)
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods)] // the clock is read only off wasm
|
||||
fn prof_now() -> f64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
0.0
|
||||
}
|
||||
gridcraft_core::date::unix_now_ms().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, Formula, Workbook};
|
||||
@@ -640,11 +638,10 @@ pub(crate) mod tests {
|
||||
}
|
||||
|
||||
/// Opening (full recalc), changing every value of a column, and re-entering every formula.
|
||||
#[allow(clippy::disallowed_methods)] // a native-only timing test: the clock is read off wasm
|
||||
fn open_and_bulk_edit(rows: u32) -> Duration {
|
||||
let mut wb = sum_rows(rows);
|
||||
let mut calc = Calc::new();
|
||||
let t = Instant::now();
|
||||
let started = gridcraft_core::date::unix_now_ms().unwrap_or(0.0);
|
||||
calc.recalc_all(&mut wb);
|
||||
let values: Vec<Key> = (0..rows).map(|r| (0, CellRef::new(r, 4))).collect();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
@@ -655,7 +652,8 @@ pub(crate) mod tests {
|
||||
calc.cells_changed(&mut wb, &values);
|
||||
let formulas: Vec<Key> = (0..rows).map(|r| (0, CellRef::new(r, 10))).collect();
|
||||
calc.cells_changed(&mut wb, &formulas);
|
||||
let elapsed = t.elapsed();
|
||||
let ended = gridcraft_core::date::unix_now_ms().unwrap_or(started);
|
||||
let elapsed = Duration::from_secs_f64((ended - started).max(0.0) / 1000.0);
|
||||
let last = wb.sheet(0).map(|s| s.value(CellRef::new(rows - 1, 10)));
|
||||
let r = rows - 1;
|
||||
assert_eq!(last, Some(Value::Number(f64::from(1 + (5..10).map(|c| r % 97 + c).sum::<u32>()))));
|
||||
@@ -670,7 +668,6 @@ pub(crate) mod tests {
|
||||
|
||||
#[test]
|
||||
#[ignore = "timing-sensitive; run with --ignored in release"]
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
fn recalc_with_range_formulas_scales_linearly() {
|
||||
// Each row's SUM reads a range in the same columns. Recalculating on open used to walk
|
||||
// every row per range read (the used range), and finding a cell's dependents scanned
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use super::*;
|
||||
use super::RangeRef;
|
||||
use crate::eval::Area;
|
||||
use gridcraft_core::CellRef;
|
||||
use std::collections::BTreeSet;
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
|
||||
/// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate).
|
||||
#[derive(Default, Clone, Copy)]
|
||||
@@ -179,6 +183,9 @@ pub struct Graph {
|
||||
unsettled_wide: HashSet<usize>,
|
||||
}
|
||||
|
||||
/// A formula's precedents split by shape: single cells, column intervals and wide ranges.
|
||||
type Entries = (Vec<Key>, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>);
|
||||
|
||||
impl Graph {
|
||||
pub fn len(&self) -> usize {
|
||||
self.nodes.len()
|
||||
@@ -189,7 +196,7 @@ impl Graph {
|
||||
|
||||
/// Index entries of a formula's precedents, without duplicates: single cells, column
|
||||
/// intervals and wide ranges.
|
||||
fn entries(areas: &[Area]) -> (Vec<Key>, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>) {
|
||||
fn entries(areas: &[Area]) -> Entries {
|
||||
let (mut cells, mut cols, mut wide) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for a in areas {
|
||||
if a.range.is_single() {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::graph::{HashMap, HashSet, Key};
|
||||
use super::*;
|
||||
use super::{Evaluator, Host, Workbook, stack_position};
|
||||
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_formula::Expr;
|
||||
|
||||
/// Formulas a formula may calculate on demand one inside the other (each needing one another's
|
||||
/// value before it is done). Each level takes stack, so a chain deeper than this is suspended and
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! Chart layout: title, legend, axes and every chart kind, drawn into [`Prim`]s.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::f32::consts::{PI, TAU};
|
||||
|
||||
use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, QuartileMethod, Theme, TrendlineKind};
|
||||
use gridcraft_model::{Chart, ChartKind, LineDash, QuartileMethod, Theme, TrendlineKind};
|
||||
|
||||
use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale};
|
||||
use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color};
|
||||
@@ -70,8 +69,7 @@ impl Ctx<'_> {
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, align: HAlign, valign: VAlign) {
|
||||
fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, (align, valign): (HAlign, VAlign)) {
|
||||
if s.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -135,7 +133,7 @@ pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure)
|
||||
if let Some(t) = chart_title(chart, &data) {
|
||||
let size = TITLE_SIZE.min(h * 0.12).max(4.0);
|
||||
let t = ctx.fit(&t, size, area.w);
|
||||
ctx.text(w / 2.0, area.y, &t, size, TEXT, HAlign::Center, VAlign::Top);
|
||||
ctx.text(w / 2.0, area.y, &t, size, TEXT, (HAlign::Center, VAlign::Top));
|
||||
let used = size * 1.3 + 4.0;
|
||||
area.y += used;
|
||||
area.h -= used;
|
||||
|
||||
@@ -2,7 +2,10 @@ use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range};
|
||||
use super::legend::{cat_label, plot_kind};
|
||||
use super::lines::{decimate, draw_markers, draw_runs};
|
||||
use super::trend::{draw_trend_labels, draw_trendlines, max_backward, max_forward, max_forward_slots, supports_trendline};
|
||||
use super::*;
|
||||
use super::{
|
||||
Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, Prim, Scale, SeriesData, TEXT,
|
||||
VAlign, WHITE, clean, fixed_scale, nice_scale,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo)
|
||||
|
||||
@@ -18,6 +21,9 @@ enum Fam {
|
||||
Area100,
|
||||
}
|
||||
|
||||
/// Stack accumulator: (axis, family) key with positive and negative running sums.
|
||||
type Stack = ((bool, Fam), Vec<f64>, Vec<f64>);
|
||||
|
||||
fn family(k: ChartKind) -> Fam {
|
||||
use ChartKind::*;
|
||||
match k {
|
||||
@@ -45,16 +51,7 @@ fn is_line(f: Fam) -> bool {
|
||||
matches!(f, Fam::Line | Fam::LineStack)
|
||||
}
|
||||
|
||||
pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizontal: bool) {
|
||||
let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS);
|
||||
if ncat == 0 {
|
||||
return;
|
||||
}
|
||||
let kinds: Vec<ChartKind> = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect();
|
||||
let fams: Vec<Fam> = kinds.iter().map(|k| family(*k)).collect();
|
||||
let any_primary = data.series.iter().any(|s| !s.secondary);
|
||||
let sec: Vec<bool> = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect();
|
||||
|
||||
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();
|
||||
for (i, s) in data.series.iter().enumerate() {
|
||||
@@ -81,7 +78,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
if t > 0.0 { v / t } else { 0.0 }
|
||||
};
|
||||
// Stack accumulators keyed by (axis, family): positive and negative running sums.
|
||||
let mut stacks: Vec<((bool, Fam), Vec<f64>, Vec<f64>)> = Vec::new();
|
||||
let mut stacks: Vec<Stack> = Vec::new();
|
||||
let mut spans: Vec<Vec<Option<(f64, f64)>>> = Vec::with_capacity(data.series.len());
|
||||
for (i, s) in data.series.iter().enumerate() {
|
||||
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
|
||||
@@ -126,6 +123,10 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
spans.push(row);
|
||||
}
|
||||
|
||||
spans
|
||||
}
|
||||
|
||||
fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec<Option<(f64, f64)>>]) -> Vec<(Scale, Fmt)> {
|
||||
// Axis scales.
|
||||
let mut axes: Vec<(Scale, Fmt)> = Vec::new();
|
||||
for axis in [false, true] {
|
||||
@@ -151,6 +152,46 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
break;
|
||||
}
|
||||
}
|
||||
axes
|
||||
}
|
||||
|
||||
fn column_slots(fams: &[Fam], sec: &[bool]) -> (Vec<usize>, f32) {
|
||||
// Column slots: each clustered series gets one, each stack group shares one.
|
||||
let mut slot_keys: Vec<(bool, Fam, usize)> = Vec::new();
|
||||
let mut slot_of = vec![0usize; fams.len()];
|
||||
for (i, f) in fams.iter().enumerate() {
|
||||
if !is_col(*f) {
|
||||
continue;
|
||||
}
|
||||
let a = sec.get(i).copied().unwrap_or(false);
|
||||
let key = if *f == Fam::Col { (a, *f, i) } else { (a, *f, usize::MAX) };
|
||||
let k = match slot_keys.iter().position(|k| *k == key) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
slot_keys.push(key);
|
||||
slot_keys.len() - 1
|
||||
}
|
||||
};
|
||||
if let Some(s) = slot_of.get_mut(i) {
|
||||
*s = k;
|
||||
}
|
||||
}
|
||||
(slot_of, slot_keys.len().max(1) as f32)
|
||||
}
|
||||
|
||||
pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizontal: bool) {
|
||||
let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS);
|
||||
if ncat == 0 {
|
||||
return;
|
||||
}
|
||||
let kinds: Vec<ChartKind> = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect();
|
||||
let fams: Vec<Fam> = kinds.iter().map(|k| family(*k)).collect();
|
||||
let any_primary = data.series.iter().any(|s| !s.secondary);
|
||||
let sec: Vec<bool> = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect();
|
||||
|
||||
let spans = series_spans(data, &fams, &sec, ncat);
|
||||
|
||||
let axes = axis_scales(data, &fams, &sec, &spans);
|
||||
let Some((s1, f1)) = axes.first() else { return };
|
||||
let y1 = ValAxis { scale: *s1, fmt: f1 };
|
||||
let y2 = axes.get(1).map(|(s, f)| ValAxis { scale: *s, fmt: f });
|
||||
@@ -177,27 +218,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
let center = |j: usize| if horizontal { plot.bottom() - (j as f32 + 0.5) * cw } else { plot.x + (j as f32 + 0.5) * cw };
|
||||
let vpos = |v: f64, sc: &Scale| if horizontal { plot.x + sc.frac(v) * plot.w } else { plot.bottom() - sc.frac(v) * plot.h };
|
||||
|
||||
// Column slots: each clustered series gets one, each stack group shares one.
|
||||
let mut slot_keys: Vec<(bool, Fam, usize)> = Vec::new();
|
||||
let mut slot_of = vec![0usize; data.series.len()];
|
||||
for (i, f) in fams.iter().enumerate() {
|
||||
if !is_col(*f) {
|
||||
continue;
|
||||
}
|
||||
let a = sec.get(i).copied().unwrap_or(false);
|
||||
let key = if *f == Fam::Col { (a, *f, i) } else { (a, *f, usize::MAX) };
|
||||
let k = match slot_keys.iter().position(|k| *k == key) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
slot_keys.push(key);
|
||||
slot_keys.len() - 1
|
||||
}
|
||||
};
|
||||
if let Some(s) = slot_of.get_mut(i) {
|
||||
*s = k;
|
||||
}
|
||||
}
|
||||
let nslots = slot_keys.len().max(1) as f32;
|
||||
let (slot_of, nslots) = column_slots(&fams, &sec);
|
||||
let bw = cw / (nslots + 1.5);
|
||||
let labels_on = chart.data_labels && ncat <= MAX_LABELS;
|
||||
// Area labels wait until every area is filled, so a later (stacked) area can't paint over them.
|
||||
@@ -275,20 +296,20 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
if horizontal {
|
||||
let y = c + nslots * bw / 2.0 - (k + 0.5) * bw;
|
||||
if stacked {
|
||||
ctx.text(mid, y, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle);
|
||||
ctx.text(mid, y, &t, LABEL_SIZE, on(s.color), (HAlign::Center, VAlign::Middle));
|
||||
} else if v >= 0.0 {
|
||||
ctx.text(pb.max(pt) + 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Left, VAlign::Middle);
|
||||
ctx.text(pb.max(pt) + 3.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Left, VAlign::Middle));
|
||||
} else {
|
||||
ctx.text(lo - 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle);
|
||||
ctx.text(lo - 3.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Middle));
|
||||
}
|
||||
} else {
|
||||
let x = c - nslots * bw / 2.0 + (k + 0.5) * bw;
|
||||
if stacked {
|
||||
ctx.text(x, mid, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle);
|
||||
ctx.text(x, mid, &t, LABEL_SIZE, on(s.color), (HAlign::Center, VAlign::Middle));
|
||||
} else if v >= 0.0 {
|
||||
ctx.text(x, lo - 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
|
||||
ctx.text(x, lo - 2.0, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
|
||||
} else {
|
||||
ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Top);
|
||||
ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Top));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,7 +325,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
if labels_on {
|
||||
for (j, p) in pts.iter().enumerate() {
|
||||
if let (Some(p), Some(v)) = (p, s.values.get(j).copied().flatten()) {
|
||||
ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
|
||||
ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,7 +333,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
}
|
||||
}
|
||||
for (x, y, t) in area_labels.drain(..) {
|
||||
ctx.text(x, y, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
|
||||
ctx.text(x, y, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
|
||||
}
|
||||
}
|
||||
// Trendlines on top: x is the category number 1..n.
|
||||
@@ -412,7 +433,7 @@ pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
|
||||
if chart.data_labels && pts.len() <= MAX_LABELS {
|
||||
for (p, raw) in px.iter().zip(pts) {
|
||||
if let (Some(p), Some((_, y))) = (p, raw) {
|
||||
ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
|
||||
ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{AXIS, AXIS_TITLE_SIZE, Chart, ChartData, Ctx, DARK_TEXT, Fmt, GRID, HAlign, LABEL_SIZE, PI, Prim, Rgba, Scale, TEXT, VAlign, WHITE};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Bx {
|
||||
@@ -44,7 +44,7 @@ pub(super) fn axis_titles(ctx: &mut Ctx, chart: &Chart, mut area: Bx) -> Bx {
|
||||
let lh = AXIS_TITLE_SIZE * 1.4 + 2.0;
|
||||
if let Some(t) = chart.x_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
|
||||
let t = ctx.fit(t, AXIS_TITLE_SIZE, area.w);
|
||||
ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
|
||||
ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
|
||||
area.h -= lh;
|
||||
}
|
||||
if let Some(t) = chart.y_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
|
||||
@@ -123,12 +123,12 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
if grid {
|
||||
ctx.hline(plot.x, plot.right(), yy, GRID, 0.75);
|
||||
}
|
||||
ctx.text(plot.x - 4.0, yy, t, ls, TEXT, HAlign::Right, VAlign::Middle);
|
||||
ctx.text(plot.x - 4.0, yy, t, ls, TEXT, (HAlign::Right, VAlign::Middle));
|
||||
}
|
||||
if let (Some(a), Some(l)) = (y2, &y2labels) {
|
||||
for (v, t) in l {
|
||||
let yy = plot.bottom() - a.scale.frac(*v) * plot.h;
|
||||
ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, HAlign::Left, VAlign::Middle);
|
||||
ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, (HAlign::Left, VAlign::Middle));
|
||||
}
|
||||
}
|
||||
let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min };
|
||||
@@ -142,7 +142,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
let k = (((lw + 6.0) / cw.max(1e-3)).ceil() as usize).clamp(1, n);
|
||||
for (j, t) in l.iter().enumerate().step_by(k) {
|
||||
let t = ctx.fit(t, ls, cw * k as f32 - 2.0);
|
||||
ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top);
|
||||
ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, (HAlign::Center, VAlign::Top));
|
||||
}
|
||||
// Outer levels, one row each below the labels: a group's name centred under its
|
||||
// categories, and a separator at each group boundary down through the rows.
|
||||
@@ -152,7 +152,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw);
|
||||
if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {
|
||||
let t = ctx.fit(t, ls, x1 - x0 - 2.0);
|
||||
ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, HAlign::Center, VAlign::Top);
|
||||
ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, (HAlign::Center, VAlign::Top));
|
||||
}
|
||||
if first > 0 {
|
||||
ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75);
|
||||
@@ -169,7 +169,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
let k = (((lw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1);
|
||||
for (v, t) in ticks.iter().zip(&labels).step_by(k) {
|
||||
let xx = plot.x + s.frac(*v) * plot.w;
|
||||
ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top);
|
||||
ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, (HAlign::Center, VAlign::Top));
|
||||
}
|
||||
if s.min <= 0.0 && s.max >= 0.0 {
|
||||
let xx = plot.x + s.frac(0.0) * plot.w;
|
||||
@@ -202,7 +202,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
ctx.vline(xx, plot.y, plot.bottom(), GRID, 0.75);
|
||||
}
|
||||
if i % k == 0 {
|
||||
ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top);
|
||||
ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, (HAlign::Center, VAlign::Top));
|
||||
}
|
||||
}
|
||||
let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min };
|
||||
@@ -213,7 +213,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
let k = ((lh / ch.max(1e-3)).ceil() as usize).clamp(1, n);
|
||||
for (j, t) in labels.iter().enumerate().step_by(k) {
|
||||
let t = ctx.fit(t, ls, lw - 6.0);
|
||||
ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle);
|
||||
ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, (HAlign::Right, VAlign::Middle));
|
||||
}
|
||||
// Outer levels: a group's name centred beside its categories, separators at the
|
||||
// boundaries running right to the axis.
|
||||
@@ -224,7 +224,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch);
|
||||
if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {
|
||||
let t = ctx.fit(t, ls, w - 4.0);
|
||||
ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, HAlign::Center, VAlign::Middle);
|
||||
ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, (HAlign::Center, VAlign::Middle));
|
||||
}
|
||||
if first > 0 {
|
||||
ctx.hline(x0, plot.x, y1, AXIS, 0.75);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range};
|
||||
use super::legend::{cat_label, point_color};
|
||||
use super::*;
|
||||
use super::{
|
||||
AXIS, Chart, ChartData, Ctx, DOWN, Fmt, GRID, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_MARKERS, Prim, QuartileMethod, STOCK, SeriesData,
|
||||
TAU, TEXT, UP, VAlign, WHITE, clean, nice_scale,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- histogram
|
||||
|
||||
@@ -47,7 +50,7 @@ pub(super) fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
let x = plot.x + b as f32 * cw;
|
||||
ctx.rect(x, top, cw, plot.bottom() - top, s.color, Some((WHITE, 0.75)));
|
||||
if chart.data_labels {
|
||||
ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
|
||||
ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,7 +113,7 @@ pub(super) fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
}
|
||||
if chart.data_labels && ncat <= MAX_LABELS {
|
||||
let v = if *kind == 0 { *t } else { t - b };
|
||||
ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
|
||||
ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,10 +153,10 @@ pub(super) fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
if chart.data_labels {
|
||||
let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None);
|
||||
let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT };
|
||||
ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle);
|
||||
ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, (HAlign::Center, VAlign::Middle));
|
||||
}
|
||||
let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0);
|
||||
ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle);
|
||||
ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Middle));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,7 +197,7 @@ pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inne
|
||||
a += sweep;
|
||||
}
|
||||
for (x, y, t, c) in labels {
|
||||
ctx.text(x, y, &t, LABEL_SIZE, c, HAlign::Center, VAlign::Middle);
|
||||
ctx.text(x, y, &t, LABEL_SIZE, c, (HAlign::Center, VAlign::Middle));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,11 +284,11 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
|
||||
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);
|
||||
ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top));
|
||||
if chart.data_labels && b.h > LABEL_SIZE * 3.2 {
|
||||
let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0);
|
||||
let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0);
|
||||
ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top);
|
||||
ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,7 +328,7 @@ pub(super) fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
ctx.line(ring, GRID, 0.75);
|
||||
}
|
||||
if ti % every == 0 {
|
||||
ctx.text(cx - 3.0, cy - rr, &f.format(t, Some(sc.step)), LABEL_SIZE * 0.9, TEXT, HAlign::Right, VAlign::Middle);
|
||||
ctx.text(cx - 3.0, cy - rr, &f.format(t, Some(sc.step)), LABEL_SIZE * 0.9, TEXT, (HAlign::Right, VAlign::Middle));
|
||||
}
|
||||
}
|
||||
let step = ncat.div_ceil(72).max(1);
|
||||
@@ -349,7 +352,7 @@ pub(super) fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
VAlign::Middle
|
||||
};
|
||||
let t = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw.max(20.0));
|
||||
ctx.text(p[0], p[1], &t, LABEL_SIZE, TEXT, align, valign);
|
||||
ctx.text(p[0], p[1], &t, LABEL_SIZE, TEXT, (align, valign));
|
||||
}
|
||||
for s in &data.series {
|
||||
let mut pts: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), sc.frac(s.values.get(j).copied().flatten().unwrap_or(sc.min)) * r)).collect();
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use super::frame::Bx;
|
||||
use super::trend::supports_trendline;
|
||||
use super::*;
|
||||
use super::{
|
||||
Chart, ChartData, ChartKind, Ctx, DOWN, HAlign, LABEL_SIZE, MAX_CATS, MAX_SERIES, MAX_TRENDLINES, Prim, Rgba, SeriesData, TEXT, Theme, UP,
|
||||
VAlign, auto_name, series_color,
|
||||
};
|
||||
use gridcraft_model::LegendPos;
|
||||
|
||||
/// How a legend entry's swatch is drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -132,7 +136,7 @@ pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos
|
||||
draw_swatch(ctx, e.2, x, yc, s, e.1);
|
||||
let sx = swatch_w(e.2, s) + gap;
|
||||
let t = ctx.fit(&e.0, size, (iw - sx).max(0.0) + 0.5);
|
||||
ctx.text(x + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle);
|
||||
ctx.text(x + sx, yc, &t, size, TEXT, (HAlign::Left, VAlign::Middle));
|
||||
}
|
||||
x += iw + spacing;
|
||||
}
|
||||
@@ -152,7 +156,7 @@ pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos
|
||||
draw_swatch(ctx, e.2, x0, yc, s, e.1);
|
||||
let sx = swatch_w(e.2, s) + gap;
|
||||
let t = ctx.fit(&e.0, size, (colw - sx).max(0.0) + 0.5);
|
||||
ctx.text(x0 + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle);
|
||||
ctx.text(x0 + sx, yc, &t, size, TEXT, (HAlign::Left, VAlign::Middle));
|
||||
}
|
||||
let used = colw + 8.0;
|
||||
if pos == LegendPos::Right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::frame::Bx;
|
||||
use super::*;
|
||||
use super::{Ctx, MAX_LINE_PTS, MAX_MARKERS, Prim, Rgba, WHITE};
|
||||
use std::collections::HashSet;
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::frame::{Bx, value_range};
|
||||
use super::lines::decimate;
|
||||
use super::*;
|
||||
use super::{
|
||||
ChartKind, Ctx, HAlign, LABEL_SIZE, LineDash, MAX_CATS, MAX_LINE_PTS, MAX_TRENDLINES, Prim, SeriesData, TEXT, TrendlineKind, VAlign,
|
||||
fit_trendline, moving_average,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- trendlines
|
||||
|
||||
@@ -36,10 +39,11 @@ pub(super) fn max_backward(s: &SeriesData) -> f64 {
|
||||
s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.backward)).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
type ClippedSegment = ((f64, f64), (f64, f64), bool, bool);
|
||||
|
||||
/// Liang–Barsky: the part of segment `a`–`b` inside the unit square, with whether the start and
|
||||
/// end were cut.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<((f64, f64), (f64, f64), bool, bool)> {
|
||||
fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<ClippedSegment> {
|
||||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||||
let (mut t0, mut t1) = (0.0f64, 1.0f64);
|
||||
for (p, q) in [(-dx, a.0), (dx, 1.0 - a.0), (-dy, a.1), (dy, 1.0 - a.1)] {
|
||||
@@ -161,6 +165,6 @@ pub(super) fn draw_trend_labels(ctx: &mut Ctx, plot: Bx, labels: &[String]) {
|
||||
break;
|
||||
}
|
||||
let t = ctx.fit(t, LABEL_SIZE, plot.w - 8.0);
|
||||
ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Top);
|
||||
ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Top));
|
||||
}
|
||||
}
|
||||
|
||||
+24
-20
@@ -295,15 +295,16 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn range_parse_rejects_signed_rows() {
|
||||
fn range_parse_rejects_signed_rows() -> Result<(), std::io::Error> {
|
||||
assert!(RangeRef::parse("+1:+3").is_none());
|
||||
assert!(RangeRef::parse("-1:3").is_none());
|
||||
assert!(RangeRef::parse("$$1:3").is_none());
|
||||
assert!(RangeRef::parse(":3").is_none());
|
||||
assert!(RangeRef::parse("$$A:$$C").is_none());
|
||||
assert!(RangeRef::parse("$A:$C").is_some());
|
||||
let r = RangeRef::parse("$1:$3").unwrap();
|
||||
let r = RangeRef::parse("$1:$3").ok_or_else(|| std::io::Error::other("valid row range"))?;
|
||||
assert_eq!((r.start.row, r.end.row), (0, 2));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -389,18 +390,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranges() {
|
||||
let r = RangeRef::parse("C3:A1").unwrap();
|
||||
fn ranges() -> Result<(), std::io::Error> {
|
||||
let parse = |s| RangeRef::parse(s).ok_or_else(|| std::io::Error::other("valid range"));
|
||||
let r = parse("C3:A1")?;
|
||||
assert_eq!(r.a1(), "A1:C3");
|
||||
assert_eq!(r.count(), 9);
|
||||
assert_eq!(RangeRef::parse("A:C").unwrap().a1(), "A:C");
|
||||
assert_eq!(RangeRef::parse("3:5").unwrap().a1(), "3:5");
|
||||
assert_eq!(parse("A:C")?.a1(), "A:C");
|
||||
assert_eq!(parse("3:5")?.a1(), "3:5");
|
||||
assert!(RangeRef::parse("0:5").is_none());
|
||||
let a = RangeRef::parse("A1:C3").unwrap();
|
||||
let b = RangeRef::parse("B2:D4").unwrap();
|
||||
assert_eq!(a.intersection(&b).unwrap().a1(), "B2:C3");
|
||||
let a = parse("A1:C3")?;
|
||||
let b = parse("B2:D4")?;
|
||||
assert_eq!(a.intersection(&b).ok_or_else(|| std::io::Error::other("overlap expected"))?.a1(), "B2:C3");
|
||||
assert_eq!(a.union(&b).a1(), "A1:D4");
|
||||
assert_eq!(a.iter().count(), 9);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -458,18 +461,19 @@ mod category_level_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() {
|
||||
let r = |s: &str| RangeRef::parse(s).unwrap();
|
||||
assert_eq!(category_levels(&r("A4:B39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 1 }));
|
||||
assert_eq!(category_levels(&r("A4:D39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 3 }));
|
||||
assert_eq!(category_levels(&r("A4:F39"), Some(&r("C4:C39"))).map(|l| l.outer), Some(3), "capped");
|
||||
assert_eq!(category_levels(&r("A4:A39"), Some(&r("C4:C39"))), None, "one column: a plain list");
|
||||
fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() -> Result<(), std::io::Error> {
|
||||
let r = |s| RangeRef::parse(s).ok_or_else(|| std::io::Error::other("valid test range"));
|
||||
assert_eq!(category_levels(&r("A4:B39")?, Some(&r("C4:C39")?)), Some(CategoryLevels { by_columns: true, outer: 1 }));
|
||||
assert_eq!(category_levels(&r("A4:D39")?, Some(&r("C4:C39")?)), Some(CategoryLevels { by_columns: true, outer: 3 }));
|
||||
assert_eq!(category_levels(&r("A4:F39")?, Some(&r("C4:C39")?)).map(|l| l.outer), Some(3), "capped");
|
||||
assert_eq!(category_levels(&r("A4:A39")?, Some(&r("C4:C39")?)), None, "one column: a plain list");
|
||||
// A row of values: levels are rows.
|
||||
assert_eq!(category_levels(&r("B2:M3"), Some(&r("B4:M4"))), Some(CategoryLevels { by_columns: false, outer: 1 }));
|
||||
assert_eq!(category_levels(&r("B3:M3"), Some(&r("B4:M4"))), None);
|
||||
assert_eq!(category_levels(&r("B2:M3")?, Some(&r("B4:M4")?)), Some(CategoryLevels { by_columns: false, outer: 1 }));
|
||||
assert_eq!(category_levels(&r("B3:M3")?, Some(&r("B4:M4")?)), None);
|
||||
// Literal values: the categories' own shape decides.
|
||||
assert_eq!(category_levels(&r("A1:B6"), None), Some(CategoryLevels { by_columns: true, outer: 1 }));
|
||||
assert_eq!(category_levels(&r("A1:F2"), None), Some(CategoryLevels { by_columns: false, outer: 1 }));
|
||||
assert_eq!(category_levels(&r("A:B"), Some(&r("C:C"))), None, "whole columns");
|
||||
assert_eq!(category_levels(&r("A1:B6")?, None), Some(CategoryLevels { by_columns: true, outer: 1 }));
|
||||
assert_eq!(category_levels(&r("A1:F2")?, None), Some(CategoryLevels { by_columns: false, outer: 1 }));
|
||||
assert_eq!(category_levels(&r("A:B")?, Some(&r("C:C")?)), None, "whole columns");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+11
-11
@@ -47,13 +47,11 @@ pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
|
||||
}
|
||||
|
||||
/// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable.
|
||||
/// On wasm32 `std::time::SystemTime::now` traps, so the browser clock is read through `Date.now()`.
|
||||
#[allow(clippy::disallowed_methods)] // the std clock is read only off wasm
|
||||
/// Chrono uses the browser clock on wasm32, where the standard library clock methods trap.
|
||||
pub fn unix_now_ms() -> Option<f64> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?;
|
||||
Some(d.as_secs_f64() * 1000.0)
|
||||
Some(chrono::Utc::now().timestamp_millis() as f64)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
@@ -323,7 +321,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fictitious_1900_02_29() {
|
||||
fn fictitious_1900_02_29() -> Result<(), std::io::Error> {
|
||||
// Excel's 1900 calendar has a 29 February 1900 (serial 60); day and month overflow count
|
||||
// through it.
|
||||
let s = DateSystem::D1900;
|
||||
@@ -345,7 +343,7 @@ mod tests {
|
||||
assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(1521.0));
|
||||
// Every serial up to 1900-12-31 comes back from its own year, month and day.
|
||||
for serial in 0..=366 {
|
||||
let d = datetime_from_serial(s, serial as f64).unwrap();
|
||||
let d = datetime_from_serial(s, serial as f64).ok_or_else(|| std::io::Error::other("serial should convert"))?;
|
||||
assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial as f64), "{d:?}");
|
||||
}
|
||||
assert_eq!(days_in_month_in(s, 1900, 2), 29);
|
||||
@@ -358,6 +356,7 @@ mod tests {
|
||||
assert_eq!(serial_from_ymd(s, 1904, 3, 1), Some(60.0));
|
||||
assert_eq!(serial_from_ymd(s, 1904, 1, 0), None);
|
||||
assert_eq!(serial_from_ymd(s, 1900, 2, 29), None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -371,17 +370,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
fn roundtrip() -> Result<(), std::io::Error> {
|
||||
let s = DateSystem::D1900;
|
||||
for serial in [1.0, 59.0, 60.0, 61.0, 36526.0, 46302.0, 2958465.0] {
|
||||
let d = datetime_from_serial(s, serial).unwrap();
|
||||
let d = datetime_from_serial(s, serial).ok_or_else(|| std::io::Error::other("serial should convert"))?;
|
||||
assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial));
|
||||
}
|
||||
let d = datetime_from_serial(s, 60.0).unwrap();
|
||||
let d = datetime_from_serial(s, 60.0).ok_or_else(|| std::io::Error::other("leap-day serial should convert"))?;
|
||||
assert_eq!((d.year, d.month, d.day), (1900, 2, 29));
|
||||
let d = datetime_from_serial(s, 46302.75).unwrap();
|
||||
let d = datetime_from_serial(s, 46302.75).ok_or_else(|| std::io::Error::other("time serial should convert"))?;
|
||||
assert_eq!((d.hour, d.minute, d.weekday), (18, 0, 3)); // 2026-10-07 is a Wednesday
|
||||
let d = datetime_from_serial(s, 1.0).unwrap();
|
||||
let d = datetime_from_serial(s, 1.0).ok_or_else(|| std::io::Error::other("first serial should convert"))?;
|
||||
assert_eq!(d.weekday, 0); // 1900-01-01 was a Sunday in Excel's calendar
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,11 +454,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrays() {
|
||||
let a = Array::new(2, 2, vec![1.0.into(), 2.0.into(), 3.0.into(), 4.0.into()]).unwrap();
|
||||
fn arrays() -> Result<(), std::io::Error> {
|
||||
let a = Array::new(2, 2, vec![1.0.into(), 2.0.into(), 3.0.into(), 4.0.into()]).ok_or_else(|| std::io::Error::other("valid array"))?;
|
||||
assert_eq!(a.transpose().get(0, 1), Some(&Value::Number(3.0)));
|
||||
assert!(Array::new(2, 2, vec![]).is_none());
|
||||
let row = Array::row(vec![1.0.into(), 2.0.into()]);
|
||||
assert_eq!(row.get_broadcast(5, 1), Value::Number(2.0));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ gridcraft-model = { workspace = true }
|
||||
gridcraft-calc = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
gridcraft-xlsx = { workspace = true }
|
||||
|
||||
+95
-77
@@ -136,6 +136,95 @@ fn lerp(a: [u8; 3], b: [u8; 3], t: f64) -> [u8; 3] {
|
||||
|
||||
pub use gridcraft_model::icon_count;
|
||||
|
||||
struct RuleEval<'a> {
|
||||
wb: &'a Workbook,
|
||||
sheet: usize,
|
||||
cell: CellRef,
|
||||
first: CellRef,
|
||||
value: &'a Value,
|
||||
text: &'a str,
|
||||
stats: &'a RuleStats,
|
||||
}
|
||||
|
||||
fn simple_rule_style<'a>(ctx: &RuleEval<'_>, rule: &'a CfRule) -> Option<&'a Style> {
|
||||
let RuleEval { wb, sheet, cell, first, value, text, stats } = ctx;
|
||||
match rule {
|
||||
CfRule::CellIs { op, a, b, style } => {
|
||||
let eval = |f: &str| gridcraft_calc::evaluate(wb, *sheet, *cell, &relative(f, *first, *cell));
|
||||
let av = eval(a);
|
||||
let matches = match op {
|
||||
CfOperator::Between | CfOperator::NotBetween => {
|
||||
let bv = b.as_deref().map(eval).unwrap_or_default();
|
||||
let (lo, hi) = if compare(&av, &bv) == std::cmp::Ordering::Greater { (bv, av) } else { (av, bv) };
|
||||
let within = compare(value, &lo) != std::cmp::Ordering::Less && compare(value, &hi) != std::cmp::Ordering::Greater;
|
||||
if *op == CfOperator::Between { within } else { !within }
|
||||
}
|
||||
CfOperator::Equal => compare(value, &av) == std::cmp::Ordering::Equal,
|
||||
CfOperator::NotEqual => compare(value, &av) != std::cmp::Ordering::Equal,
|
||||
CfOperator::Greater => compare(value, &av) == std::cmp::Ordering::Greater,
|
||||
CfOperator::Less => compare(value, &av) == std::cmp::Ordering::Less,
|
||||
CfOperator::GreaterOrEqual => compare(value, &av) != std::cmp::Ordering::Less,
|
||||
CfOperator::LessOrEqual => compare(value, &av) != std::cmp::Ordering::Greater,
|
||||
};
|
||||
(matches && !value.is_error()).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::Expression { formula, style } => {
|
||||
gridcraft_calc::evaluate(wb, *sheet, *cell, &relative(formula, *first, *cell)).to_bool().unwrap_or(false).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::ContainsText { text: needle, style } => {
|
||||
(!value.is_error() && text.to_lowercase().contains(&needle.to_lowercase())).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::NotContainsText { text: needle, style } => {
|
||||
(value.is_error() || !text.to_lowercase().contains(&needle.to_lowercase())).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::BeginsWith { text: needle, style } => {
|
||||
(!value.is_error() && text.to_lowercase().starts_with(&needle.to_lowercase())).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::EndsWith { text: needle, style } => {
|
||||
(!value.is_error() && text.to_lowercase().ends_with(&needle.to_lowercase())).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::Blanks { style } => (value.is_empty() || text.trim().is_empty()).then_some(style.as_ref()),
|
||||
CfRule::NoBlanks { style } => (!value.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()),
|
||||
CfRule::Errors { style } => value.is_error().then_some(style.as_ref()),
|
||||
CfRule::NoErrors { style } => (!value.is_error()).then_some(style.as_ref()),
|
||||
CfRule::Duplicate { style } => {
|
||||
(!value.is_empty() && stats.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::Unique { style } => {
|
||||
(!value.is_empty() && stats.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn time_period_matches(wb: &Workbook, period: &str, serial: f64) -> bool {
|
||||
let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor();
|
||||
let day = serial.floor();
|
||||
let date = |serial| gridcraft_core::date::datetime_from_serial(wb.date_system, serial);
|
||||
let week_start = today - date(today).map(|value| value.weekday as f64).unwrap_or(0.0);
|
||||
let (year, month) = date(today).map(|value| (value.year, value.month)).unwrap_or((2026, 1));
|
||||
let year_month = date(day).map(|value| (value.year, value.month));
|
||||
let month_matches = |offset: i32| {
|
||||
let shifted_month = month as i32 + offset;
|
||||
let shifted_year = year + (shifted_month - 1).div_euclid(12);
|
||||
let shifted_month = (shifted_month - 1).rem_euclid(12) + 1;
|
||||
year_month == Some((shifted_year, shifted_month as u32))
|
||||
};
|
||||
match period {
|
||||
"yesterday" => day == today - 1.0,
|
||||
"today" => day == today,
|
||||
"tomorrow" => day == today + 1.0,
|
||||
"last7Days" => day > today - 7.0 && day <= today,
|
||||
"lastWeek" => day >= week_start - 7.0 && day < week_start,
|
||||
"thisWeek" => day >= week_start && day < week_start + 7.0,
|
||||
"nextWeek" => day >= week_start + 7.0 && day < week_start + 14.0,
|
||||
"lastMonth" => month_matches(-1),
|
||||
"thisMonth" => month_matches(0),
|
||||
"nextMonth" => month_matches(1),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl CfCache {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
@@ -161,52 +250,8 @@ impl CfCache {
|
||||
None => &no_stats,
|
||||
};
|
||||
let text = v.display();
|
||||
let matched_style: Option<&Style> = match &cf.rule {
|
||||
CfRule::CellIs { op, a, b, style } => {
|
||||
let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, c, &relative(f, first, c));
|
||||
let av = eval(a);
|
||||
let ok = match op {
|
||||
CfOperator::Between | CfOperator::NotBetween => {
|
||||
let bv = b.as_deref().map(eval).unwrap_or_default();
|
||||
let (lo, hi) = if compare(&av, &bv) == std::cmp::Ordering::Greater { (bv, av) } else { (av, bv) };
|
||||
let inside = compare(&v, &lo) != std::cmp::Ordering::Less && compare(&v, &hi) != std::cmp::Ordering::Greater;
|
||||
if *op == CfOperator::Between { inside } else { !inside }
|
||||
}
|
||||
CfOperator::Equal => compare(&v, &av) == std::cmp::Ordering::Equal,
|
||||
CfOperator::NotEqual => compare(&v, &av) != std::cmp::Ordering::Equal,
|
||||
CfOperator::Greater => compare(&v, &av) == std::cmp::Ordering::Greater,
|
||||
CfOperator::Less => compare(&v, &av) == std::cmp::Ordering::Less,
|
||||
CfOperator::GreaterOrEqual => compare(&v, &av) != std::cmp::Ordering::Less,
|
||||
CfOperator::LessOrEqual => compare(&v, &av) != std::cmp::Ordering::Greater,
|
||||
};
|
||||
// As in Excel, a blank cell compares as 0 (or ""), and an error never matches.
|
||||
(ok && !v.is_error()).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::Expression { formula, style } => {
|
||||
gridcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref())
|
||||
}
|
||||
// As in Excel, an error value never contains, begins or ends with anything (its rule
|
||||
// formulas SEARCH/LEFT/RIGHT fail on it), so it always "does not contain" (#1162).
|
||||
CfRule::ContainsText { text: t, style } => {
|
||||
(!v.is_error() && text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::NotContainsText { text: t, style } => {
|
||||
(v.is_error() || !text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::BeginsWith { text: t, style } => {
|
||||
(!v.is_error() && text.to_lowercase().starts_with(&t.to_lowercase())).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::EndsWith { text: t, style } => (!v.is_error() && text.to_lowercase().ends_with(&t.to_lowercase())).then_some(style.as_ref()),
|
||||
CfRule::Blanks { style } => (v.is_empty() || text.trim().is_empty()).then_some(style.as_ref()),
|
||||
CfRule::NoBlanks { style } => (!v.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()),
|
||||
CfRule::Errors { style } => v.is_error().then_some(style.as_ref()),
|
||||
CfRule::NoErrors { style } => (!v.is_error()).then_some(style.as_ref()),
|
||||
CfRule::Duplicate { style } => {
|
||||
(!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::Unique { style } => {
|
||||
(!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref())
|
||||
}
|
||||
let context = RuleEval { wb, sheet, cell: c, first, value: &v, text: &text, stats: st };
|
||||
let matched_style = simple_rule_style(&context, &cf.rule).or_else(|| match &cf.rule {
|
||||
CfRule::Top10 { bottom, percent, rank, style } => match v.as_f64() {
|
||||
Some(n) if !st.sorted.is_empty() => {
|
||||
let k = if *percent { ((st.sorted.len() as f64) * (*rank as f64) / 100.0).floor().max(1.0) as usize } else { *rank as usize };
|
||||
@@ -229,36 +274,8 @@ impl CfCache {
|
||||
None => None,
|
||||
},
|
||||
CfRule::TimePeriod { period, style } => match v.as_f64() {
|
||||
Some(n) => {
|
||||
let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor();
|
||||
let d = n.floor();
|
||||
let wd = gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| x.weekday as f64).unwrap_or(0.0);
|
||||
let week_start = today - wd;
|
||||
let (ty, tm) =
|
||||
gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| (x.year, x.month)).unwrap_or((2026, 1));
|
||||
let ym = gridcraft_core::date::datetime_from_serial(wb.date_system, d).map(|x| (x.year, x.month));
|
||||
let month_off = |k: i32| {
|
||||
let m = tm as i32 + k;
|
||||
let y = ty + (m - 1).div_euclid(12);
|
||||
let m = (m - 1).rem_euclid(12) + 1;
|
||||
ym == Some((y, m as u32))
|
||||
};
|
||||
let ok = match period.as_str() {
|
||||
"yesterday" => d == today - 1.0,
|
||||
"today" => d == today,
|
||||
"tomorrow" => d == today + 1.0,
|
||||
"last7Days" => d > today - 7.0 && d <= today,
|
||||
"lastWeek" => d >= week_start - 7.0 && d < week_start,
|
||||
"thisWeek" => d >= week_start && d < week_start + 7.0,
|
||||
"nextWeek" => d >= week_start + 7.0 && d < week_start + 14.0,
|
||||
"lastMonth" => month_off(-1),
|
||||
"thisMonth" => month_off(0),
|
||||
"nextMonth" => month_off(1),
|
||||
_ => false,
|
||||
};
|
||||
ok.then_some(style.as_ref())
|
||||
}
|
||||
None => None,
|
||||
Some(n) if time_period_matches(wb, period, n) => Some(style.as_ref()),
|
||||
_ => None,
|
||||
},
|
||||
CfRule::ColorScale { stops } => {
|
||||
if let Some(n) = v.as_f64() {
|
||||
@@ -329,7 +346,8 @@ impl CfCache {
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
_ => None,
|
||||
});
|
||||
if let Some(st) = matched_style {
|
||||
any = true;
|
||||
// Every matching rule applies; where two set the same part, the rule with the
|
||||
|
||||
@@ -205,7 +205,6 @@ pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) {
|
||||
}
|
||||
|
||||
fn test_commands() -> std::sync::MutexGuard<'static, Vec<&'static CommandSpec>> {
|
||||
#[allow(clippy::unwrap_used)] // a poisoned test registry has no meaningful recovery
|
||||
match TEST_COMMANDS.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
@@ -349,11 +348,15 @@ pub(crate) fn edit<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) ->
|
||||
})
|
||||
}
|
||||
|
||||
fn wall_clock() -> std::time::Duration {
|
||||
std::time::SystemTime::UNIX_EPOCH.elapsed().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
|
||||
// `GRIDCRAFT_PROFILE` timing. Read the clock only when profiling, and never on wasm: there
|
||||
// `Instant::now` traps (`RuntimeError: unreachable`), which broke every edit in the web app.
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| std::time::Instant::now()) };
|
||||
// `GRIDCRAFT_PROFILE` timing, never on wasm where the clock traps. `elapsed` on the epoch
|
||||
// reads the wall clock without the (disallowed) `now`.
|
||||
let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| wall_clock()) };
|
||||
let since = |t0: std::time::Duration| wall_clock().saturating_sub(t0);
|
||||
let mut sel = d.selection.clone();
|
||||
let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false };
|
||||
let r = f(&mut ctx)?;
|
||||
@@ -368,7 +371,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
|
||||
}
|
||||
if let Some(t0) = t0 {
|
||||
eprintln!("commit: edit {:?}, {} changed", t0.elapsed(), changed.len());
|
||||
eprintln!("commit: edit {:?}, {} changed", since(t0), changed.len());
|
||||
}
|
||||
if structural {
|
||||
d.calc.recalc_all(&mut wb);
|
||||
@@ -376,7 +379,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
d.calc.cells_changed(&mut wb, &changed);
|
||||
}
|
||||
if let Some(t0) = t0 {
|
||||
eprintln!("commit: + recalc {:?}", t0.elapsed());
|
||||
eprintln!("commit: + recalc {:?}", since(t0));
|
||||
}
|
||||
fit_rows.sort_unstable();
|
||||
fit_rows.dedup();
|
||||
@@ -384,7 +387,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
auto_row_height(&mut wb, si, row);
|
||||
}
|
||||
if let Some(t0) = t0 {
|
||||
eprintln!("commit: + rows {:?}", t0.elapsed());
|
||||
eprintln!("commit: + rows {:?}", since(t0));
|
||||
}
|
||||
d.wb = std::sync::Arc::new(wb);
|
||||
d.selection = sel;
|
||||
|
||||
@@ -14,7 +14,7 @@ use gridcraft_model::{Cell, NumFmt, Sheet};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::whatif::parse_range;
|
||||
use super::*;
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, edit, has_doc, str_param};
|
||||
|
||||
const CMD: &str = "data.analysisToolPak";
|
||||
/// Most input cells a tool reads.
|
||||
|
||||
@@ -4,10 +4,16 @@ use std::cmp::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value, sort_compare};
|
||||
use gridcraft_model::*;
|
||||
use gridcraft_model::{
|
||||
Anchor, AnchorMode, AutoFilter, Cell, CfOperator, Comment, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo, Sheet, Validation,
|
||||
ValidationKind, Workbook,
|
||||
};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, bool_param, cell_param, cell_param_on, cmd, edit, has_doc, ok, parse_lines, split_sheet,
|
||||
str_param, target_range, target_ranges, target_sheet,
|
||||
};
|
||||
use crate::outline;
|
||||
use crate::selection::current_region;
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::sort::sort_key;
|
||||
use super::*;
|
||||
use super::{
|
||||
AutoFilter, CellRef, EngineError, FilterCriterion, Json, Ordering, RangeRef, Result, Session, Sheet, Value, Workbook, bad, bool_param, col_param,
|
||||
current_region, data_target, edit, json,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- filter
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
CellRef, EngineError, Json, LineInfo, RangeRef, Result, Session, bad, bool_param, col_param, data_target, edit, json, outline, parse_lines,
|
||||
str_param, target_sheet,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- outline
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Anchor, AnchorMode, Arc, Cell, CellRef, Comment, EngineError, Formula, Hyperlink, Json, LineInfo, Ordering, RangeRef, Result, Session, Sheet,
|
||||
Value, bad, bool_param, col_param, data_range, edit, guess_header, json, ok, sort_compare, split_sheet, str_param, target_sheet,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SortKey {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Cell, CellRef, EngineError, Json, RangeRef, Result, Session, bad, bool_param, cell_param_on, col_param, data_target, edit, guess_header, json,
|
||||
ok, str_param, target_range, target_sheet,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- tools
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Cell, CellRef, CfOperator, EngineError, ErrorStyle, Json, RangeRef, Result, Session, Validation, ValidationKind, Value, Workbook, bad,
|
||||
bool_param, cell_param, edit, json, ok, str_param, target_ranges, target_sheet,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- validation
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Draw tab: pen and eraser tools, ink strokes, Ink to Shape.
|
||||
|
||||
use gridcraft_core::CellRef;
|
||||
use gridcraft_model::*;
|
||||
use gridcraft_model::{Anchor, Color, Shape, ShapeKind};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, ok, str_param, u32_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
||||
@@ -4,7 +4,10 @@ use gridcraft_core::{CellRef, MAX_ROWS, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, Formula, StyleId};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, bad, bool_param, can_redo, can_undo, cell_param, cell_param_on, cmd, edit, f64_param, has_clipboard, has_doc,
|
||||
locked_cell_error, ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param,
|
||||
};
|
||||
use crate::selection::{current_region, jump};
|
||||
use crate::{Clipboard, Session};
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Cell, CellRef, EngineError, Formula, Json, MAX_ROWS, RangeRef, Result, Session, StyleId, Value, bad, bool_param, cell_param_on, edit, json,
|
||||
locked_cell_error, str_param, target_range, target_ranges, target_sheet,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- entry
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::cell::input_to_cell;
|
||||
use super::paste_special::paste_special;
|
||||
use super::*;
|
||||
use super::{
|
||||
Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, cell_param_on, edit, json, ok, str_param, target_ranges, target_sheet,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- clipboard
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, edit, f64_param, json, str_param, target_range, target_sheet};
|
||||
|
||||
// ---------------------------------------------------------------- fill
|
||||
|
||||
@@ -78,7 +78,7 @@ pub(super) fn fill_series(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let stop = f64_param(p, "stop");
|
||||
let unit = str_param(p, "dateUnit").unwrap_or("day").to_string();
|
||||
edit(s, |cx| {
|
||||
crate::fill::series(cx, sheet, r, rows, &kind, step, stop, &unit)?;
|
||||
crate::fill::series(cx, sheet, crate::fill::SeriesOptions { range: r, rows, kind: &kind, step, stop, unit: &unit })?;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::cell::input_to_cell;
|
||||
use super::*;
|
||||
use super::{Cell, CellRef, EngineError, Json, Result, Session, Value, bad, bool_param, edit, json, str_param};
|
||||
|
||||
// ---------------------------------------------------------------- find & replace
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::selection::{select_region, selection_set};
|
||||
use super::*;
|
||||
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, bool_param, coalesce, json, split_sheet, str_param};
|
||||
|
||||
/// How many references the Go To dialog keeps for going back to.
|
||||
pub(super) const GO_TO_RECENT: usize = 10;
|
||||
|
||||
@@ -1,5 +1,76 @@
|
||||
use super::clipboard::What;
|
||||
use super::*;
|
||||
use super::{
|
||||
Cell, CellRef, EngineError, Formula, Json, RangeRef, Result, Session, StyleId, Value, bad, bool_param, cell_param_on, edit, json, str_param,
|
||||
target_sheet,
|
||||
};
|
||||
|
||||
fn tiled_cells(rows: u32, cols: u32, tiles_r: u32, tiles_c: u32) -> impl Iterator<Item = (u32, u32, u32, u32)> + Clone {
|
||||
(0..tiles_r).flat_map(move |tr| (0..tiles_c).flat_map(move |tc| (0..rows).flat_map(move |r| (0..cols).map(move |c| (tr, tc, r, c)))))
|
||||
}
|
||||
|
||||
fn apply_operation(op: &str, old: &Cell, new: &mut Cell) {
|
||||
let operand = new.value.clone();
|
||||
if let (Ok(x), Ok(y)) = (old.value.to_number(), operand.to_number())
|
||||
&& (old.value.is_number() || old.value.is_empty() || old.value.is_text())
|
||||
&& (operand.is_number() || operand.is_empty())
|
||||
{
|
||||
new.value = match op {
|
||||
"add" => Value::number(x + y),
|
||||
"subtract" => Value::number(x - y),
|
||||
"multiply" => Value::number(x * y),
|
||||
"divide" if y == 0.0 => Value::Error(gridcraft_core::CellError::Div0),
|
||||
"divide" => Value::number(x / y),
|
||||
_ => operand.clone(),
|
||||
};
|
||||
} else if (old.value.is_text() || old.value.is_error()) && (operand.is_number() || operand.is_empty()) {
|
||||
new.value = old.value.clone();
|
||||
}
|
||||
if let Some(formula) = &old.formula
|
||||
&& let Ok(y) = operand.to_number()
|
||||
&& (operand.is_number() || operand.is_empty())
|
||||
&& let Some(symbol) = operation_symbol(op)
|
||||
{
|
||||
let text = format!("=({}){symbol}{}", formula.text, gridcraft_core::number_to_text(y));
|
||||
new.formula = Some(std::sync::Arc::new(Formula::new(&text)));
|
||||
new.value = Value::Empty;
|
||||
}
|
||||
}
|
||||
|
||||
fn operation_symbol(op: &str) -> Option<char> {
|
||||
match op {
|
||||
"add" => Some('+'),
|
||||
"subtract" => Some('-'),
|
||||
"multiply" => Some('*'),
|
||||
"divide" => Some('/'),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_merges(
|
||||
cx: &mut crate::cmd::Ctx,
|
||||
dest_sheet: usize,
|
||||
src_sheet: &gridcraft_model::Sheet,
|
||||
src: RangeRef,
|
||||
at: CellRef,
|
||||
what: What,
|
||||
transpose: bool,
|
||||
) -> Result<()> {
|
||||
if !matches!(what, What::All | What::Formats) || transpose {
|
||||
return Ok(());
|
||||
}
|
||||
let merges: Vec<RangeRef> = src_sheet.merges.iter().filter(|merge| src.contains_range(merge)).copied().collect();
|
||||
for merge in merges {
|
||||
let row_offset = at.row as i64 - src.start.row as i64;
|
||||
let col_offset = at.col as i64 - src.start.col as i64;
|
||||
if let (Some(start), Some(end)) = (merge.start.offset(row_offset, col_offset), merge.end.offset(row_offset, col_offset)) {
|
||||
let moved = RangeRef::new(start, end);
|
||||
let sheet = cx.sheet_mut(dest_sheet)?;
|
||||
sheet.merges.retain(|existing| !existing.intersects(&moved));
|
||||
sheet.merges.push(moved);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) };
|
||||
@@ -74,209 +145,145 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
moved_formulas = taken;
|
||||
}
|
||||
for tr in 0..tiles_r {
|
||||
for tc in 0..tiles_c {
|
||||
for r in 0..src.height() {
|
||||
for c in 0..src.width() {
|
||||
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 };
|
||||
let src_cell = if cut_move {
|
||||
moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone())
|
||||
for (tr, tc, r, c) in tiled_cells(src.height(), src.width(), tiles_r, tiles_c) {
|
||||
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 };
|
||||
let src_cell = if cut_move { moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) } 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();
|
||||
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) {
|
||||
continue;
|
||||
}
|
||||
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
|
||||
let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned();
|
||||
let mut new_picture = old_picture.clone();
|
||||
let mut new = old.clone();
|
||||
let shift = |cell: &Cell| -> Option<std::sync::Arc<Formula>> {
|
||||
let f = cell.formula.as_ref()?;
|
||||
let e = f.expr()?;
|
||||
if cut_move {
|
||||
// A moved formula still reads cells on its original sheet. Qualify
|
||||
// those references before the workbook-wide move adjustment below.
|
||||
let e = if dest_sheet != clip.sheet {
|
||||
e.map(&mut |x| match x {
|
||||
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
|
||||
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
|
||||
gridcraft_formula::Expr::Ref(r)
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
} else {
|
||||
e
|
||||
};
|
||||
let mut moved = Formula::from_expr(e);
|
||||
// The array range moves with its formula.
|
||||
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
||||
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
|
||||
moved.dynamic = f.dynamic;
|
||||
return Some(std::sync::Arc::new(moved));
|
||||
}
|
||||
let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
||||
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
|
||||
};
|
||||
if link {
|
||||
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name))
|
||||
};
|
||||
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
|
||||
new.value = Value::Empty;
|
||||
new_picture = None;
|
||||
} else {
|
||||
let sc_cell = src_cell.clone().unwrap_or_default();
|
||||
match what {
|
||||
What::All | What::AllExceptBorders => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
let st = map_style(sc_cell.style, &mut cx.wb);
|
||||
new.style = if what == What::AllExceptBorders {
|
||||
let borders = cx.wb.styles.get(old.style).borders;
|
||||
cx.wb.styles.derive(st, |s| s.borders = borders)
|
||||
} else {
|
||||
src_sheet.cell(sc).cloned()
|
||||
st
|
||||
};
|
||||
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();
|
||||
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) {
|
||||
continue;
|
||||
}
|
||||
What::Formulas | What::FormulasAndNumberFormats => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
if what == What::FormulasAndNumberFormats {
|
||||
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
|
||||
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
|
||||
}
|
||||
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
|
||||
let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned();
|
||||
let mut new_picture = old_picture.clone();
|
||||
let mut new = old.clone();
|
||||
let shift = |cell: &Cell| -> Option<std::sync::Arc<Formula>> {
|
||||
let f = cell.formula.as_ref()?;
|
||||
let e = f.expr()?;
|
||||
if cut_move {
|
||||
// A moved formula still reads cells on its original sheet. Qualify
|
||||
// those references before the workbook-wide move adjustment below.
|
||||
let e = if dest_sheet != clip.sheet {
|
||||
e.map(&mut |x| match x {
|
||||
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
|
||||
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
|
||||
gridcraft_formula::Expr::Ref(r)
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
} else {
|
||||
e
|
||||
};
|
||||
let mut moved = Formula::from_expr(e);
|
||||
// The array range moves with its formula.
|
||||
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
||||
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
|
||||
moved.dynamic = f.dynamic;
|
||||
return Some(std::sync::Arc::new(moved));
|
||||
}
|
||||
let moved =
|
||||
gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
||||
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
|
||||
};
|
||||
if link {
|
||||
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name))
|
||||
};
|
||||
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
|
||||
new.value = Value::Empty;
|
||||
new_picture = None;
|
||||
} else {
|
||||
let sc_cell = src_cell.clone().unwrap_or_default();
|
||||
match what {
|
||||
What::All | What::AllExceptBorders => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
let st = map_style(sc_cell.style, &mut cx.wb);
|
||||
new.style = if what == What::AllExceptBorders {
|
||||
let borders = cx.wb.styles.get(old.style).borders;
|
||||
cx.wb.styles.derive(st, |s| s.borders = borders)
|
||||
} else {
|
||||
st
|
||||
};
|
||||
}
|
||||
What::Formulas | What::FormulasAndNumberFormats => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
if what == What::FormulasAndNumberFormats {
|
||||
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
|
||||
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
|
||||
}
|
||||
}
|
||||
What::Values | What::ValuesAndNumberFormats => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = None;
|
||||
new.value = src_val.clone();
|
||||
if what == What::ValuesAndNumberFormats {
|
||||
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
|
||||
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
|
||||
}
|
||||
}
|
||||
What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb),
|
||||
What::Comments | What::Validation | What::ColumnWidths => {}
|
||||
}
|
||||
// Operations combine numbers with what is already there.
|
||||
if op != "none"
|
||||
&& matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas)
|
||||
&& new.formula.is_none()
|
||||
{
|
||||
if old_picture.is_some() || new_picture.is_some() {
|
||||
new_picture = None;
|
||||
new.value = Value::Error(gridcraft_core::CellError::Value);
|
||||
}
|
||||
let a = old.value.clone();
|
||||
let b = new.value.clone();
|
||||
// Numbers stored as text take part (multiplying by 1 turns them into
|
||||
// numbers); other text and errors stay as they are.
|
||||
if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number())
|
||||
&& (a.is_number() || a.is_empty() || a.is_text())
|
||||
&& (b.is_number() || b.is_empty())
|
||||
{
|
||||
new.value = match op.as_str() {
|
||||
"add" => Value::number(x + y),
|
||||
"subtract" => Value::number(x - y),
|
||||
"multiply" => Value::number(x * y),
|
||||
"divide" => {
|
||||
if y == 0.0 {
|
||||
Value::Error(gridcraft_core::CellError::Div0)
|
||||
} else {
|
||||
Value::number(x / y)
|
||||
}
|
||||
}
|
||||
_ => b.clone(),
|
||||
};
|
||||
} else if (a.is_text() || a.is_error()) && (b.is_number() || b.is_empty()) {
|
||||
new.value = a;
|
||||
}
|
||||
// A formula already there is kept and combined with the pasted number.
|
||||
if let Some(f) = &old.formula
|
||||
&& let Ok(y) = b.to_number()
|
||||
&& (b.is_number() || b.is_empty())
|
||||
&& let Some(sym) = match op.as_str() {
|
||||
"add" => Some('+'),
|
||||
"subtract" => Some('-'),
|
||||
"multiply" => Some('*'),
|
||||
"divide" => Some('/'),
|
||||
_ => None,
|
||||
}
|
||||
{
|
||||
let text = format!("=({}){sym}{}", f.text, gridcraft_core::number_to_text(y));
|
||||
new.formula = Some(std::sync::Arc::new(Formula::new(&text)));
|
||||
new.value = Value::Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
What::Values | What::ValuesAndNumberFormats => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = None;
|
||||
new.value = src_val.clone();
|
||||
if what == What::ValuesAndNumberFormats {
|
||||
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
|
||||
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
|
||||
}
|
||||
// The copied cell's validation rule replaces whatever rule the destination had.
|
||||
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
|
||||
let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned();
|
||||
let sh = cx.sheet_mut(dest_sheet)?;
|
||||
crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]);
|
||||
if let Some(mut dv) = rule {
|
||||
// Relative references move with the rule, as pasted formulas do.
|
||||
let first = dv.ranges.first().map_or(sc, |r| r.start);
|
||||
dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest);
|
||||
dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest));
|
||||
dv.ranges = vec![RangeRef::new(dest, dest)];
|
||||
sh.validations.push(dv);
|
||||
}
|
||||
}
|
||||
match what {
|
||||
What::Comments => {
|
||||
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
|
||||
}
|
||||
}
|
||||
What::ColumnWidths => {
|
||||
if let Some(info) = src_sheet.cols.get(&sc.col).copied() {
|
||||
cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info);
|
||||
}
|
||||
}
|
||||
What::All => {
|
||||
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
|
||||
}
|
||||
if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h);
|
||||
}
|
||||
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
|
||||
}
|
||||
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
|
||||
}
|
||||
if let Some(picture) = new_picture {
|
||||
cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture);
|
||||
}
|
||||
cx.touch(dest_sheet, dest);
|
||||
}
|
||||
What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb),
|
||||
What::Comments | What::Validation | What::ColumnWidths => {}
|
||||
}
|
||||
// Operations combine numbers with what is already there.
|
||||
if op != "none" && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) && new.formula.is_none() {
|
||||
if old_picture.is_some() || new_picture.is_some() {
|
||||
new_picture = None;
|
||||
new.value = Value::Error(gridcraft_core::CellError::Value);
|
||||
}
|
||||
// Numbers stored as text participate; other text and errors stay as they are.
|
||||
apply_operation(&op, &old, &mut new);
|
||||
}
|
||||
}
|
||||
// The copied cell's validation rule replaces whatever rule the destination had.
|
||||
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
|
||||
let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned();
|
||||
let sh = cx.sheet_mut(dest_sheet)?;
|
||||
crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]);
|
||||
if let Some(mut dv) = rule {
|
||||
// Relative references move with the rule, as pasted formulas do.
|
||||
let first = dv.ranges.first().map_or(sc, |r| r.start);
|
||||
dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest);
|
||||
dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest));
|
||||
dv.ranges = vec![RangeRef::new(dest, dest)];
|
||||
sh.validations.push(dv);
|
||||
}
|
||||
}
|
||||
match what {
|
||||
What::Comments => {
|
||||
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
|
||||
}
|
||||
}
|
||||
What::ColumnWidths => {
|
||||
if let Some(info) = src_sheet.cols.get(&sc.col).copied() {
|
||||
cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info);
|
||||
}
|
||||
}
|
||||
What::All => {
|
||||
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
|
||||
}
|
||||
if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h);
|
||||
}
|
||||
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
|
||||
}
|
||||
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
|
||||
}
|
||||
if let Some(picture) = new_picture {
|
||||
cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture);
|
||||
}
|
||||
cx.touch(dest_sheet, dest);
|
||||
}
|
||||
// Merged areas inside the copied block travel with it.
|
||||
if matches!(what, What::All | What::Formats) && !transpose {
|
||||
let merges: Vec<RangeRef> = src_sheet.merges.iter().filter(|m| src.contains_range(m)).copied().collect();
|
||||
for m in merges {
|
||||
let dr = at.row as i64 - src.start.row as i64;
|
||||
let dc = at.col as i64 - src.start.col as i64;
|
||||
if let (Some(a), Some(b)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
|
||||
let nm = RangeRef::new(a, b);
|
||||
let sh = cx.sheet_mut(dest_sheet)?;
|
||||
sh.merges.retain(|x| !x.intersects(&nm));
|
||||
sh.merges.push(nm);
|
||||
}
|
||||
}
|
||||
}
|
||||
copy_merges(cx, dest_sheet, src_sheet, src, at, what, transpose)?;
|
||||
if cut_move {
|
||||
// References elsewhere to the moved block now point to its new place.
|
||||
let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
CellRef, EngineError, Json, RangeRef, Result, Session, bool_param, cell_param, current_region, json, jump, split_sheet, str_param, target_ranges,
|
||||
target_sheet,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- selection
|
||||
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
//! tools, Get Data, scripts (Automate), cell checkboxes, theme colours and fonts.
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::*;
|
||||
use gridcraft_model::{
|
||||
Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline, TrendlineKind,
|
||||
Workbook,
|
||||
};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, always, anchor_mode_name, bad, bool_param, cell_param, cell_param_on, cmd, edit, f64_param, has_doc,
|
||||
ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param,
|
||||
};
|
||||
|
||||
mod cells;
|
||||
mod chart;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
CellRef, EngineError, HAlign, Json, NumFmt, Result, Session, Value, bad, bool_param, cell_param, cell_param_on, edit, json, object_params, ok,
|
||||
str_param, target_ranges, target_sheet,
|
||||
};
|
||||
|
||||
/// Whether `c` has checkbox formatting.
|
||||
pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
CellRef, Chart, Color, EngineError, Json, LegendPos, RangeRef, Result, Session, Trendline, TrendlineKind, bad, bool_param, cell_param_on, edit,
|
||||
f64_param, json, split_sheet, str_param, u32_param,
|
||||
};
|
||||
|
||||
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
let d = s.doc()?;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, TableColumn, TotalsFn, Value, bad, edit, json, ok, str_param, target_range};
|
||||
|
||||
pub(super) fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::chart::format_series;
|
||||
use super::*;
|
||||
use super::{
|
||||
Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param, u32_param,
|
||||
};
|
||||
|
||||
pub(super) fn shape_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if str_param(p, "kind") == Some("shape") {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Json, Result, SCRIPTS_NAME, Session, Workbook, bad, bool_param, edit, json, stash_get, stash_map, stash_set, str_param};
|
||||
|
||||
pub(super) fn record(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
CellRef, EngineError, Json, Result, Session, SheetViewMode, VIEWS_NAME, WATCH_NAME, Workbook, bad, bool_param, edit, json, ok, split_sheet,
|
||||
stash_get, stash_map, stash_set, str_param, target_sheet, u32_param,
|
||||
};
|
||||
|
||||
/// Switches a sheet's view. Like zoom, the view is saved with the file but is not an edit: it
|
||||
/// neither records an undo step nor marks a clean workbook as changed.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_param, cmd, edit, has_doc, ok, str_param, target_sheet, u32_param};
|
||||
use crate::DocState;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -11,7 +11,7 @@ use gridcraft_functions::Timeline;
|
||||
use gridcraft_model::{Anchor, Cell, Chart, ChartKind, Formula, LegendPos, Series, Sheet, StyleId, Table, TableColumn, TotalsFn};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, split_sheet, str_param};
|
||||
use crate::selection::current_region;
|
||||
|
||||
const CMD: &str = "data.forecastSheet";
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
//! Formatting: Home › Font, Alignment, Number, Styles, Cells › Format.
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_model::*;
|
||||
use gridcraft_model::{BorderLine, BorderStyle, Borders, Color, Fill, HAlign, NumFmt, Style, StyleId, Theme, Underline, VAlign, VertAlign};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, ok, parse_lines, split_sheet, str_param,
|
||||
target_ranges, target_sheet,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
ACCOUNTING, BorderLine, BorderStyle, Borders, COMMA, Color, Fill, Json, NumFmt, Result, Session, Style, Theme, apply_style, bad, str_param,
|
||||
};
|
||||
|
||||
/// Built-in cell styles (our own palette).
|
||||
pub fn builtin_cell_style(name: &str, theme: &Theme) -> Option<Style> {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
CellRef, EngineError, Json, RangeRef, Result, Session, StyleId, bad, edit, f64_param, parse_lines, split_sheet, str_param, target_sheet,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- sizes
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ use gridcraft_core::{CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{CalcMode, Cell, DefinedName, Formula, Workbook};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, always, bad, bool_param, cell_param, cmd, edit, f64_param, has_doc, ok, stash, str_param,
|
||||
target_range, target_ranges, target_sheet, u32_param,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
||||
@@ -3,11 +3,18 @@
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef};
|
||||
use gridcraft_formula::adjust::{Axis, Edit};
|
||||
use gridcraft_model::*;
|
||||
use gridcraft_model::{
|
||||
Anchor, BorderLine, BorderStyle, Borders, Cell, CellPicture, CfOperator, CfRule, CfValueKind, Chart, ChartKind, Color, Comment, CondFormat, Fill,
|
||||
Formula, Hyperlink, Image, LegendPos, Series, Shape, ShapeKind, Sheet, Sparkline, SparklineKind, Style, Table, TableColumn, TotalsFn, Underline,
|
||||
Workbook,
|
||||
};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::sheet::{rewrite, shift_cell_features};
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_name, anchor_mode_param, bad, bool_param, cell_param, cell_param_on, cmd, edit,
|
||||
f64_param, has_doc, locked_cell_error, ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param,
|
||||
};
|
||||
use crate::selection::current_region;
|
||||
|
||||
mod chart;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Anchor, CellRef, Chart, ChartKind, EngineError, Json, LegendPos, RangeRef, Result, Series, Session, Sheet, anchor_mode_param, bad, block,
|
||||
bool_param, cell_param_on, edit, f64_param, json, split_sheet, str_param, target_ranges, target_sheet, u32_param,
|
||||
};
|
||||
|
||||
fn chart_areas(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
|
||||
if p.get("range").is_some() {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Anchor, BorderLine, BorderStyle, Borders, CfOperator, CfRule, CfValueKind, Color, CondFormat, EngineError, Fill, Json, Result, Session, Shape,
|
||||
ShapeKind, Style, anchor_mode_param, bad, bool_param, cell_param, edit, f64_param, json, ok, str_param, target_ranges, target_sheet, u32_param,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- conditional formatting
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Cell, Color, Comment, EngineError, Hyperlink, Json, RangeRef, Result, Session, Underline, Workbook, bool_param, cell_param_on, edit, json, ok,
|
||||
split_sheet, str_param, target_ranges, target_sheet,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- links & comments
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Anchor, CellPicture, CellRef, Color, EngineError, Image, Json, PROTECTED, Result, Session, Shape, ShapeKind, Sparkline, SparklineKind,
|
||||
anchor_mode_name, anchor_mode_param, bad, bool_param, cell_param, edit, f64_param, json, locked_cell_error, ok, str_param, u32_param,
|
||||
};
|
||||
|
||||
pub(super) fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let src = str_param(p, "range").ok_or_else(|| bad("insert.sparkline", "missing `range`"))?.to_string();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Axis, Cell, CellRef, Edit, EngineError, Formula, Json, MAX_ROWS, RangeRef, Result, Session, Sheet, Style, Table, TableColumn, TotalsFn, bad,
|
||||
block, bool_param, edit, json, rewrite, shift_cell_features, str_param, target_range, target_sheet, with,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- tables
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_param, cell_param_on, cmd, has_doc, str_param, target_range, target_sheet};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
||||
@@ -7,10 +7,16 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_model::*;
|
||||
use gridcraft_model::{
|
||||
Anchor, Cell, Chart, ChartKind, LegendPos, NumFmt, PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs,
|
||||
PivotSort, PivotTable, PivotValue, Sheet, StyleId, Workbook,
|
||||
};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, Ctx, EngineError, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, has_doc, ok, str_param,
|
||||
u32_param,
|
||||
};
|
||||
use crate::pivot as pv;
|
||||
use crate::selection::current_region;
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::create::{insert_pivot, source_param};
|
||||
use super::*;
|
||||
use super::{
|
||||
Anchor, CellRef, Chart, ChartKind, EngineError, Json, LegendPos, PivotDateGroup, Result, Session, Workbook, anchor_mode_param, bad, cell_param,
|
||||
edit, f64_param, find_pivot, json, other, pv, str_param,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- PivotChart
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::fields::{add_to_area, in_axes};
|
||||
use super::fields::{AreaPlacement, add_to_area, in_axes};
|
||||
use super::params::{func_name, func_param, group_name, group_param, layout_name, show_as_name, sort_name};
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, Cell, CellRef, Ctx, EngineError, Json, NumFmt, PivotDateGroup, PivotTable, RangeRef, Result, Session, Sheet, StyleId, Workbook, bad,
|
||||
bool_param, cell_param, current_region, edit, find_pivot, json, other, pv, str_param, with,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- insert
|
||||
|
||||
@@ -115,7 +118,9 @@ pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let add = |pt: &mut PivotTable, list: &[Json], area: &str| -> Result<()> {
|
||||
for f in list {
|
||||
match f {
|
||||
Json::String(n) => add_to_area("insert.pivotTable", pt, &src, n, area, None, None, None)?,
|
||||
Json::String(n) => {
|
||||
add_to_area(pt, &src, AreaPlacement { cmd: "insert.pivotTable", field: n, area, func: None, name: None, position: None })?
|
||||
}
|
||||
Json::Object(_) => {
|
||||
let n = str_param(f, "field").ok_or_else(|| bad("insert.pivotTable", "a field object needs `field`"))?;
|
||||
let func = match str_param(f, "func") {
|
||||
@@ -124,7 +129,11 @@ pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
add_to_area("insert.pivotTable", pt, &src, n, area, func, str_param(f, "name"), None)?;
|
||||
add_to_area(
|
||||
pt,
|
||||
&src,
|
||||
AreaPlacement { cmd: "insert.pivotTable", field: n, area, func, name: str_param(f, "name"), position: None },
|
||||
)?;
|
||||
let by = group_param(str_param(f, "group"));
|
||||
if let Some(by) = by.filter(|b| *b != PivotDateGroup::None)
|
||||
&& matches!(area, "rows" | "columns")
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::create::pivot_json;
|
||||
use super::params::{area_param, func_param, position_param, show_as_param};
|
||||
use super::*;
|
||||
use super::{
|
||||
Json, PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotShowAs, PivotTable, PivotValue, Result, Session, Workbook, bad,
|
||||
bool_param, edit, find_pivot, json, ok, other, pv, str_param, u32_param,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- layout editing (pure)
|
||||
|
||||
@@ -104,16 +107,17 @@ fn auto_name(f: PivotFunc, field: &str) -> String {
|
||||
}
|
||||
|
||||
/// Adds `field` to an area (moving it out of the other axis areas).
|
||||
pub(super) fn add_to_area(
|
||||
cmd: &str,
|
||||
pt: &mut PivotTable,
|
||||
src: &pv::Source,
|
||||
field: &str,
|
||||
area: &str,
|
||||
func: Option<PivotFunc>,
|
||||
name: Option<&str>,
|
||||
position: Option<usize>,
|
||||
) -> Result<()> {
|
||||
pub(super) struct AreaPlacement<'a> {
|
||||
pub cmd: &'a str,
|
||||
pub field: &'a str,
|
||||
pub area: &'a str,
|
||||
pub func: Option<PivotFunc>,
|
||||
pub name: Option<&'a str>,
|
||||
pub position: Option<usize>,
|
||||
}
|
||||
|
||||
pub(super) fn add_to_area(pt: &mut PivotTable, src: &pv::Source, placement: AreaPlacement<'_>) -> Result<()> {
|
||||
let AreaPlacement { cmd, field, area, func, name, position } = placement;
|
||||
let is_calc = src.col(field).is_none() && calc_field(pt, field).is_some();
|
||||
if is_calc && area != "values" {
|
||||
return Err(other("A calculated field can only be placed in the Values area."));
|
||||
@@ -300,7 +304,7 @@ pub(super) fn add_field(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = str_param(p, "name").map(str::to_string);
|
||||
let pos = position_param(p);
|
||||
update(s, p, |pt, src, _| {
|
||||
add_to_area(C, pt, src, &field, area, func, name.as_deref(), pos)?;
|
||||
add_to_area(pt, src, AreaPlacement { cmd: C, field: &field, area, func, name: name.as_deref(), position: pos })?;
|
||||
Ok(json!({"field": canonical_any(C, pt, src, &field)?, "area": area}))
|
||||
})
|
||||
}
|
||||
@@ -364,7 +368,7 @@ pub(super) fn calculated_field(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(bad(C, format!("`{canon}` refers to itself through its formula")));
|
||||
}
|
||||
if existing.is_none() && to_values && !pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&canon)) {
|
||||
add_to_area(C, pt, src, &canon, "values", Some(PivotFunc::Sum), None, None)?;
|
||||
add_to_area(pt, src, AreaPlacement { cmd: C, field: &canon, area: "values", func: Some(PivotFunc::Sum), name: None, position: None })?;
|
||||
}
|
||||
Ok(json!({
|
||||
"name": canon,
|
||||
@@ -452,7 +456,7 @@ pub(super) fn move_field(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let kept = pt.rows.iter().chain(pt.columns.iter()).find(|f| f.source_col.eq_ignore_ascii_case(&field)).cloned();
|
||||
let hidden = pt.filters.iter().find(|f| f.source_col.eq_ignore_ascii_case(&field)).cloned();
|
||||
let src_field = remove_from_area(C, pt, &field, from)?;
|
||||
add_to_area(C, pt, src, &src_field, to, None, None, pos)?;
|
||||
add_to_area(pt, src, AreaPlacement { cmd: C, field: &src_field, area: to, func: None, name: None, position: pos })?;
|
||||
if matches!(to, "rows" | "columns") {
|
||||
let list = if to == "rows" { &mut pt.rows } else { &mut pt.columns };
|
||||
if let (Some(k), Some(slot)) = (kept, list.iter_mut().find(|f| f.source_col.eq_ignore_ascii_case(&src_field))) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use super::create::source_param;
|
||||
use super::fields::{canonical, in_axes, shift_values, update};
|
||||
use super::params::{group_param, layout_name};
|
||||
use super::*;
|
||||
use super::{
|
||||
Chart, Json, PivotDateGroup, PivotField, PivotFilter, PivotLayout, PivotSort, PivotTable, Result, Session, bad, bool_param, edit, find_pivot,
|
||||
json, other, pv, str_param,
|
||||
};
|
||||
|
||||
pub(super) fn filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const C: &str = "pivot.filter";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Json, PivotDateGroup, PivotFunc, PivotLayout, PivotShowAs, PivotSort, Result, bad, u32_param};
|
||||
|
||||
pub(super) fn area_param(cmd: &str, v: Option<&str>) -> Result<&'static str> {
|
||||
match v.map(str::to_ascii_lowercase) {
|
||||
|
||||
@@ -243,6 +243,70 @@ fn compact_vs_tabular_shapes() {
|
||||
assert_eq!(text(&s, "A5"), "Apples");
|
||||
}
|
||||
|
||||
fn assert_values_on_rows_default(s: &Session) {
|
||||
let p = pt(s);
|
||||
assert!(p.values_on_rows);
|
||||
assert_eq!(p.values_position, None);
|
||||
let r = p.last_range.unwrap();
|
||||
assert_eq!(r.width(), 2);
|
||||
// header + 3 × (region + 2 values) + 2 grand totals
|
||||
assert_eq!(r.height(), 1 + 9 + 2);
|
||||
assert_eq!(text(s, "A4"), "East");
|
||||
assert_eq!(val(s, "B4"), Value::Empty);
|
||||
assert_eq!(text(s, "A5"), "Sum of Sales");
|
||||
assert_eq!(val(s, "B5"), Value::Number(415.0));
|
||||
assert_eq!(text(s, "A6"), "Sum of Units");
|
||||
assert_eq!(val(s, "B6"), Value::Number(33.0));
|
||||
assert_eq!(text(s, "A13"), "Total Sum of Sales");
|
||||
assert_eq!(val(s, "B13"), Value::Number(TOTAL));
|
||||
assert_eq!(text(s, "A14"), "Total Sum of Units");
|
||||
assert_eq!(val(s, "B14"), Value::Number(72.0));
|
||||
}
|
||||
|
||||
fn assert_values_on_rows_tabular(s: &mut Session) {
|
||||
// Tabular, no column field: "Total" heads the data column, as Excel saves it.
|
||||
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
|
||||
assert_eq!(text(s, "A3"), "Region");
|
||||
assert_eq!(text(s, "B3"), "Values");
|
||||
assert_eq!(text(s, "C3"), "Total");
|
||||
}
|
||||
|
||||
fn assert_values_on_rows_position(s: &mut Session) {
|
||||
s.execute("pivot.layout", json!({"layout": "compact"})).unwrap();
|
||||
// Σ Values before Region: the value fields are the outer items.
|
||||
s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap();
|
||||
assert_eq!(pt(s).values_position, Some(0));
|
||||
assert_eq!(text(s, "A4"), "Sum of Sales");
|
||||
assert_eq!(val(s, "B4"), Value::Empty);
|
||||
assert_eq!(text(s, "A5"), "East");
|
||||
assert_eq!(val(s, "B5"), Value::Number(415.0));
|
||||
assert_eq!(text(s, "A8"), "Sum of Units");
|
||||
assert_eq!(text(s, "A9"), "East");
|
||||
assert_eq!(val(s, "B9"), Value::Number(33.0));
|
||||
assert_eq!(text(s, "A12"), "Total Sum of Sales");
|
||||
assert_eq!(val(s, "B13"), Value::Number(72.0));
|
||||
// Adding a row field in front keeps Σ Values ahead of Region.
|
||||
s.execute("pivot.addField", json!({"field": "Product", "area": "rows", "position": 0})).unwrap();
|
||||
assert_eq!(pt(s).values_position, Some(1));
|
||||
s.execute("pivot.removeField", json!({"field": "Product", "area": "rows"})).unwrap();
|
||||
assert_eq!(pt(s).values_position, Some(0));
|
||||
// No row field: one line per value field and no grand total.
|
||||
s.execute("pivot.removeField", json!({"field": "Region", "area": "rows"})).unwrap();
|
||||
let r = pt(s).last_range.unwrap();
|
||||
assert_eq!(r.height(), 1 + 2);
|
||||
assert_eq!(text(s, "A4"), "Sum of Sales");
|
||||
assert_eq!(val(s, "B4"), Value::Number(TOTAL));
|
||||
assert_eq!(text(s, "A5"), "Sum of Units");
|
||||
assert_eq!(val(s, "B5"), Value::Number(72.0));
|
||||
// Back to Columns.
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).unwrap();
|
||||
assert!(!pt(s).values_on_rows);
|
||||
assert_eq!(text(s, "A3"), "Sum of Sales");
|
||||
assert_eq!(text(s, "B3"), "Sum of Units");
|
||||
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "filters"})).is_err());
|
||||
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_on_rows_layouts() {
|
||||
let mut s = sales();
|
||||
@@ -250,61 +314,9 @@ fn values_on_rows_layouts() {
|
||||
assert_eq!(text(&s, "C3"), "Sum of Units");
|
||||
// Σ Values after Region: each region holds one line per value field.
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
|
||||
let p = pt(&s);
|
||||
assert!(p.values_on_rows);
|
||||
assert_eq!(p.values_position, None);
|
||||
let r = p.last_range.unwrap();
|
||||
assert_eq!(r.width(), 2);
|
||||
// header + 3 × (region + 2 values) + 2 grand totals
|
||||
assert_eq!(r.height(), 1 + 9 + 2);
|
||||
assert_eq!(text(&s, "A4"), "East");
|
||||
assert_eq!(val(&s, "B4"), Value::Empty);
|
||||
assert_eq!(text(&s, "A5"), "Sum of Sales");
|
||||
assert_eq!(val(&s, "B5"), Value::Number(415.0));
|
||||
assert_eq!(text(&s, "A6"), "Sum of Units");
|
||||
assert_eq!(val(&s, "B6"), Value::Number(33.0));
|
||||
assert_eq!(text(&s, "A13"), "Total Sum of Sales");
|
||||
assert_eq!(val(&s, "B13"), Value::Number(TOTAL));
|
||||
assert_eq!(text(&s, "A14"), "Total Sum of Units");
|
||||
assert_eq!(val(&s, "B14"), Value::Number(72.0));
|
||||
// Tabular, no column field: "Total" heads the data column, as Excel saves it.
|
||||
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
|
||||
assert_eq!(text(&s, "A3"), "Region");
|
||||
assert_eq!(text(&s, "B3"), "Values");
|
||||
assert_eq!(text(&s, "C3"), "Total");
|
||||
s.execute("pivot.layout", json!({"layout": "compact"})).unwrap();
|
||||
// Σ Values before Region: the value fields are the outer items.
|
||||
s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap();
|
||||
assert_eq!(pt(&s).values_position, Some(0));
|
||||
assert_eq!(text(&s, "A4"), "Sum of Sales");
|
||||
assert_eq!(val(&s, "B4"), Value::Empty);
|
||||
assert_eq!(text(&s, "A5"), "East");
|
||||
assert_eq!(val(&s, "B5"), Value::Number(415.0));
|
||||
assert_eq!(text(&s, "A8"), "Sum of Units");
|
||||
assert_eq!(text(&s, "A9"), "East");
|
||||
assert_eq!(val(&s, "B9"), Value::Number(33.0));
|
||||
assert_eq!(text(&s, "A12"), "Total Sum of Sales");
|
||||
assert_eq!(val(&s, "B13"), Value::Number(72.0));
|
||||
// Adding a row field in front keeps Σ Values ahead of Region.
|
||||
s.execute("pivot.addField", json!({"field": "Product", "area": "rows", "position": 0})).unwrap();
|
||||
assert_eq!(pt(&s).values_position, Some(1));
|
||||
s.execute("pivot.removeField", json!({"field": "Product", "area": "rows"})).unwrap();
|
||||
assert_eq!(pt(&s).values_position, Some(0));
|
||||
// No row field: one line per value field and no grand total.
|
||||
s.execute("pivot.removeField", json!({"field": "Region", "area": "rows"})).unwrap();
|
||||
let r = pt(&s).last_range.unwrap();
|
||||
assert_eq!(r.height(), 1 + 2);
|
||||
assert_eq!(text(&s, "A4"), "Sum of Sales");
|
||||
assert_eq!(val(&s, "B4"), Value::Number(TOTAL));
|
||||
assert_eq!(text(&s, "A5"), "Sum of Units");
|
||||
assert_eq!(val(&s, "B5"), Value::Number(72.0));
|
||||
// Back to Columns.
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).unwrap();
|
||||
assert!(!pt(&s).values_on_rows);
|
||||
assert_eq!(text(&s, "A3"), "Sum of Sales");
|
||||
assert_eq!(text(&s, "B3"), "Sum of Units");
|
||||
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "filters"})).is_err());
|
||||
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err());
|
||||
assert_values_on_rows_default(&s);
|
||||
assert_values_on_rows_tabular(&mut s);
|
||||
assert_values_on_rows_position(&mut s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -355,53 +367,65 @@ fn change_source_keeps_values_ahead_of_its_field() {
|
||||
assert_eq!(text(&s, "A5"), "Apples");
|
||||
}
|
||||
|
||||
fn assert_values_between_compact_rows(s: &Session) {
|
||||
// Compact: East, then per value field its products, then per-value subtotals of East.
|
||||
assert_eq!(text(s, "A4"), "East");
|
||||
assert_eq!(text(s, "A5"), "Sum of Sales");
|
||||
assert_eq!(text(s, "A6"), "Apples");
|
||||
assert_eq!(val(s, "B6"), Value::Number(160.0));
|
||||
assert_eq!(text(s, "A9"), "Sum of Units");
|
||||
assert_eq!(text(s, "A10"), "Apples");
|
||||
assert_eq!(val(s, "B10"), Value::Number(16.0));
|
||||
assert_eq!(text(s, "A13"), "East Sum of Sales");
|
||||
assert_eq!(val(s, "B13"), Value::Number(415.0));
|
||||
assert_eq!(text(s, "A14"), "East Sum of Units");
|
||||
assert_eq!(val(s, "B14"), Value::Number(33.0));
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1);
|
||||
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A6").unwrap())).align.indent, 2);
|
||||
}
|
||||
|
||||
fn assert_values_between_outline(s: &mut Session) {
|
||||
// Outline: one column per level, "Values" heads the Σ Values column.
|
||||
s.execute("pivot.layout", json!({"layout": "outline"})).unwrap();
|
||||
assert_eq!(pt(s).last_range.unwrap().width(), 4);
|
||||
assert_eq!(text(s, "A3"), "Region");
|
||||
assert_eq!(text(s, "B3"), "Values");
|
||||
assert_eq!(text(s, "C3"), "Product");
|
||||
assert_eq!(text(s, "B5"), "Sum of Sales");
|
||||
assert_eq!(text(s, "C6"), "Apples");
|
||||
assert_eq!(val(s, "D6"), Value::Number(160.0));
|
||||
}
|
||||
|
||||
fn assert_values_between_tabular(s: &mut Session) {
|
||||
// Tabular: labels start on their parent's row; totals per value field below each group.
|
||||
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
|
||||
assert_eq!(text(s, "A4"), "East");
|
||||
assert_eq!(text(s, "B4"), "Sum of Sales");
|
||||
assert_eq!(text(s, "C4"), "Apples");
|
||||
assert_eq!(val(s, "D4"), Value::Number(160.0));
|
||||
assert_eq!(text(s, "B7"), "Sum of Units");
|
||||
assert_eq!(text(s, "C7"), "Apples");
|
||||
assert_eq!(val(s, "D7"), Value::Number(16.0));
|
||||
assert_eq!(text(s, "A10"), "East Sum of Sales");
|
||||
assert_eq!(val(s, "D10"), Value::Number(415.0));
|
||||
assert_eq!(text(s, "A11"), "East Sum of Units");
|
||||
let r = pt(s).last_range.unwrap();
|
||||
let last = r.end.row + 1;
|
||||
assert_eq!(text(s, &format!("A{}", last - 1)), "Total Sum of Sales");
|
||||
assert_eq!(text(s, &format!("A{last}")), "Total Sum of Units");
|
||||
assert_eq!(val(s, &format!("D{last}")), Value::Number(72.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_on_rows_between_row_fields() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap();
|
||||
// Compact: East, then per value field its products, then per-value subtotals of East.
|
||||
assert_eq!(text(&s, "A4"), "East");
|
||||
assert_eq!(text(&s, "A5"), "Sum of Sales");
|
||||
assert_eq!(text(&s, "A6"), "Apples");
|
||||
assert_eq!(val(&s, "B6"), Value::Number(160.0));
|
||||
assert_eq!(text(&s, "A9"), "Sum of Units");
|
||||
assert_eq!(text(&s, "A10"), "Apples");
|
||||
assert_eq!(val(&s, "B10"), Value::Number(16.0));
|
||||
assert_eq!(text(&s, "A13"), "East Sum of Sales");
|
||||
assert_eq!(val(&s, "B13"), Value::Number(415.0));
|
||||
assert_eq!(text(&s, "A14"), "East Sum of Units");
|
||||
assert_eq!(val(&s, "B14"), Value::Number(33.0));
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1);
|
||||
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A6").unwrap())).align.indent, 2);
|
||||
// Outline: one column per level, "Values" heads the Σ Values column.
|
||||
s.execute("pivot.layout", json!({"layout": "outline"})).unwrap();
|
||||
assert_eq!(pt(&s).last_range.unwrap().width(), 4);
|
||||
assert_eq!(text(&s, "A3"), "Region");
|
||||
assert_eq!(text(&s, "B3"), "Values");
|
||||
assert_eq!(text(&s, "C3"), "Product");
|
||||
assert_eq!(text(&s, "B5"), "Sum of Sales");
|
||||
assert_eq!(text(&s, "C6"), "Apples");
|
||||
assert_eq!(val(&s, "D6"), Value::Number(160.0));
|
||||
// Tabular: labels start on their parent's row; totals per value field below each group.
|
||||
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
|
||||
assert_eq!(text(&s, "A4"), "East");
|
||||
assert_eq!(text(&s, "B4"), "Sum of Sales");
|
||||
assert_eq!(text(&s, "C4"), "Apples");
|
||||
assert_eq!(val(&s, "D4"), Value::Number(160.0));
|
||||
assert_eq!(text(&s, "B7"), "Sum of Units");
|
||||
assert_eq!(text(&s, "C7"), "Apples");
|
||||
assert_eq!(val(&s, "D7"), Value::Number(16.0));
|
||||
assert_eq!(text(&s, "A10"), "East Sum of Sales");
|
||||
assert_eq!(val(&s, "D10"), Value::Number(415.0));
|
||||
assert_eq!(text(&s, "A11"), "East Sum of Units");
|
||||
let r = pt(&s).last_range.unwrap();
|
||||
let last = r.end.row + 1;
|
||||
assert_eq!(text(&s, &format!("A{}", last - 1)), "Total Sum of Sales");
|
||||
assert_eq!(text(&s, &format!("A{last}")), "Total Sum of Units");
|
||||
assert_eq!(val(&s, &format!("D{last}")), Value::Number(72.0));
|
||||
assert_values_between_compact_rows(&s);
|
||||
assert_values_between_outline(&mut s);
|
||||
assert_values_between_tabular(&mut s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,11 +10,16 @@
|
||||
//! codes.
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
|
||||
use gridcraft_model::*;
|
||||
use gridcraft_pdf::{Font, Page, PdfDoc, Rgb};
|
||||
#[cfg(test)]
|
||||
use gridcraft_model::{Alignment, Image, SheetViewMode};
|
||||
use gridcraft_model::{
|
||||
Anchor, BorderLine, BorderStyle, Borders, Color, HAlign, Orientation, PatternType, PrintSettings, ShapeKind, Sheet, Style, StyleId, StyleTable,
|
||||
Underline, VAlign, Visibility, Workbook,
|
||||
};
|
||||
use gridcraft_pdf::{Font, Page, PdfDoc, Point, Rect, Rgb};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, split_sheet, str_param, target_sheet, u32_param};
|
||||
|
||||
mod draw;
|
||||
mod header_footer;
|
||||
@@ -27,6 +32,9 @@ use header_footer::draw_header_footer;
|
||||
pub use header_footer::{
|
||||
HF_FIELDS, HfContext, HfSection, codes_to_fields, expand_header_footer, fields_to_codes, hf_date_time, join_header_footer, split_header_footer,
|
||||
};
|
||||
/// Decoded pictures (bytes, width, height) keyed by (sheet, picture); `None` = undecodable.
|
||||
type PicCache = std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>>;
|
||||
|
||||
#[cfg(test)]
|
||||
use layout::bands;
|
||||
pub(crate) use layout::split_bands;
|
||||
@@ -94,7 +102,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
let mut printed_blocks = Vec::new();
|
||||
// Decoded pictures are reused across the pages their anchor covers: a floating picture
|
||||
// spanning 40 pages was decoded (and pushed into the PDF) 40 times (#2389).
|
||||
let mut pics: std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>> = std::collections::HashMap::new();
|
||||
let mut pics = PicCache::new();
|
||||
for lay in layouts {
|
||||
let Some(sh) = wb.sheet(lay.sheet) else { continue };
|
||||
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
|
||||
@@ -116,7 +124,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
for (rsi, rs) in rsegs.iter().enumerate() {
|
||||
let mut x = ox + lay.heading_w * k;
|
||||
for (csi, cs) in csegs.iter().enumerate() {
|
||||
let b = make_block(wb, lay.sheet, sh, *rs, *cs, x, y, lay);
|
||||
let b = make_block(wb, lay.sheet, sh, *rs, *cs, (x, y), lay);
|
||||
draw_block(pg, &b, lay.settings.gridlines, &mut cf);
|
||||
draw_block_links(pg, &b, page_index, &mut pending_links);
|
||||
printed_blocks.push(PrintedBlock { page: page_index, sheet: lay.sheet, rows: *rs, cols: *cs, x, y, k });
|
||||
@@ -155,7 +163,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
&& let Some(page) = doc.page_mut(link.page)
|
||||
{
|
||||
let (lx, ly, w, h) = link.rect;
|
||||
page.link_page(lx, ly, w, h, target_page, x, y);
|
||||
page.link_page(Rect::new(lx, ly, w, h), target_page, Point::new(x, y));
|
||||
}
|
||||
}
|
||||
let n = doc.page_count();
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use super::layout::{DENSE_BLOCK, SheetLayout};
|
||||
use super::objects::draw_cell_picture;
|
||||
use super::text::draw_text;
|
||||
use super::*;
|
||||
use super::text::{TextPlacement, draw_text};
|
||||
use super::{
|
||||
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Workbook,
|
||||
split_sheet,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- drawing
|
||||
|
||||
@@ -151,7 +154,7 @@ pub(super) fn draw_prims(pg: &mut Page, prims: &[gridcraft_chart::Prim], xf: Xf)
|
||||
};
|
||||
let (sn, cs) = if rotation.is_finite() { rotation.sin_cos() } else { (0.0, 1.0) };
|
||||
let (ax, ay) = xf.p(*x, *y);
|
||||
pg.text_rotated(ax + dx * cs - dy * sn, ay + dx * sn + dy * cs, size, font, blend(*color), text, *rotation);
|
||||
pg.text_rotated(Point::new(ax + dx * cs - dy * sn, ay + dx * sn + dy * cs), size, font, blend(*color), text, *rotation);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,10 +210,11 @@ fn border_dash(style: BorderStyle, k: f32) -> Vec<f32> {
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, x0: f32, y0: f32, x1: f32, y1: f32, k: f32) {
|
||||
fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, start: Point, end: Point, k: f32) {
|
||||
if b.is_none() {
|
||||
return;
|
||||
}
|
||||
let (x0, y0, x1, y1) = (start.x, start.y, end.x, end.y);
|
||||
let color = rgb_of(b.color, wb).unwrap_or([0, 0, 0]);
|
||||
if b.style == BorderStyle::Double {
|
||||
let off = 1.0 * k;
|
||||
@@ -221,7 +225,7 @@ fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, x0: f32, y0: f32, x
|
||||
return;
|
||||
}
|
||||
let lw = (b.style.width() * k).clamp(0.25, 6.0);
|
||||
pg.stroke_line_dashed(x0, y0, x1, y1, lw, color, &border_dash(b.style, k));
|
||||
pg.stroke_line_dashed(Point::new(x0, y0), Point::new(x1, y1), lw, color, &border_dash(b.style, k));
|
||||
}
|
||||
|
||||
/// Draws the cells of a block: fills, gridlines, data bars, borders and text.
|
||||
@@ -324,12 +328,12 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|
||||
),
|
||||
None => (bd.right, bd.bottom),
|
||||
};
|
||||
draw_border(pg, &bd.top, b.wb, x, y, x + w, y, b.k);
|
||||
draw_border(pg, &bottom, b.wb, x, y + h, x + w, y + h, b.k);
|
||||
draw_border(pg, &bd.left, b.wb, x, y, x, y + h, b.k);
|
||||
draw_border(pg, &right, b.wb, x + w, y, x + w, y + h, b.k);
|
||||
draw_border(pg, &bd.diag_down, b.wb, x, y, x + w, y + h, b.k);
|
||||
draw_border(pg, &bd.diag_up, b.wb, x, y + h, x + w, y, b.k);
|
||||
draw_border(pg, &bd.top, b.wb, Point::new(x, y), Point::new(x + w, y), b.k);
|
||||
draw_border(pg, &bottom, b.wb, Point::new(x, y + h), Point::new(x + w, y + h), b.k);
|
||||
draw_border(pg, &bd.left, b.wb, Point::new(x, y), Point::new(x, y + h), b.k);
|
||||
draw_border(pg, &right, b.wb, Point::new(x + w, y), Point::new(x + w, y + h), b.k);
|
||||
draw_border(pg, &bd.diag_down, b.wb, Point::new(x, y), Point::new(x + w, y + h), b.k);
|
||||
draw_border(pg, &bd.diag_up, b.wb, Point::new(x, y + h), Point::new(x + w, y), b.k);
|
||||
}
|
||||
// Text.
|
||||
for (c, l) in &looks {
|
||||
@@ -340,7 +344,7 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|
||||
if let Some(picture) = sh.cell_pictures.get(c) {
|
||||
draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);
|
||||
} else {
|
||||
draw_text(pg, b, *c, l, (x, y, w, h), merged, &merges, (bx, bx + bw));
|
||||
draw_text(pg, b, TextPlacement { cell: *c, look: l, rect: (x, y, w, h), merged, merges: &merges, limits: (bx, bx + bw) });
|
||||
}
|
||||
}
|
||||
pg.restore();
|
||||
@@ -444,10 +448,10 @@ pub(super) fn make_block<'a>(
|
||||
sh: &'a Sheet,
|
||||
rows: (u32, u32),
|
||||
cols: (u32, u32),
|
||||
x: f32,
|
||||
y: f32,
|
||||
origin: (f32, f32),
|
||||
lay: &SheetLayout,
|
||||
) -> Block<'a> {
|
||||
let (x, y) = origin;
|
||||
let k = lay.k();
|
||||
let mut xs = Vec::with_capacity((cols.1 - cols.0 + 2) as usize);
|
||||
let mut acc = x;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::layout::SheetLayout;
|
||||
use super::*;
|
||||
use super::{Font, Page, Value, Workbook};
|
||||
|
||||
/// Side margin of headers and footers, in points, when Align with Page Margins is off.
|
||||
pub(super) const HF_FIXED_SIDE_MARGIN: f32 = 0.7 * 72.0;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::header_footer::HF_FIXED_SIDE_MARGIN;
|
||||
use super::*;
|
||||
use super::{
|
||||
Anchor, CellRef, Json, MAX_COLS, MAX_PAGES, MAX_ROWS, Orientation, PX_TO_PT, PrintSettings, RangeRef, Result, Sheet, Visibility, Workbook, bad,
|
||||
bool_param, split_sheet, str_param,
|
||||
};
|
||||
|
||||
/// Cells in a page block above which only stored cells are visited (not every grid position).
|
||||
pub(super) const DENSE_BLOCK: u64 = 60_000;
|
||||
@@ -287,10 +290,13 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid
|
||||
Some(lay)
|
||||
}
|
||||
|
||||
/// Inclusive-start/end index spans of the pages along one axis.
|
||||
pub(crate) type Bands = Vec<(u32, u32)>;
|
||||
|
||||
/// Splits a layout's area into row and column bands that fit its pages (at most `cap` of each),
|
||||
/// honouring manual breaks (when `lay.manual_breaks`), print headings and repeated titles. The
|
||||
/// one pagination rule shared by printing, PDF export, Page Break Preview and Page Layout view.
|
||||
pub(crate) fn split_bands(sh: &Sheet, lay: &SheetLayout, cap: usize) -> (Vec<(u32, u32)>, Vec<(u32, u32)>) {
|
||||
pub(crate) fn split_bands(sh: &Sheet, lay: &SheetLayout, cap: usize) -> (Bands, Bands) {
|
||||
let k = lay.k();
|
||||
let (aw, ah) = lay.avail();
|
||||
let (aw, ah) = (aw / k, ah / k);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::draw::{Block, PdfMeasure, Xf, draw_prims, rgb_of};
|
||||
use super::text::wrap_lines;
|
||||
use super::*;
|
||||
use super::{Anchor, Font, Page, RangeRef, Rect, ShapeKind, Sheet, Value, Workbook};
|
||||
|
||||
/// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio.
|
||||
pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) {
|
||||
@@ -25,7 +25,7 @@ pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32
|
||||
image.thumbnail(((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32).to_rgba8();
|
||||
let fit = (w / iw as f32).min(h / ih as f32);
|
||||
let (pw, ph) = (iw as f32 * fit, ih as f32 * fit);
|
||||
pg.image_rgba(x + pad + (w - pw) / 2.0, y + pad + (h - ph) / 2.0, pw, ph, image.as_raw(), image.width(), image.height());
|
||||
pg.image_rgba(Rect::new(x + pad + (w - pw) / 2.0, y + pad + (h - ph) / 2.0, pw, ph), image.as_raw(), [image.width(), image.height()]);
|
||||
}
|
||||
|
||||
/// Sheet pixel rectangle of an anchor.
|
||||
@@ -49,14 +49,7 @@ fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec<u8>, u32, u3
|
||||
/// Draws charts, pictures, shapes and sparklines overlapping the body block of a page.
|
||||
/// `pics` memoizes decoded pictures for the whole render (one page at a time draws each
|
||||
/// picture again, but the decode happens once).
|
||||
pub(super) fn draw_objects(
|
||||
pg: &mut Page,
|
||||
wb: &Workbook,
|
||||
si: usize,
|
||||
sh: &Sheet,
|
||||
b: &Block,
|
||||
pics: &mut std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>>,
|
||||
) {
|
||||
pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block, pics: &mut super::PicCache) {
|
||||
let block = b.range();
|
||||
let (sx0, sy0) = (sh.col_left(block.start.col), sh.row_top(block.start.row));
|
||||
let (sx1, sy1) = (sh.col_left(block.end.col.saturating_add(1)), sh.row_top(block.end.row.saturating_add(1)));
|
||||
@@ -77,7 +70,7 @@ pub(super) fn draw_objects(
|
||||
let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32);
|
||||
let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh));
|
||||
if let Some((px, pw, ph)) = decoded.as_ref() {
|
||||
pg.image_rgba(x, y, w, h, px, *pw, *ph);
|
||||
pg.image_rgba(Rect::new(x, y, w, h), px, [*pw, *ph]);
|
||||
}
|
||||
}
|
||||
for shp in &sh.shapes {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::draw::{Block, Look, rgb_of};
|
||||
use super::*;
|
||||
use super::{CellRef, Color, Font, HAlign, MAX_COLS, Page, Point, RangeRef, Sheet, Style, StyleTable, Underline, VAlign, Value};
|
||||
|
||||
const PAD_PX: f32 = 2.0;
|
||||
|
||||
@@ -51,11 +51,55 @@ fn font_for(style: &Style) -> Font {
|
||||
Font::styled(Font::family_for(&style.font.name), style.font.bold, style.font.italic)
|
||||
}
|
||||
|
||||
fn text_align(align: HAlign, value: &Value) -> HAlign {
|
||||
match align {
|
||||
HAlign::General => match value {
|
||||
Value::Number(_) => HAlign::Right,
|
||||
Value::Bool(_) | Value::Error(_) => HAlign::Center,
|
||||
_ => HAlign::Left,
|
||||
},
|
||||
HAlign::CenterAcross | HAlign::Distributed => HAlign::Center,
|
||||
HAlign::Justify | HAlign::Fill => HAlign::Left,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is a cell free for text to overflow into?
|
||||
fn empty_for_overflow(sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> bool {
|
||||
sh.value_ref(c).is_none_or(|v| v.is_empty()) && !merges.iter().any(|m| m.contains(c))
|
||||
}
|
||||
|
||||
fn extend_text_bounds(sh: &Sheet, c: CellRef, merges: &[RangeRef], k: f32, need: f32, align: HAlign, bounds: (f32, f32)) -> (f32, f32) {
|
||||
let (mut clip_l, mut clip_r) = bounds;
|
||||
let grow = |dir: i64, mut remaining: f32| -> f32 {
|
||||
let mut ext = 0.0;
|
||||
let mut col = c.col as i64 + dir;
|
||||
while remaining > 0.0 && col >= 0 && col < MAX_COLS as i64 {
|
||||
let nc = CellRef::new(c.row, col as u32);
|
||||
if !empty_for_overflow(sh, nc, merges) {
|
||||
break;
|
||||
}
|
||||
let width = sh.col_width(nc.col) * k;
|
||||
ext += width;
|
||||
remaining -= width;
|
||||
col += dir;
|
||||
if ext > 20_000.0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ext
|
||||
};
|
||||
match align {
|
||||
HAlign::Right => clip_l -= grow(-1, need),
|
||||
HAlign::Center => {
|
||||
clip_l -= grow(-1, need / 2.0);
|
||||
clip_r += grow(1, need / 2.0);
|
||||
}
|
||||
_ => clip_r += grow(1, need),
|
||||
}
|
||||
(clip_l, clip_r)
|
||||
}
|
||||
|
||||
/// Unscaled width of the empty Center Across Selection cells right of `c`, which its text is centred over too.
|
||||
pub(super) fn center_across_width(styles: &StyleTable, sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> f32 {
|
||||
let mut ext = 0.0;
|
||||
@@ -71,16 +115,17 @@ pub(super) fn center_across_width(styles: &StyleTable, sh: &Sheet, c: CellRef, m
|
||||
ext
|
||||
}
|
||||
|
||||
pub(super) fn draw_text(
|
||||
pg: &mut Page,
|
||||
b: &Block,
|
||||
c: CellRef,
|
||||
l: &Look,
|
||||
rect: (f32, f32, f32, f32),
|
||||
merged: bool,
|
||||
merges: &[RangeRef],
|
||||
limits: (f32, f32),
|
||||
) {
|
||||
pub(super) struct TextPlacement<'a> {
|
||||
pub cell: CellRef,
|
||||
pub look: &'a Look,
|
||||
pub rect: (f32, f32, f32, f32),
|
||||
pub merged: bool,
|
||||
pub merges: &'a [RangeRef],
|
||||
pub limits: (f32, f32),
|
||||
}
|
||||
|
||||
pub(super) fn draw_text(pg: &mut Page, b: &Block, placement: TextPlacement<'_>) {
|
||||
let TextPlacement { cell: c, look: l, rect, merged, merges, limits } = placement;
|
||||
let (x, y, w, h) = rect;
|
||||
let sh = b.sh;
|
||||
let wb = b.wb;
|
||||
@@ -133,20 +178,7 @@ pub(super) fn draw_text(
|
||||
text = "#".repeat(((avail / hash).floor().max(1.0) as usize).min(255));
|
||||
tw = gridcraft_pdf::text_width(font, size, &text);
|
||||
}
|
||||
let h_align = match st.align.h {
|
||||
HAlign::General => {
|
||||
if is_num {
|
||||
HAlign::Right
|
||||
} else if matches!(v, Value::Bool(_) | Value::Error(_)) {
|
||||
HAlign::Center
|
||||
} else {
|
||||
HAlign::Left
|
||||
}
|
||||
}
|
||||
HAlign::CenterAcross | HAlign::Distributed => HAlign::Center,
|
||||
HAlign::Justify | HAlign::Fill => HAlign::Left,
|
||||
other => other,
|
||||
};
|
||||
let h_align = text_align(st.align.h, &v);
|
||||
let rot = st.align.rotation;
|
||||
let rotated = rot != 0 && rot != 255;
|
||||
// Rotated text wraps along its own direction: upright (±90°) text to the row height.
|
||||
@@ -170,32 +202,7 @@ pub(super) fn draw_text(
|
||||
let (mut clip_l, mut clip_r) = (x, x + w + across);
|
||||
if !wrap && !merged && !is_num && rot.abs() != 90 && across <= 0.0 && tw + 2.0 * pad + indent > w {
|
||||
let need = tw + 2.0 * pad + indent - w;
|
||||
let grow = |dir: i64, mut need: f32| -> f32 {
|
||||
let mut ext = 0.0;
|
||||
let mut col = c.col as i64 + dir;
|
||||
while need > 0.0 && col >= 0 && col < MAX_COLS as i64 {
|
||||
let nc = CellRef::new(c.row, col as u32);
|
||||
if !empty_for_overflow(sh, nc, merges) {
|
||||
break;
|
||||
}
|
||||
let cw = sh.col_width(nc.col) * b.k;
|
||||
ext += cw;
|
||||
need -= cw;
|
||||
col += dir;
|
||||
if ext > 20_000.0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ext
|
||||
};
|
||||
match h_align {
|
||||
HAlign::Right => clip_l -= grow(-1, need),
|
||||
HAlign::Center => {
|
||||
clip_l -= grow(-1, need / 2.0);
|
||||
clip_r += grow(1, need / 2.0);
|
||||
}
|
||||
_ => clip_r += grow(1, need),
|
||||
}
|
||||
(clip_l, clip_r) = extend_text_bounds(sh, c, merges, b.k, need, h_align, (clip_l, clip_r));
|
||||
}
|
||||
let clip_l = clip_l.max(limits.0);
|
||||
let clip_r = clip_r.min(limits.1);
|
||||
@@ -215,7 +222,7 @@ pub(super) fn draw_text(
|
||||
} else {
|
||||
(x + pad + (last - i) * line_h, y + pad + size * 0.8)
|
||||
};
|
||||
pg.text_rotated(ox, oy, size, font, color, t, angle);
|
||||
pg.text_rotated(Point::new(ox, oy), size, font, color, t, angle);
|
||||
}
|
||||
pg.restore();
|
||||
return;
|
||||
|
||||
@@ -5,7 +5,7 @@ use gridcraft_core::CellRef;
|
||||
use gridcraft_model::{EditRange, PasswordHash, SheetProtection, Visibility, password_hash};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, str_param, target_ranges, target_sheet};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Arc, CellRef, Ctx, EngineError, Json, Result, Session, Sheet, Visibility, bad, bool_param, edit, json, ok, str_param, target_sheet};
|
||||
|
||||
pub(super) fn insert_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if s.doc()?.wb.protected_structure {
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
//! `table.insertSlicer`) selects everything, i.e. applies no filter.
|
||||
|
||||
use gridcraft_core::CellRef;
|
||||
use gridcraft_model::*;
|
||||
use gridcraft_model::{Anchor, AnchorMode, AutoFilter, FilterCriterion, Slicer, Workbook};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, bool_param, cell_param_on, cmd, edit, f64_param, has_doc, str_param, target_sheet, u32_param,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
||||
@@ -97,9 +97,17 @@ struct Solve {
|
||||
evo_end: Option<EvoEnd>,
|
||||
}
|
||||
|
||||
/// Variable bounds and integrality flags.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Bounds<'a> {
|
||||
lo: &'a [f64],
|
||||
hi: &'a [f64],
|
||||
integer: &'a [bool],
|
||||
}
|
||||
|
||||
/// Runs the model's method for `goal` within `lo..hi` from `x0`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn optimise(ev: &mut Evaluator, m: &Model, goal: Goal, x0: &[f64], lo: &[f64], hi: &[f64], integer: &[bool], gap: f64) -> Solve {
|
||||
fn optimise(ev: &mut Evaluator, m: &Model, goal: Goal, x0: &[f64], bounds: Bounds, gap: f64) -> Solve {
|
||||
let Bounds { lo, hi, integer } = bounds;
|
||||
let any_int = integer.iter().any(|b| *b);
|
||||
let mut not_linear = false;
|
||||
let mut evo_end = None;
|
||||
@@ -203,7 +211,8 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let Solve { outcome, not_linear, evo_end } = optimise(&mut ev, &model, model.goal, &x0, &prob.lo, &prob.hi, &prob.integer, gap);
|
||||
let Solve { outcome, not_linear, evo_end } =
|
||||
optimise(&mut ev, &model, model.goal, &x0, Bounds { lo: &prob.lo, hi: &prob.hi, integer: &prob.integer }, gap);
|
||||
let (status, message, solution) = match outcome {
|
||||
_ if not_linear => ("notLinear", "The model isn't linear in the variable cells, so Simplex LP can't solve it. Try GRG Nonlinear.", None),
|
||||
Outcome::Solved(x, _, true) if model.method == Method::Evolutionary => match evo_end {
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::lp::{Outcome, Row, Sense};
|
||||
use super::model::{EvoOptions, Goal};
|
||||
use super::nlp::{project, shape, violation};
|
||||
use super::problem::Evaluator;
|
||||
use super::*;
|
||||
use super::{MAX_POPULATION, MIN_POPULATION, PRECISION};
|
||||
|
||||
// ---------------------------------------------------------------- evolutionary
|
||||
|
||||
|
||||
+199
-155
@@ -1,8 +1,13 @@
|
||||
use super::problem::Evaluator;
|
||||
use super::*;
|
||||
use super::{CellRef, EPS};
|
||||
|
||||
// ---------------------------------------------------------------- evaluation
|
||||
|
||||
type BoundRow = (Vec<(usize, f64)>, Sense, f64);
|
||||
|
||||
/// Objective (gradient, constant) and per-row (gradient, constant).
|
||||
pub(super) type Linearized = ((Vec<f64>, f64), Vec<(Vec<f64>, f64)>);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) enum Sense {
|
||||
Le,
|
||||
@@ -29,6 +34,188 @@ pub(super) enum Outcome {
|
||||
Limit,
|
||||
}
|
||||
|
||||
struct Map {
|
||||
var: usize,
|
||||
sign: f64,
|
||||
}
|
||||
|
||||
struct VariableMap {
|
||||
columns: Vec<Map>,
|
||||
offset: Vec<f64>,
|
||||
bound_rows: Vec<BoundRow>,
|
||||
}
|
||||
|
||||
struct Tableau {
|
||||
rows: Vec<Vec<f64>>,
|
||||
basis: Vec<usize>,
|
||||
width: usize,
|
||||
decision_cols: usize,
|
||||
slack_cols: usize,
|
||||
artificial_cols: usize,
|
||||
}
|
||||
|
||||
fn map_variables(n: usize, lo: &[f64], hi: &[f64]) -> VariableMap {
|
||||
let mut columns = Vec::new();
|
||||
let mut offset = vec![0.0; n];
|
||||
let mut bound_rows = Vec::new();
|
||||
for j in 0..n {
|
||||
let lower = lo.get(j).copied().unwrap_or(f64::NEG_INFINITY);
|
||||
let upper = hi.get(j).copied().unwrap_or(f64::INFINITY);
|
||||
if lower.is_finite() {
|
||||
if let Some(value) = offset.get_mut(j) {
|
||||
*value = lower;
|
||||
}
|
||||
columns.push(Map { var: j, sign: 1.0 });
|
||||
if upper.is_finite() {
|
||||
bound_rows.push((vec![(columns.len() - 1, 1.0)], Sense::Le, upper - lower));
|
||||
}
|
||||
} else if upper.is_finite() {
|
||||
if let Some(value) = offset.get_mut(j) {
|
||||
*value = upper;
|
||||
}
|
||||
columns.push(Map { var: j, sign: -1.0 });
|
||||
} else {
|
||||
columns.push(Map { var: j, sign: 1.0 });
|
||||
columns.push(Map { var: j, sign: -1.0 });
|
||||
}
|
||||
}
|
||||
VariableMap { columns, offset, bound_rows }
|
||||
}
|
||||
|
||||
fn normalize_rows(rows: &[(Vec<f64>, Sense, f64)], bound_rows: Vec<BoundRow>, columns: &[Map], offset: &[f64]) -> Vec<(Vec<f64>, Sense, f64)> {
|
||||
let width = columns.len();
|
||||
let mut transformed = Vec::with_capacity(rows.len() + bound_rows.len());
|
||||
for (coefficients, sense, rhs) in rows {
|
||||
let mut row = vec![0.0; width];
|
||||
for (index, mapping) in columns.iter().enumerate() {
|
||||
if let Some(value) = row.get_mut(index) {
|
||||
*value = coefficients.get(mapping.var).copied().unwrap_or(0.0) * mapping.sign;
|
||||
}
|
||||
}
|
||||
let shift: f64 = coefficients.iter().zip(offset).map(|(coefficient, base)| coefficient * base).sum();
|
||||
transformed.push((row, *sense, rhs - shift));
|
||||
}
|
||||
for (entries, sense, rhs) in bound_rows {
|
||||
let mut row = vec![0.0; width];
|
||||
for (index, value) in entries {
|
||||
if let Some(slot) = row.get_mut(index) {
|
||||
*slot = value;
|
||||
}
|
||||
}
|
||||
transformed.push((row, sense, rhs));
|
||||
}
|
||||
// Rows with a negative right side are negated so every right side is >= 0.
|
||||
transformed
|
||||
.into_iter()
|
||||
.map(|(row, sense, rhs)| {
|
||||
if rhs < 0.0 {
|
||||
let sense = match sense {
|
||||
Sense::Le => Sense::Ge,
|
||||
Sense::Ge => Sense::Le,
|
||||
Sense::Eq => Sense::Eq,
|
||||
};
|
||||
(row.iter().map(|v| -v).collect(), sense, -rhs)
|
||||
} else {
|
||||
(row, sense, rhs)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_tableau(normalized: &[(Vec<f64>, Sense, f64)], decision_cols: usize, slack_cols: usize, artificial_cols: usize) -> Tableau {
|
||||
let width = decision_cols + slack_cols + artificial_cols;
|
||||
let mut rows = Vec::with_capacity(normalized.len());
|
||||
let mut basis = vec![0usize; normalized.len()];
|
||||
let (mut slack, mut artificial) = (decision_cols, decision_cols + slack_cols);
|
||||
for (i, (coefficients, sense, rhs)) in normalized.iter().enumerate() {
|
||||
let mut row = vec![0.0; width + 1];
|
||||
for (value, coefficient) in row.iter_mut().zip(coefficients) {
|
||||
*value = *coefficient;
|
||||
}
|
||||
let basic = match sense {
|
||||
Sense::Le => {
|
||||
if let Some(value) = row.get_mut(slack) {
|
||||
*value = 1.0;
|
||||
}
|
||||
let basic = slack;
|
||||
slack += 1;
|
||||
basic
|
||||
}
|
||||
Sense::Ge => {
|
||||
if let Some(value) = row.get_mut(slack) {
|
||||
*value = -1.0;
|
||||
}
|
||||
slack += 1;
|
||||
if let Some(value) = row.get_mut(artificial) {
|
||||
*value = 1.0;
|
||||
}
|
||||
let basic = artificial;
|
||||
artificial += 1;
|
||||
basic
|
||||
}
|
||||
Sense::Eq => {
|
||||
if let Some(value) = row.get_mut(artificial) {
|
||||
*value = 1.0;
|
||||
}
|
||||
let basic = artificial;
|
||||
artificial += 1;
|
||||
basic
|
||||
}
|
||||
};
|
||||
if let Some(value) = row.get_mut(width) {
|
||||
*value = *rhs;
|
||||
}
|
||||
if let Some(slot) = basis.get_mut(i) {
|
||||
*slot = basic;
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
Tableau { rows, basis, width, decision_cols, slack_cols, artificial_cols }
|
||||
}
|
||||
|
||||
fn phase_one(tableau: &mut Tableau, normalized: &[(Vec<f64>, Sense, f64)], budget: &mut u64) -> Result<(), Outcome> {
|
||||
if tableau.artificial_cols == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let mut objective = vec![0.0; tableau.width + 1];
|
||||
for column in tableau.decision_cols + tableau.slack_cols..tableau.width {
|
||||
if let Some(value) = objective.get_mut(column) {
|
||||
*value = 1.0;
|
||||
}
|
||||
}
|
||||
for (i, row) in tableau.rows.iter().enumerate() {
|
||||
if tableau.basis.get(i).is_some_and(|basic| *basic >= tableau.decision_cols + tableau.slack_cols) {
|
||||
for (value, coefficient) in objective.iter_mut().zip(row) {
|
||||
*value -= coefficient;
|
||||
}
|
||||
}
|
||||
}
|
||||
let all = vec![true; tableau.width];
|
||||
if matches!(simplex(&mut tableau.rows, &mut tableau.basis, &mut objective, &all, budget), Step::Limit) {
|
||||
return Err(Outcome::Limit);
|
||||
}
|
||||
let scale = normalized.iter().map(|row| row.2.abs()).fold(1.0f64, f64::max);
|
||||
if -objective.get(tableau.width).copied().unwrap_or(0.0) > 1e-7 * scale {
|
||||
return Err(Outcome::Infeasible);
|
||||
}
|
||||
for i in 0..tableau.rows.len() {
|
||||
if !tableau.basis.get(i).is_some_and(|basic| *basic >= tableau.decision_cols + tableau.slack_cols) {
|
||||
continue;
|
||||
}
|
||||
let pivot_col = tableau
|
||||
.rows
|
||||
.get(i)
|
||||
.and_then(|row| (0..tableau.decision_cols + tableau.slack_cols).find(|&column| row.get(column).is_some_and(|value| value.abs() > EPS)));
|
||||
if let Some(column) = pivot_col {
|
||||
pivot(&mut tableau.rows, &mut objective, i, column);
|
||||
if let Some(basic) = tableau.basis.get_mut(i) {
|
||||
*basic = column;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Depth-first branch and bound over a relaxation solver; returns the best integer point.
|
||||
pub(super) fn branch_and_bound(
|
||||
lo: &[f64],
|
||||
@@ -201,165 +388,23 @@ fn simplex(t: &mut [Vec<f64>], basis: &mut [usize], obj: &mut [f64], allowed: &[
|
||||
|
||||
/// Minimises `c·x` subject to `rows` (coefficients, sense, rhs) and `lo ≤ x ≤ hi`.
|
||||
pub(super) fn solve_lp(c: &[f64], rows: &[(Vec<f64>, Sense, f64)], lo: &[f64], hi: &[f64], budget: &mut u64) -> Outcome {
|
||||
let n = c.len();
|
||||
if lo.iter().zip(hi).any(|(l, h)| l > h) {
|
||||
return Outcome::Infeasible;
|
||||
}
|
||||
// x_j = offset + Σ sign·y_k with y ≥ 0.
|
||||
struct Map {
|
||||
var: usize,
|
||||
sign: f64,
|
||||
}
|
||||
let mut cols: Vec<Map> = Vec::new();
|
||||
let mut offset = vec![0.0; n];
|
||||
let mut extra: Vec<(Vec<(usize, f64)>, Sense, f64)> = Vec::new();
|
||||
for j in 0..n {
|
||||
let (l, h) = (lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), hi.get(j).copied().unwrap_or(f64::INFINITY));
|
||||
if l.is_finite() {
|
||||
if let Some(o) = offset.get_mut(j) {
|
||||
*o = l;
|
||||
}
|
||||
cols.push(Map { var: j, sign: 1.0 });
|
||||
if h.is_finite() {
|
||||
extra.push((vec![(cols.len() - 1, 1.0)], Sense::Le, h - l));
|
||||
}
|
||||
} else if h.is_finite() {
|
||||
if let Some(o) = offset.get_mut(j) {
|
||||
*o = h;
|
||||
}
|
||||
cols.push(Map { var: j, sign: -1.0 });
|
||||
} else {
|
||||
cols.push(Map { var: j, sign: 1.0 });
|
||||
cols.push(Map { var: j, sign: -1.0 });
|
||||
}
|
||||
}
|
||||
let mapping = map_variables(c.len(), lo, hi);
|
||||
let VariableMap { columns: cols, offset, bound_rows: extra } = mapping;
|
||||
let ny = cols.len();
|
||||
// Rows over y: (coefficients, sense, rhs).
|
||||
let mut yrows: Vec<(Vec<f64>, Sense, f64)> = Vec::with_capacity(rows.len() + extra.len());
|
||||
for (a, sense, b) in rows {
|
||||
let mut coef = vec![0.0; ny];
|
||||
for (k, m) in cols.iter().enumerate() {
|
||||
if let Some(v) = coef.get_mut(k) {
|
||||
*v = a.get(m.var).copied().unwrap_or(0.0) * m.sign;
|
||||
}
|
||||
}
|
||||
let shift: f64 = a.iter().zip(&offset).map(|(x, o)| x * o).sum();
|
||||
yrows.push((coef, *sense, b - shift));
|
||||
}
|
||||
for (entries, sense, b) in extra {
|
||||
let mut coef = vec![0.0; ny];
|
||||
for (k, v) in entries {
|
||||
if let Some(e) = coef.get_mut(k) {
|
||||
*e = v;
|
||||
}
|
||||
}
|
||||
yrows.push((coef, sense, b));
|
||||
}
|
||||
let m = yrows.len();
|
||||
let n_slack = yrows.iter().filter(|r| r.1 != Sense::Eq).count();
|
||||
// Rows with b < 0 are negated so every right side is ≥ 0.
|
||||
let normalized: Vec<(Vec<f64>, Sense, f64)> = yrows
|
||||
.into_iter()
|
||||
.map(|(a, s, b)| {
|
||||
if b < 0.0 {
|
||||
let s = match s {
|
||||
Sense::Le => Sense::Ge,
|
||||
Sense::Ge => Sense::Le,
|
||||
Sense::Eq => Sense::Eq,
|
||||
};
|
||||
(a.iter().map(|v| -v).collect(), s, -b)
|
||||
} else {
|
||||
(a, s, b)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let n_art = normalized.iter().filter(|r| r.1 != Sense::Le).count();
|
||||
let width = ny + n_slack + n_art;
|
||||
let mut t: Vec<Vec<f64>> = Vec::with_capacity(m);
|
||||
let mut basis = vec![0usize; m];
|
||||
let (mut si, mut ai) = (ny, ny + n_slack);
|
||||
for (i, (a, s, b)) in normalized.iter().enumerate() {
|
||||
let mut row = vec![0.0; width + 1];
|
||||
for (v, x) in row.iter_mut().zip(a) {
|
||||
*v = *x;
|
||||
}
|
||||
match s {
|
||||
Sense::Le => {
|
||||
if let Some(v) = row.get_mut(si) {
|
||||
*v = 1.0;
|
||||
}
|
||||
if let Some(bi) = basis.get_mut(i) {
|
||||
*bi = si;
|
||||
}
|
||||
si += 1;
|
||||
}
|
||||
Sense::Ge => {
|
||||
if let Some(v) = row.get_mut(si) {
|
||||
*v = -1.0;
|
||||
}
|
||||
si += 1;
|
||||
if let Some(v) = row.get_mut(ai) {
|
||||
*v = 1.0;
|
||||
}
|
||||
if let Some(bi) = basis.get_mut(i) {
|
||||
*bi = ai;
|
||||
}
|
||||
ai += 1;
|
||||
}
|
||||
Sense::Eq => {
|
||||
if let Some(v) = row.get_mut(ai) {
|
||||
*v = 1.0;
|
||||
}
|
||||
if let Some(bi) = basis.get_mut(i) {
|
||||
*bi = ai;
|
||||
}
|
||||
ai += 1;
|
||||
}
|
||||
}
|
||||
if let Some(v) = row.get_mut(width) {
|
||||
*v = *b;
|
||||
}
|
||||
t.push(row);
|
||||
}
|
||||
let is_art = |j: usize| j >= ny + n_slack && j < width;
|
||||
// Phase 1: minimise the sum of the artificials.
|
||||
let mut obj = vec![0.0; width + 1];
|
||||
for j in (ny + n_slack)..width {
|
||||
if let Some(v) = obj.get_mut(j) {
|
||||
*v = 1.0;
|
||||
}
|
||||
}
|
||||
for (i, row) in t.iter().enumerate() {
|
||||
if basis.get(i).is_some_and(|b| is_art(*b)) {
|
||||
for (v, x) in obj.iter_mut().zip(row) {
|
||||
*v -= x;
|
||||
}
|
||||
}
|
||||
}
|
||||
let all: Vec<bool> = vec![true; width];
|
||||
if n_art > 0 {
|
||||
match simplex(&mut t, &mut basis, &mut obj, &all, budget) {
|
||||
Step::Limit => return Outcome::Limit,
|
||||
Step::Unbounded | Step::Optimal => {}
|
||||
}
|
||||
let scale = normalized.iter().map(|r| r.2.abs()).fold(1.0f64, f64::max);
|
||||
if -obj.get(width).copied().unwrap_or(0.0) > 1e-7 * scale {
|
||||
return Outcome::Infeasible;
|
||||
}
|
||||
// Drive zero-valued artificials out of the basis where possible.
|
||||
for i in 0..m {
|
||||
if !basis.get(i).is_some_and(|b| is_art(*b)) {
|
||||
continue;
|
||||
}
|
||||
let j = t.get(i).and_then(|row| (0..ny + n_slack).find(|&j| row.get(j).is_some_and(|v| v.abs() > EPS)));
|
||||
if let Some(j) = j {
|
||||
pivot(&mut t, &mut obj, i, j);
|
||||
if let Some(b) = basis.get_mut(i) {
|
||||
*b = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
let normalized = normalize_rows(rows, extra, &cols, &offset);
|
||||
let n_slack = normalized.iter().filter(|row| row.1 != Sense::Eq).count();
|
||||
let n_art = normalized.iter().filter(|row| row.1 != Sense::Le).count();
|
||||
let mut tableau = build_tableau(&normalized, ny, n_slack, n_art);
|
||||
if let Err(outcome) = phase_one(&mut tableau, &normalized, budget) {
|
||||
return outcome;
|
||||
}
|
||||
let Tableau { rows: mut t, mut basis, width, decision_cols: ny, slack_cols: n_slack, .. } = tableau;
|
||||
let is_art = |column: usize| column >= ny + n_slack && column < width;
|
||||
// Phase 2: the real costs; artificials may not re-enter.
|
||||
let mut cost = vec![0.0; width + 1];
|
||||
for (k, mp) in cols.iter().enumerate() {
|
||||
@@ -402,8 +447,7 @@ pub(super) fn solve_lp(c: &[f64], rows: &[(Vec<f64>, Sense, f64)], lo: &[f64], h
|
||||
|
||||
/// Linearises the model at `x0` (n + 1 evaluations) and checks it at one more point. Returns the
|
||||
/// objective gradient and constant, and per row the gradient and constant of `lhs − rhs`.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn linearize(ev: &mut Evaluator, x0: &[f64]) -> std::result::Result<((Vec<f64>, f64), Vec<(Vec<f64>, f64)>), &'static str> {
|
||||
pub(super) fn linearize(ev: &mut Evaluator, x0: &[f64]) -> std::result::Result<Linearized, &'static str> {
|
||||
let n = x0.len();
|
||||
let p0 = ev.eval(x0).ok_or("budget")?;
|
||||
if !p0.f.is_finite() || p0.g.iter().any(|v| !v.is_finite()) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
CMD, CellRef, DefinedName, EngineError, Json, MAX_CONSTRAINTS, MAX_POPULATION, MAX_VARS, MIN_POPULATION, RangeRef, Result, Session, Workbook,
|
||||
bad, bool_param, f64_param, json, split_sheet, str_param,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- model
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::PRECISION;
|
||||
use super::lp::{Outcome, Row, Sense};
|
||||
use super::model::Goal;
|
||||
use super::problem::{Evaluator, Point};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- nonlinear
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::lp::{Row, Sense};
|
||||
use super::model::{Model, Rel, Rhs};
|
||||
use super::*;
|
||||
use super::{CellRef, Trial, Value};
|
||||
|
||||
/// The values the solvers see at a point: the objective and `lhs − rhs` per row.
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use super::lp::Outcome;
|
||||
use super::model::{Constraint, Goal, Method, Model, Rel, Rhs, parse_model, saved_model};
|
||||
use super::problem::{Evaluator, Problem, as_num, build_problem};
|
||||
use super::*;
|
||||
use super::{
|
||||
Cell, CellRef, Ctx, DEFAULT_EVALS, DocState, EngineError, Json, MAX_EVALS, PRECISION, REPORT_CMD, Result, Session, Sheet, Trial, Value, bad,
|
||||
edit, json, optimise, str_param,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- reports
|
||||
|
||||
@@ -226,7 +229,7 @@ fn limits_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result<Lines> {
|
||||
ev.target = Some(j);
|
||||
ev.evals = 0;
|
||||
ev.budget = per;
|
||||
let solve = optimise(&mut ev, m, goal, x, &lo, &hi, &inp.prob.integer, 0.0);
|
||||
let solve = optimise(&mut ev, m, goal, x, super::Bounds { lo: &lo, hi: &hi, integer: &inp.prob.integer }, 0.0);
|
||||
ends.push(match solve.outcome {
|
||||
Outcome::Solved(p, _, _) => {
|
||||
let v = p.get(j).copied().unwrap_or(f64::NAN);
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::sync::OnceLock;
|
||||
use gridcraft_core::{RangeRef, Value};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cell_param, cmd, has_doc, ok, str_param, target_range, target_sheet};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
//! View tab (sheet view state that's saved with the file) and Page Layout settings.
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_core::RangeRef;
|
||||
use gridcraft_model::Orientation;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, always, bad, bool_param, cell_param, cell_param_on, cmd, edit, f64_param, has_doc, str_param,
|
||||
target_range, target_sheet, u32_param,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -493,9 +496,6 @@ fn theme(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn unused(_: CellRef) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -14,7 +14,7 @@ use gridcraft_model::{CalcMode, Cell, DefinedName, Sheet, StyleId, Workbook};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, split_sheet, str_param};
|
||||
|
||||
mod advanced_filter;
|
||||
mod consolidate;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Cell, CellRef, EngineError, HashSet, Json, RangeRef, Result, Session, Value, bad, bool_param, edit, json, parse_range, str_param};
|
||||
|
||||
// ---------------------------------------------------------------- advanced filter
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user