diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 17f0fa7e7..7704a4b1d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -37,6 +37,16 @@ jobs: # which invokes `protoc` at build time. Required for the ADBC test path. run: sudo apt-get install -y protobuf-compiler + - name: Install png writer system libraries + # The png writer renders via wgpu/Vello, which needs a Vulkan + # adapter. ubuntu-latest has no GPU, so install Mesa's lavapipe software + # device. If no adapter is found anyway, the writer test skips its render + # assertion gracefully rather than failing. + # Text layout goes through parley/fontique, which links the system + # fontconfig on Linux to enumerate fonts, so its development files + # (fontconfig.pc plus headers) must be present at build time. + run: sudo apt-get install -y mesa-vulkan-drivers libfontconfig1-dev + - name: Install Rust # 1.86 is the MSRV (declared as `rust-version` in /Cargo.toml, see # /CLAUDE.md); this sets it as the default toolchain so plain `cargo` @@ -98,6 +108,13 @@ jobs: - name: Run ADBC SQLite equivalence tests run: cargo +stable test --features "adbc sqlite" --lib -- --ignored equivalence + - name: Run png writer tests + # Non-default feature. The hephaestus crate it renders through needs + # rustc ≥1.88 (wgpu), so it builds on +stable and is excluded from the + # 1.86 library build. Default features (incl. duckdb) supply the + # in-memory reader the test uses. + run: cargo +stable test --features png --lib writer::hephaestus + - name: Build WASM library working-directory: ggsql-wasm/library run: npm install && npm run build diff --git a/CHANGELOG.md b/CHANGELOG.md index 85eaed44b..5ec8c0c15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,114 @@ ## [Unreleased] ### Added +- New `PngWriter` renders a plot to a PNG raster image via + [hephaestus](https://github.com/posit-dev/hephaestus), behind a new + off-by-default `png` feature (`--writer png` in the CLI). `LABEL caption` + and the new `minor_breaks` setting have no Vega-Lite equivalent and render + only here. Requires a working GPU adapter — hardware or software, e.g. + lavapipe — at render time. +- Writers can be configured from key–value options: `Writer::from_options` takes + a `WriterOptions` set, and the CLI collects them from a repeatable + `--writer-option key=value` flag on `exec` and `run` (short `-D`, also + spellable `--writer-options`). Several settings can be collapsed into one flag + separated by `;` — `-D 'width=1600;dpi=150'`, quoted because shells read `;` + themselves — and the two forms mix. The png writer + takes `width`, `height`, `units` (`px`, `in`, `cm`, `mm`, `pt`), `dpi`, and + `background` (any CSS color, including `transparent`), defaulting to a + 1500×1000 px white canvas at 300 dpi; the Vega-Lite writer takes none. An + unknown key or unusable value is an error naming the option, not a silently + ignored setting. +- `--reader`, `--writer`, and `--output` gained the short forms `-r`, `-w`, and + `-o` on `exec` and `run`; `validate --reader` also takes `-r`. +- Text is rendered as rich text (markdown) by the png writer. A text layer's + `label` is parsed for `**bold**`, `*italic*`, `_underline_`, `~~strike~~`, + `` `code` `` and marquee-style `{selector body}` spans that set a colour or + size (`{.red hot}`, `{#0072B2 blue}`, `{.20 big}`), and so are the plot title, + subtitle, caption and axis titles set with `LABEL`. Legend titles and break + labels (axis tick labels, legend keys) do not parse yet and show their markers. + The new `parse` setting on the text layer turns it off for that layer + (`SETTING parse => false`), drawing the label exactly as given; it defaults to + `true`. Chrome text has no switch yet. The Vega-Lite writer has no rich-text + equivalent and ignores `parse`, always drawing text literally. +- New `minor_breaks` setting on continuous scales, controlling the unlabelled + subdivisions between breaks: a whole number of minor breaks *per interval between + two breaks* (`0` removes them), an array of exact positions, or — for temporal + scales — an interval such as `'week'`. Defaults to a value chosen by the + transformation. This has no Vega-Lite equivalent and is ignored by that writer; + the png writer draws them. +### Changed +- Dodging now only takes effect where groups actually meet on a position. A + layer whose grouping gives every group a position of its own — `colour` mapped + to the same column as the discrete axis, say — is drawn at its full width + instead of being squeezed into `1/n` of the band and shifted off its own + category, which made a coloured ridgeline plot (`DRAW violin SETTING side => + 'top'`) land its violins between the axis ticks or outside the panel + altogether. Groups in different facet panels don't meet either. Where any + position does hold several groups the whole layer still dodges, so an element + keeps the same slot in every position. Jitter, which dodges before jittering, + follows the same rule. +- Categorical `y` axes now run bottom-up, so the first level sits at the bottom + of the panel as it does in ggplot2. This affects every plot with a discrete or + ordinal `y` — horizontal bars, boxplots and violins by category, points and + 2D jitter — and brings the Vega-Lite writer in line with the raster one, which + already read this way. +- Banded marks now measure against the full step in the VegaLite writer. A band fraction + (a bar's `width`, a dodge displacement, a jitter spread, a violin or boxplot + half-width, a discrete tile's extent) is a fraction of the whole category step, + so `width => 0.9` leaves a 10% gap — ggplot2's convention. Vega-Lite previously + subtracted its own default band padding first, making every banded mark there + narrower than the same query rendered as a raster. This applies to dodged, + jittered and half-sided layers too, where Vega-Lite reserved a further 20% of + every step: their marks were narrower, their displacements smaller, and their + category ticks pulled toward the middle of the panel. + +### Fixed +- A dodged violin or half-boxplot on a categorical `y` axis is no longer flipped + in the Vega-Lite writer. Both took their band displacement from an encoding of + their own that read a ggsql offset as pointing down the screen, so their groups + came out in the opposite order to every other mark — a violin put the first + group above the second where a boxplot of the same data put it below, and a + half-boxplot's box parted company with its own whiskers once dodged. Violins + are also clipped to the panel now, as every other mark is. +- An identity-scaled column is now read exactly like the equivalent literal. + `SCALE IDENTITY ` hands its values straight to the aesthetic, so they mean + what the same value written with `SETTING` means, but several were passed to the + renderer unconverted: a `size` column was read as a symbol area in pixels² + rather than the radius in points `SETTING size => 3` gives (markers far too + small), a `shape` column of names such as `'star'` made Vega-Lite fail to render + at all, and a `linetype` column of names such as `'dashed'` drew a solid line in + both writers. `size`, `linewidth`, `fontsize`, `shape` and `linetype` identity + columns now convert per row, so an identity column and a setting produce the + same drawing. A value the aesthetic already understands still passes through + untouched. +- `DRAW bar MAPPING AS y` produced a single bar against a synthetic + axis instead of horizontal bars. A layer whose geom synthesises its primary + position (bar, boxplot) now transposes when the user maps a *discrete* `y`, and + stays put when they map a continuous one — that being the value axis, where a + lone `DRAW boxplot MAPPING AS y` already belongs. +- `RENAMING` was ignored on a discrete or ordinal scale over a non-string domain + (`SCALE ORDINAL color RENAMING 6 => 'June'` on a numeric month), because the + break label was formatted as `6.0` while the rename was keyed on `6`. +- A temporal axis given a calendar interval (`SETTING breaks => '2 months'`) no + longer draws ticks outside its own domain. The generator steps a whole interval + past each end, and the filter that trims them back compared only plain numbers, + so a date break was never constrained at all. +- Minor breaks are no longer extrapolated beyond the outermost major break when + the majors are unevenly spaced, as they are when set by hand + (`SETTING breaks => (37, 42, 55)`). Their spacing was taken from the first + interval alone, so they matched no part of the axis. Evenly spaced majors still + extend to the edge of the range. +- `Scale::break_labels()` — what a writer reads to label an axis, colorbar or + legend tick — labels a temporal break with its own date (`1973-04-23`) instead + of the epoch number its position projects to (`1208`), and keys `RENAMING` + overrides by that same string, so a rename on a temporal scale is found rather + than missed. Numeric and categorical labels are unchanged. +- A scale with an explicit input range that no layer trains — `SCALE x FROM (0, 10)` + alongside a diagonal `rule`, whose position is deliberately kept out of scale + training — takes its type from that range (numeric or temporal → continuous, + string or boolean → discrete) instead of staying untyped, so consumers get a + fully resolved scale. - The VS Code / Positron extension now offers its "Source Current File" button and code cells in plain `.sql` files, so existing SQL can be run against a ggsql kernel without renaming it. `.sql` files keep their usual SQL syntax @@ -11,9 +118,6 @@ turns the whole behaviour off. - The VS Code / Positron extension contributes a "ggsql File" entry to the New File dialog. - -### Fixed - - The VS Code / Positron extension now ships a language icon that renders in the session picker, editor tabs and the Explorer. It previously pointed at a file that did not exist, which left the icon blank. @@ -27,8 +131,8 @@ it at zero size with nothing left to correct it. It now recovers once the container has a real width. - ggsql interpreter sessions in Positron now come back after an extension host - restart as well as after a window reload. A session the user renamed also - keeps its name across the restore, and ggsql runtimes are rediscovered on + restart as well as after a window reload. A session the user renamed also + keeps its name across the restore, and ggsql runtimes are rediscovered on every window open rather than risking a stale cache hit. ## 0.4.1 - 2026-06-22 @@ -54,18 +158,18 @@ - Added `radar` setting to polar coordinates for making radar plots (#418). - New `side` SETTING on the `boxplot` layer and the `jitter` position, mirroring the existing `violin` setting (#439). -- New `hinge` SETTING on the `boxplot` layer, mirroring the existing `range` +- New `hinge` SETTING on the `boxplot` layer, mirroring the existing `range` setting (#438) -- New `DRAW spatial` layer for rendering simple features (WKT/WKB) for drawing +- New `DRAW spatial` layer for rendering simple features (WKT/WKB) for drawing maps and choropleths (#370). - New builtin dataset `ggsql:world` for showcasing spatial examples. Data is - a subset of columns from the [Natural Earth](https://www.naturalearthdata.com/) + a subset of columns from the [Natural Earth](https://www.naturalearthdata.com/) country data at 1:110m resolution (#370). -- New `PROJECT TO ` family of spatial map projections. For general - projections, one can use `PROJECT TO crs SETTING target => '+proj=...'`. - Several named projections have explicit support using e.g. - `PROJECT TO mollweide`. Works for a subset of layers, notably `spatial`, - `point`, `text`, `path`, `polygon` and `tile`. Requires a spatial backend +- New `PROJECT TO ` family of spatial map projections. For general + projections, one can use `PROJECT TO crs SETTING target => '+proj=...'`. + Several named projections have explicit support using e.g. + `PROJECT TO mollweide`. Works for a subset of layers, notably `spatial`, + `point`, `text`, `path`, `polygon` and `tile`. Requires a spatial backend like PostGIS, SpatiaLite, or DuckDB spatial extension (#455). ### Fixed @@ -76,7 +180,7 @@ - Dodging of horizontal violin plots were broken due to a bad orientation assumption in the VegaLite writer. We now correctly use the orientation to dodge in the correct dimension (#439). -- Fixed misbehaviour of numeric scale's `RENAMING` clause due to pre-formatting +- Fixed misbehaviour of numeric scale's `RENAMING` clause due to pre-formatting issues (#461) ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 2151b8674..8ce276632 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,16 +35,16 @@ The Cargo workspace (`/Cargo.toml`) has five members: `tree-sitter-ggsql`, `src` ``` ggsql query ──► parser ──► Plot AST ──► executor ──► Spec ──► writer ──► output - (tree-sitter) (Reader runs SQL, (Vega-Lite JSON) - applies stats, + (tree-sitter) (Reader runs SQL, (Vega-Lite JSON + applies stats, or PNG) resolves scales) ``` - The parser splits the query at the `VISUALISE` boundary. SQL goes to a pluggable `Reader` (DuckDB, SQLite, ODBC); the VISUALISE part becomes a typed `Plot`. - The executor ties the two together: SQL → DataFrame, AST resolved against actual schema, stats and scales applied per layer. -- The writer renders the resolved `Spec` to an output format (today: Vega-Lite JSON). +- The writer renders the resolved `Spec` to an output format: Vega-Lite JSON (default), or PNG (non-default `png` feature). The PNG writer is implemented on top of the hephaestus renderer — a name that stays internal; users see `png`. -For details — module layout, traits, where extension points live — see [`src/CLAUDE.md`](src/CLAUDE.md). For the Vega-Lite renderer specifically, [`src/writer/vegalite/CLAUDE.md`](src/writer/vegalite/CLAUDE.md). For the AST types, [`src/plot/CLAUDE.md`](src/plot/CLAUDE.md). +For details — module layout, traits, where extension points live — see [`src/CLAUDE.md`](src/CLAUDE.md). For a specific renderer, [`src/writer/vegalite/CLAUDE.md`](src/writer/vegalite/CLAUDE.md) (Vega-Lite) or [`src/writer/hephaestus/CLAUDE.md`](src/writer/hephaestus/CLAUDE.md) (PNG). For the AST types, [`src/plot/CLAUDE.md`](src/plot/CLAUDE.md). ## Building @@ -117,6 +117,7 @@ Per-folder CLAUDE.md files cover component-specific test guidance. - *How does the parser work? How is a `Plot` built?* → [`src/CLAUDE.md`](src/CLAUDE.md), then `src/parser/`. - *How do I add a new geom / scale type / coord?* → [`src/plot/CLAUDE.md`](src/plot/CLAUDE.md). - *How does Vega-Lite output get assembled?* → [`src/writer/vegalite/CLAUDE.md`](src/writer/vegalite/CLAUDE.md). +- *How does the raster (PNG) writer work?* → [`src/writer/hephaestus/CLAUDE.md`](src/writer/hephaestus/CLAUDE.md), which also lists its known gaps. - *How does a query become rendered output end-to-end?* → [`src/CLAUDE.md`](src/CLAUDE.md) (execution pipeline), then `src/execute/`. - *How does the Jupyter kernel route messages?* → [`ggsql-jupyter/CLAUDE.md`](ggsql-jupyter/CLAUDE.md). - *How does the VS Code / Positron extension talk to the kernel?* → [`ggsql-vscode/CLAUDE.md`](ggsql-vscode/CLAUDE.md). diff --git a/Cargo.lock b/Cargo.lock index 2cacacebc..788e814dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -417,6 +417,15 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + [[package]] name = "async-recursion" version = "1.1.1" @@ -498,7 +507,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec 0.9.1", ] [[package]] @@ -507,6 +525,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + [[package]] name = "bitflags" version = "2.11.1" @@ -534,6 +558,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -604,6 +637,26 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -709,6 +762,32 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipper2-rust" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fd663fe209e7030c956e3be4c051dcc20cdb73da794f31466762cff12ca11bf" +dependencies = [ + "num-traits", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width 0.2.2", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" + [[package]] name = "colorchoice" version = "1.0.5" @@ -791,6 +870,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1688,6 +1776,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1699,6 +1797,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1764,6 +1871,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1782,7 +1898,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" dependencies = [ - "bit-set", + "bit-set 0.8.0", "regex-automata", "regex-syntax", ] @@ -1799,6 +1915,15 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "filetime" version = "0.2.29" @@ -1865,6 +1990,46 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75382bc7392ef10aad10935f92fc3db36d2d4dad0e5d96d8d65e04f89a07ec39" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontique" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c20b425addb8661e97fe1d51c4d8bcec3ec29ed6ad0db983976a7521276b8f7" +dependencies = [ + "hashbrown 0.17.1", + "linebender_resource_handle", + "memmap2", + "objc2", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation", + "parlance", + "read-fonts 0.39.2", + "roxmltree", + "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1932,6 +2097,17 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -2079,6 +2255,7 @@ dependencies = [ "csscolorparser", "duckdb", "geozero", + "hephaestus", "jsonschema", "libloading", "palette", @@ -2151,12 +2328,88 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.18", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", + "svg_fmt", +] + [[package]] name = "half" version = "2.7.1" @@ -2169,6 +2422,19 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551ed25397e4b444e89686602877d5cf3a7f6e3d548dcac37a8357d1e195f4df" +dependencies = [ + "bitflags", + "bytemuck", + "core_maths", + "read-fonts 0.39.2", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2209,6 +2475,9 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -2234,12 +2503,38 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hephaestus" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca883b65468b673aab28f1c7a86690ad5a00c31a4547c93577ecfd569c47e9b4" +dependencies = [ + "bytemuck", + "clipper2-rust", + "futures-intrusive", + "kurbo", + "parley", + "peniko", + "png", + "pollster", + "pulldown-cmark", + "thiserror 2.0.18", + "vello", + "wgpu", +] + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + [[package]] name = "hmac" version = "0.12.1" @@ -2391,6 +2686,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_locale" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.2.0" @@ -2399,11 +2709,18 @@ checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" + [[package]] name = "icu_normalizer" version = "2.2.0" @@ -2452,6 +2769,8 @@ checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2459,6 +2778,27 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_segmenter" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0794db0b1a86193ac9c48768d0e6c52c54448e0870ad87907d456ee0dac964" +dependencies = [ + "icu_collections", + "icu_locale", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" + [[package]] name = "id-arena" version = "2.3.0" @@ -2531,6 +2871,34 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -2580,6 +2948,23 @@ dependencies = [ "uuid-simd", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "konst" version = "0.2.20" @@ -2595,6 +2980,18 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lazy-regex" version = "3.6.0" @@ -2737,6 +3134,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2806,6 +3209,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + [[package]] name = "minimad" version = "0.13.1" @@ -2843,6 +3255,41 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "naga" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd91265cc2454558f659b3b4b9640f0ddb8cc6521277f166b8a8c181c898079" +dependencies = [ + "arrayvec", + "bit-set 0.9.1", + "bitflags", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2932,6 +3379,78 @@ dependencies = [ "libm", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-metal", +] + [[package]] name = "object_store" version = "0.13.2" @@ -2979,6 +3498,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + [[package]] name = "outref" version = "0.5.2" @@ -3031,6 +3559,39 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parlance" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5" + +[[package]] +name = "parley" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fad031076f48f0d4d85ce1aea9b94b4e715a4d636a030a123038f8f5b5e4343" +dependencies = [ + "fontique", + "harfrust", + "hashbrown 0.17.1", + "icu_normalizer", + "icu_properties", + "icu_segmenter", + "linebender_resource_handle", + "parlance", + "parley_data", + "skrifa 0.42.1", +] + +[[package]] +name = "parley_data" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ab9ace3fad1b9ed603ddac5b595e69931fc50263d7e04e4055015b77b02da5" +dependencies = [ + "icu_properties", +] + [[package]] name = "parquet" version = "58.3.0" @@ -3108,6 +3669,18 @@ dependencies = [ "serde", ] +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3201,12 +3774,57 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -3219,6 +3837,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "prettyplease" version = "0.2.37" @@ -3247,6 +3871,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + [[package]] name = "prost" version = "0.14.3" @@ -3318,6 +3948,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + [[package]] name = "quinn" version = "0.11.9" @@ -3329,7 +3970,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "socket2", "thiserror 2.0.18", @@ -3349,7 +3990,7 @@ dependencies = [ "lru-slab", "rand 0.9.4", "ring", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "slab", @@ -3459,6 +4100,51 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.3", + "once_cell", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3551,6 +4237,12 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + [[package]] name = "reqwest" version = "0.12.28" @@ -3634,6 +4326,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rsqlite-vfs" version = "0.1.0" @@ -3677,6 +4378,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -4009,12 +4716,41 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts 0.39.2", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -4037,6 +4773,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags", +] + [[package]] name = "sprintf" version = "0.4.3" @@ -4084,6 +4829,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "streaming-iterator" version = "0.1.9" @@ -4154,6 +4905,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + [[package]] name = "syn" version = "1.0.109" @@ -4226,6 +4983,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "termimad" version = "0.31.3" @@ -4299,7 +5065,7 @@ checksum = "7e54bc85fc7faa8bc175c4bab5b92ba8d9a3ce893d0e9f42cc455c8ab16a9e09" dependencies = [ "byteorder", "integer-encoding", - "ordered-float", + "ordered-float 2.10.1", ] [[package]] @@ -4318,6 +5084,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -4681,6 +5448,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -4821,6 +5594,51 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vello" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af76ceb17b2869be23598baef40a9c8db4de86b87c60510c3bc245559275a617" +dependencies = [ + "bytemuck", + "futures-intrusive", + "log", + "peniko", + "png", + "skrifa 0.44.0", + "static_assertions", + "thiserror 2.0.18", + "vello_encoding", + "vello_shaders", + "wgpu", +] + +[[package]] +name = "vello_encoding" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e31cd622201690d8dfe9fd8fea8d1ae59db1bfeea414856a617d1d03438418c" +dependencies = [ + "bytemuck", + "guillotiere", + "peniko", + "skrifa 0.44.0", + "smallvec", +] + +[[package]] +name = "vello_shaders" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abf943bd2920bfd22928a9c1bad39866f7ffcc1109b6ed924ab52773e3868a83" +dependencies = [ + "bytemuck", + "log", + "naga", + "thiserror 2.0.18", + "vello_encoding", +] + [[package]] name = "version_check" version = "0.9.5" @@ -4966,6 +5784,18 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.98" @@ -4995,6 +5825,183 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "wgpu" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb3feacc458f7bee8bc1737149b42b6c731aa461039a4264a67bb6681646b250" +dependencies = [ + "arrayvec", + "bitflags", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02da3ad1b568337f25513b317870960ef87073ea0945502e44b864b67a8c77b7" +dependencies = [ + "arrayvec", + "bit-set 0.9.1", + "bit-vec 0.9.1", + "bitflags", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e51b5447e144b3dbba4feb01f80f4fa21696fa0cd99afb2c3df1affd6fdb28" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3487cd6293a963bc5c0c0396f6a2192043c50003c07f4efdccbad3d90ec9d819" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2f2fb042f36920771deb0b966543c5751b18f3d327760ffc90f74e20b2dcd4" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb01076d0aa08b0ba9bd741e178b5cc440f5abe99d9581323a4c8b5d1a1916" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f8e1a9e7a8512f276f7c62e018c7fa8d60954303fed2e5750114332049193f" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set 0.9.1", + "bitflags", + "block2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "ndk-sys", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-metal", + "objc2-quartz-core", + "once_cell", + "ordered-float 5.3.0", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.18", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59c654c483f058800972c3645e95388a7eca31bf9fe1933bc20e036588a0be02" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9bcc31518a0e9735aefebedb5f7a9ef3ed1c42549c9f4c882fa9060ceaac639" +dependencies = [ + "bitflags", + "bytemuck", + "js-sys", + "log", + "raw-window-handle", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -5026,6 +6033,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -5039,6 +6067,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -5067,6 +6106,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -5165,6 +6214,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5410,6 +6468,23 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.2" @@ -5516,6 +6591,7 @@ dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -5524,6 +6600,7 @@ version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/doc/get_started/tooling/cli.qmd b/doc/get_started/tooling/cli.qmd index dc7a484ac..b22931b14 100644 --- a/doc/get_started/tooling/cli.qmd +++ b/doc/get_started/tooling/cli.qmd @@ -47,7 +47,7 @@ $ ggsql validate "VISUALISE x, y FROM table DRAW point" ## Database connections -Both `ggsql exec` and `ggsql run` accept a `--reader` flag that can be used to specify a connection string to be used when executing the query. If not provided, ggsql will use an empty in-memory duckdb connection, equivalent to `--reader duckdb://memory`. +Both `ggsql exec` and `ggsql run` accept a `--reader` flag (short `-r`) that can be used to specify a connection string to be used when executing the query. If not provided, ggsql will use an empty in-memory duckdb connection, equivalent to `--reader duckdb://memory`. ```bash $ ggsql exec --reader sqlite://sample/ggsql_test.sqlite \ @@ -65,6 +65,47 @@ col_a, col_b, col_c 12.5, 29.48, gamma ``` +## Output format + +`ggsql exec` and `ggsql run` render with the writer named by `--writer` (short `-w`), defaulting to `--writer vegalite` (the Vega-Lite JSON above). A build that includes the optional `png` writer can also render straight to a PNG image with `--writer png`, which needs a GPU adapter available where it runs. + +A writer is configured with `--writer-option key=value`, repeated once per setting: + +```bash +ggsql exec --writer png \ + --writer-option width=6 \ + --writer-option height=4 \ + --writer-option units=in \ + --writer-option dpi=150 \ + --output chart.png \ + "VISUALISE species AS fill FROM ggsql:penguins DRAW bar" +``` + +Several settings can also be collapsed into one flag, separated by `;`. With `-D` short for `--writer-option` (and `--writer-options` accepted as well), plus `-w` for `--writer` and `-o` for `--output`, the same call reads: + +```bash +ggsql exec -w png -D 'width=6;height=4;units=in;dpi=150' -o chart.png \ + "VISUALISE species AS fill FROM ggsql:penguins DRAW bar" +``` + +**Quote the collapsed form.** Most shells — bash, zsh, PowerShell — read `;` as a command separator, so unquoted it silently runs something else rather than failing. Single quotes, double quotes and `\;` all work. The two forms mix freely, and a repeated key takes its last value. + +`;` is the only separator; `,` is not, because values contain commas — `background='rgb(255, 0, 0)'` has to survive intact. + +The png writer understands these options: + +| Option | Value | Default | +| --- | --- | --- | +| `width` | Canvas width, in `units` | `1500` (px) | +| `height` | Canvas height, in `units` | `1000` (px) | +| `units` | `px`, `in`, `cm`, `mm`, or `pt` — how `width` and `height` are read | `px` | +| `dpi` | Pixels per inch. Sets the print resolution of a physical size, and how large text and other chrome are relative to the canvas | `300` | +| `background` | Any CSS color, e.g. `white`, `#faf3e0`, `rgb(0 0 0 / 50%)`, or `transparent` | `white` | + +`units` applies to the `width` and `height` you supply — the defaults are pixel counts either way, so `--writer-option width=6 --writer-option units=in` gives a canvas 6 inches wide and 1000 pixels tall. + +The Vega-Lite writer takes no options: its output is resolution-independent, so size, resolution and background belong to whatever renders the spec. Passing an option a writer doesn't understand is an error rather than a setting quietly ignored. + ## Documentation The ggsql CLI has built-in documentation for ggsql syntax and usage. Run `ggsql docs` for an overview of available documentation topics, and `ggsql docs [topic]` to read about a specific topic. diff --git a/doc/syntax/clause/label.qmd b/doc/syntax/clause/label.qmd index c6b89e5a0..3117dc2a4 100644 --- a/doc/syntax/clause/label.qmd +++ b/doc/syntax/clause/label.qmd @@ -17,7 +17,17 @@ There are a few additional labels beside the aesthetics that govern the differen * `title`: The main title of the plot * `subtitle`: An additional, often longer and more descriptive, title beneath the main title -* `caption`: A string placed below the plot, often used to add additional information about the data source etc. Currently not possible as our only writer (Vega-Lite) doesn't support it. Will be available as new writers appear. +* `caption`: A string placed below the plot, often used to add additional information about the data source etc. Not supported by the Vega-Lite writer, which has no equivalent; the png (raster) writer renders it. + +## Rich text +The png writer reads the strings you provide here as markdown, so `LABEL title => 'Sales in **2024**'` renders the year in bold. +This currently applies to the plot title, subtitle and caption, and to the axis titles. +Legend titles and break labels (axis tick labels and legend keys) are still drawn literally — the renderer has no rich-text support on those slots yet, so markdown in them shows its markers. This is a gap rather than a design choice, and they will parse once the renderer catches up. +The Vega-Lite writer has no rich-text support at all and draws every label exactly as given. + +The recognised markdown is the same as for the [text layer](../layer/type/text.qmd#parse). + +There is currently no way to turn this off for labels — the [`parse` setting](../layer/type/text.qmd#parse) only covers text layers. ## Automatic labelling logic Axes and legends get an automatic label from the mapping. The logic is as follows: diff --git a/doc/syntax/layer/position/dodge.qmd b/doc/syntax/layer/position/dodge.qmd index b32f46a4d..19a217030 100644 --- a/doc/syntax/layer/position/dodge.qmd +++ b/doc/syntax/layer/position/dodge.qmd @@ -9,6 +9,8 @@ The dodge adjustment is intended to move entities that share the same position o ## Position scale requirements Dodge doesn't have specific requirements to the scale type of the plot, but will only affect discrete scales (including binned and ordinal). If only one scale is discrete, the dodging happens in that scale's direction. If both scales are discrete, the dodging happens as a 2D grid. +Dodging only takes effect if two or more groups actually meet on the same position — that is what there is to separate. Mapping an aesthetic to the same variable as the discrete axis, for instance, gives every group a position of its own, and the layer is drawn at its full width as if no dodging had been asked for. Groups in different [facet](../../clause/facet.qmd) panels don't meet either. Where any position does hold several groups, the whole layer dodges, so an element occupies the same slot in every position and stays comparable across them. + ## Settings Apart from the settings of the layer type, setting `position => 'dodge'` will allow these additional settings: diff --git a/doc/syntax/layer/type/text.qmd b/doc/syntax/layer/type/text.qmd index ad9430b51..4b75c4eb2 100644 --- a/doc/syntax/layer/type/text.qmd +++ b/doc/syntax/layer/type/text.qmd @@ -34,6 +34,7 @@ The following aesthetics are recognised by the text layer. * a single number that applies both horizontally and vertically * a 2-element numeric array `[h, v]` where the first number is the horizontal offset and the second number is the vertical offset. * `format` Formatting specifier, see explanation below. +* `parse` Whether to read the label as rich text (markdown). Boolean value, `true` by default. See explanation below. * `position`: Position adjustment. One of `'identity'` (default), `'stack'`, `'dodge'`, or `'jitter'` * `aggregate` Aggregation functions to apply per group: * `null` apply no group aggregation (default). @@ -68,6 +69,21 @@ Known formatters are: * `o`: Unsigned octal * `x`/`X`: Unsigned hexadecimal +### Parse +By default the label is read as markdown, so `'**Adelie**'` draws a bold *Adelie* rather than the asterisks around it. +Set `parse => false` to draw the label exactly as it is, markers and all. + +The markdown flavour recognised is CommonMark plus a few extensions. +The most useful parts for a label are: + +* `**bold**` and `*italic*`. Note that `_underline_` underlines rather than italicises. +* `~~strikethrough~~`. +* `` `code` ``, rendered in the monospace typeface. +* `{selector body}` spans, which style a fragment without a dedicated marker. The selector is a single token: a colour name or CSS colour (`{.red hot}`), a hex colour (`{#0072B2 blue}`), or a size in points (`{.20 big}`). Combine them by nesting: `{.red {.20 big and red}}`. + +Note that `parse` is only honoured by the png writer. +The Vega-Lite writer has no rich-text support and always draws the label literally, so a query meant for both writers should either avoid markdown in its labels or set `parse => false`. + ## Data transformation This layer supports aggregation through the `aggregate` setting. Aggregation groups are defined by `PARTITION BY` and all discrete mappings. Within each group, every numeric mapping is replaced in place by its aggregated value. Use a default like `'mean'` or target individual aesthetics with `':'`. See [the `DRAW` documentation](../../clause/draw.qmd#aggregate) for the full setting shape. @@ -106,6 +122,15 @@ DRAW text SCALE fontsize TO (6, 20) ``` +Labels are read as markdown, so a `format` template can style part of the label. This only shows up in the png writer. + +```{ggsql} +VISUALISE bill_len AS x, bill_dep AS y FROM ggsql:penguins +DRAW text + MAPPING island AS label + SETTING format => '{:Title} *island*' +``` + The 'stroke' aesthetic is applied to the outline of the text. ```{ggsql} diff --git a/doc/syntax/scale/type/continuous.qmd b/doc/syntax/scale/type/continuous.qmd index 54b113fce..188e093c6 100644 --- a/doc/syntax/scale/type/continuous.qmd +++ b/doc/syntax/scale/type/continuous.qmd @@ -95,6 +95,27 @@ If not provided explicitly by the user the breaks for the scales will be calcula - `pretty => true`: An appropriate interval is chosen that approximates the requested number of breaks and then used as above - `pretty => false`: Linear spacing in integer space as close to the requested number of breaks +### Minor breaks +Minor breaks are the unlabelled sub-divisions between two breaks — drawn as shorter ticks and fainter gridlines. They carry no labels, so unlike `breaks` they are never affected by `RENAMING` or a label template. + +Where `breaks => 5` asks for about five breaks across the whole scale, `minor_breaks => 3` asks for three minor breaks *inside each interval between two breaks*. + +If not given, the transformation picks the count: + +* `linear`/`integer`/`sqrt`/`square`: one minor break per interval, i.e. at the midpoint +* `log`/`log2`/`ln`/`exp10`/`exp2`/`exp`/`asinh`/`pseudo_log`/`pseudo_log2`/`pseudo_ln`: eight per interval, giving the familiar 2-9 pattern between powers +* `date`/`datetime`/`time`: three per interval + +Automatically derived minor breaks are always placed relative to the breaks, so changing `breaks` moves them too. Set them explicitly to break that link: + +* `minor_breaks => `: that many evenly spaced minor breaks inside every interval. `0` removes them entirely +* `minor_breaks => (…)`: an array of exact positions. Values outside the scale range are dropped +* `minor_breaks => `: for `date`/`datetime`/`time` only, an interval (e.g. `week` or `6 hours`) aligned at the interval boundary, exactly as `breaks` treats an interval + +::: {.callout-note} +Minor breaks are only drawn by writers that support them. The Vega-Lite writer has no concept of a minor break and ignores the setting; the png (raster) writer draws them. +::: + ### The size aesthetic The size aesthetic requires special attention. To the user, size is given as radius in points (1/72 inch), but internally the provided values are converted to area, and the scale operates on area transformed values. This means that while you provide the output range in radius, the scaling is proportional to the area, even when using the default linear transformation. While this seems somewhat complicated we have chosen this approach to satisfy two opposing needs: @@ -144,6 +165,7 @@ The following settings are recognised by continuous scales: * `expand` (only for `x`/`y`): Either a scalar number or 2-element array of numbers (values must be >= 0). Sets the expansion of the scale to either side of the range. If a scalar it gives the multiplicative expansion. If an array the first element is a multiplication factor and the second element is an additive constant. Defaults to `0.05` (5 %). Expansion is only applied to values that are not explicitly given by the user, i.e. if setting the range as `SCALE x FROM (0, null)` expansion will only be applied to the upper range. * `oob`: How should values outside of the scale input range be treated. One of `'keep'` (keep the values as-is), `'censor'` (set to `null`), or `'squish'` (set to the nearest values within the range). Default for `x`/`y` is `'keep'`, for the remaining it is `'censor'`. * `breaks`: Either a scalar (whole number >= 1) as described in [the section on breaks](#breaks), or an array of values to place breaks at. Defaults to `5`. +* `minor_breaks`: Placement of the minor breaks that fall between the breaks, as described in [the section on minor breaks](#minor-breaks). Either a scalar (whole number >= 0) giving the number of minor breaks *per interval between two breaks*, an array of values to place them at, or (for temporal transformations) an interval. Use `minor_breaks => 0` to remove them. Defaults to a value chosen by the transformation. * `pretty`: A boolean indicating which algorithm to use for automatic calculation of breaks as described in [the section on breaks](#breaks). Defaults to `true`. * `reverse`: A boolean indicating whether the scale direction should be reversed. Defaults to `false`. diff --git a/doc/syntax/scale/type/identity.qmd b/doc/syntax/scale/type/identity.qmd index 326cb0033..5b33bd5f5 100644 --- a/doc/syntax/scale/type/identity.qmd +++ b/doc/syntax/scale/type/identity.qmd @@ -6,14 +6,19 @@ title: Identity The identity scale is a special scale that allows the input to flow through unchanged. You can use this if your data already contains values in a format understood by the aesthetic, e.g. a column of color values mapped to fill. It doesn't take any additional settings. +Since the values are used as-is, they are read exactly the way a literal given with [`SETTING`](../../clause/draw.qmd) is read, in the same unit and the same vocabulary: a column mapped to [`size`](../aesthetic/size.qmd) is a radius in points, one mapped to [`linewidth`](../aesthetic/linewidth.qmd) is a width in points, and one mapped to [`shape`](../aesthetic/shape.qmd) or [`linetype`](../aesthetic/linetype.qmd) holds the same names you would write as a setting (`'star'`, `'dashed'`). Data measured in something else needs converting in SQL first. + Since the identity scale doesn't do any translation of data it doesn't create a legend. ### Examples #### Use data values directly for size +`flipper_len` is measured in millimetres, so it is scaled down in SQL to give radii of a few points before being handed to the aesthetic: + ```{ggsql} -VISUALISE bill_len AS x, bill_dep AS y, flipper_len AS size FROM ggsql:penguins +SELECT bill_len, bill_dep, flipper_len / 40.0 AS radius FROM ggsql:penguins +VISUALISE bill_len AS x, bill_dep AS y, radius AS size DRAW point SCALE IDENTITY size ``` diff --git a/doc/vendor/SKILL.md b/doc/vendor/SKILL.md index abb27c355..0849914e8 100644 --- a/doc/vendor/SKILL.md +++ b/doc/vendor/SKILL.md @@ -260,6 +260,9 @@ Continuous/binned scales: - `pretty` — boolean, default `true`. Use Wilkinson's algorithm for nice breaks. - `reverse` — boolean, default `false`. Reverse scale direction. +Continuous scales additionally: +- `minor_breaks` — unlabelled subdivisions between breaks. Integer count **per interval between two breaks** (`0` removes them), array of values, or interval string for temporal. Defaults to a per-transformation value. Only drawn by writers that support minor breaks; Vega-Lite ignores it. + Binned scales additionally: - `closed` — `'left'` (default) or `'right'` @@ -425,7 +428,7 @@ Line segments between two endpoints. Required: x, y, xend, yend. For axis-aligne Reference lines spanning the full panel. Required: x or y. Optional: `slope` (for diagonal: `y = a + slope * x`). ### text -Text labels. Required: x, y, label. Settings: `offset` (number or `(h, v)`), `format` (string interpolation like RENAMING). `hjust`: `'left'`/`'right'`/`'centre'` or 0-1. `vjust`: `'top'`/`'bottom'`/`'middle'` or 0-1. +Text labels. Required: x, y, label. Settings: `offset` (number or `(h, v)`), `format` (string interpolation like RENAMING), `parse` (boolean, default `true`: read the label as markdown — `**bold**`, `*italic*`, `~~strike~~`, `` `code` ``, `{.red span}` — set `false` to draw it literally; png writer only). `hjust`: `'left'`/`'right'`/`'centre'` or 0-1. `vjust`: `'top'`/`'bottom'`/`'middle'` or 0-1. ### rect Rectangles. Required: pick 2 per axis from center (x/y), min (xmin/ymin), max (xmax/ymax), width, height. Or just center (defaults width/height to 1). diff --git a/ggsql-cli/CLAUDE.md b/ggsql-cli/CLAUDE.md index c6ed59703..f0126ef59 100644 --- a/ggsql-cli/CLAUDE.md +++ b/ggsql-cli/CLAUDE.md @@ -10,6 +10,8 @@ End-user installation lives in [`/doc/get_started/installation.qmd`](../doc/get_ ggsql-cli/ ├── Cargo.toml Binary def, depends on ggsql; holds [package.metadata.packager] ├── build.rs Generates docs_data.rs by reading /doc/syntax/ + /doc/vendor/SKILL.md +├── examples/ +│ └── visual_test.rs Dev harness: renders the doc examples into an HTML report └── src/ └── main.rs clap CLI: exec, run, parse, validate, docs, skill ``` @@ -32,6 +34,8 @@ The binary name is `ggsql` (not `ggsql-cli`) — that's what release artifacts a Only public `ggsql::*` API is used (`reader`, `writer`, `validate`, `parser`, `VERSION`) — this crate has no awareness of internal modules. +`exec` and `run` share a `WriterSpec { name, options }`: `--writer` names the writer and repeated `--writer-option key=value` flags (short `-D`, visible alias `--writer-options`, several settings per flag when separated by `;`) become a `ggsql::writer::WriterOptions`, parsed up front in `main` so a malformed pair fails before any SQL runs. The two travel together down `cmd_exec` → `exec_with_reader` → `render_spec`, which dispatches on the name and hands the options to `Writer::from_options`. Adding a setting to a writer therefore needs no CLI change; which keys exist is the writer's business, and an unknown one is its error to report. User-facing keys are documented in [`/doc/get_started/tooling/cli.qmd`](../doc/get_started/tooling/cli.qmd). + ## Build & install ```sh @@ -73,9 +77,32 @@ Library-level coverage lives in `ggsql` itself — this crate is thin glue, so i ./target/release/ggsql skill ``` +## The `visual_test` example + +[`examples/visual_test.rs`](examples/visual_test.rs) is a **developer harness, not a shipped feature**: it treats every executable ```` ```{ggsql} ```` cell in [`/doc/`](../doc/) as a test corpus, renders each one, and writes a single HTML report pairing every query with its output. It lives here because this is the crate that already owns clap and the public `ggsql` API; it adds nothing to the binary. + +```sh +cargo run -p ggsql-cli --features png --example visual_test # doc/syntax + doc/gallery +cargo run -p ggsql-cli --features png --example visual_test -- --compare # + Vega-Lite side by side +cargo run -p ggsql-cli --features png --example visual_test -- doc/gallery -f pie +open target/visual-test/index.html +``` + +`[[example]]`'s `required-features` keeps it out of `cargo test --workspace`, so a build without a GPU stack never compiles it. + +Four properties are worth preserving when changing it: + +- **One reader per source file, cells in document order.** Doc pages build a table in one cell and plot it in the next, so per-cell isolation would break the corpus. A cell with no `VISUALISE` (`validate(..).has_visual()` is false) runs as setup through `execute_sql`. +- **Cells run in their own page's directory**, as Quarto runs them, so a query reading `FROM 'minard_troops.csv'` finds the CSV sitting beside the `.qmd`. The report and its `assets/` are resolved to an absolute path up front, since they outlive that switch. +- **Nothing aborts the run.** Execution errors, render errors and *panics* inside a writer are captured per cell (`capture`), so one report surfaces every problem in the corpus at once. This is the point of the tool — a run that stops at the first failure tells you almost nothing. +- **Renders are files, specs are inline.** PNGs are written to `assets/`; Vega-Lite specs are embedded in `", + inline_json(json) + ); + } + if let Some(message) = vegalite_error { + let _ = write!( + html, + "
vega-lite
\ +
{}
", + escape(message) + ); + } + } + } + + html.push_str(""); + + if !cell.warnings.is_empty() { + html.push_str("
    "); + for warning in &cell.warnings { + let _ = write!(html, "
  • {}
  • ", escape(warning)); + } + html.push_str("
"); + } + + html.push_str("\n\n"); + html +} + +fn report_head(total: usize, plots: usize, problems: usize, args: &Args) -> String { + let compare = if args.compare { + " · compared against vega-lite" + } else { + "" + }; + format!( + r#" + + + +ggsql visual test + + + +
+

ggsql visual test

+

{total} cells · {plots} plots · {problems} problems + · {width}×{height} px @ {dpi} dpi{compare}

+
+ + +
+
+"#, + style = STYLE, + problem_class = if problems > 0 { "bad" } else { "good" }, + width = args.width, + height = args.height, + dpi = args.dpi, + ) +} + +const STYLE: &str = r#" +:root { --bg:#fff; --fg:#1c1c1c; --muted:#666; --line:#e3e3e3; --good:#137333; --bad:#c5221f; --warn:#b06000; } +* { box-sizing: border-box; } +body { margin:0; font:14px/1.5 system-ui, sans-serif; color:var(--fg); background:var(--bg); + display:grid; grid-template-columns:240px 1fr; grid-template-rows:auto 1fr; } +#top { grid-column:1/-1; padding:16px 24px; border-bottom:1px solid var(--line); position:sticky; top:0; background:var(--bg); z-index:2; } +#top h1 { margin:0 0 4px; font-size:18px; } +.summary { margin:0 0 8px; color:var(--muted); } +.controls { display:flex; gap:16px; align-items:center; } +#search { padding:4px 8px; border:1px solid var(--line); border-radius:4px; width:320px; } +#toc { padding:16px 8px 48px 16px; border-right:1px solid var(--line); overflow:auto; position:sticky; top:96px; align-self:start; max-height:calc(100vh - 96px); } +#toc a { display:block; padding:2px 4px; color:var(--fg); text-decoration:none; font-size:12px; border-radius:3px; } +#toc a:hover { background:#f2f2f2; } +main { padding:16px 24px 96px; min-width:0; } +h2.source { font-size:16px; margin:32px 0 8px; padding-top:8px; border-top:2px solid var(--line); } +h2.source small { display:block; font-weight:400; color:var(--muted); font-family:ui-monospace, monospace; } +.cell { border:1px solid var(--line); border-radius:6px; margin:12px 0; overflow:hidden; } +.cell.problem { border-color:var(--bad); } +.cell header { display:flex; gap:12px; align-items:baseline; padding:6px 10px; background:#fafafa; border-bottom:1px solid var(--line); font-size:12px; } +.cell .loc { font-family:ui-monospace, monospace; color:var(--muted); } +.cell .heading { color:var(--muted); } +.cell .time { margin-left:auto; color:var(--muted); } +.badge { font-weight:600; text-transform:uppercase; letter-spacing:.03em; font-size:10px; padding:2px 6px; border-radius:3px; background:#eee; } +.badge.good { background:#e6f4ea; color:var(--good); } +.badge.bad { background:#fce8e6; color:var(--bad); } +.badge.warn { background:#fef7e0; color:var(--warn); } +.count.bad { color:var(--bad); font-weight:600; } +.good { color:var(--good); } .bad { color:var(--bad); } +.body { display:grid; grid-template-columns:minmax(260px, 26%) 1fr; gap:16px; padding:12px; align-items:start; } +pre.query { margin:0; padding:10px; background:#f7f7f7; border-radius:4px; font:12px/1.45 ui-monospace, monospace; white-space:pre-wrap; overflow-wrap:anywhere; } +.renders { display:flex; gap:16px; flex-wrap:wrap; min-width:0; } +figure { margin:0; flex:1 1 420px; min-width:0; } +figcaption { font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); margin-bottom:4px; } +figure img { width:100%; height:auto; border:1px solid var(--line); border-radius:4px; background:#fff; } +.vl { width:100%; border:1px solid var(--line); border-radius:4px; overflow:hidden; } +.vl > script { display:none; } +pre.error { margin:0; padding:10px; background:#fce8e6; color:var(--bad); border-radius:4px; font:12px/1.45 ui-monospace, monospace; white-space:pre-wrap; } +.note { margin:0; color:var(--muted); } +.warnings { grid-column:1/-1; margin:0; padding:0 0 0 20px; color:var(--warn); font-size:12px; } +.hidden { display:none; } +@media (max-width:1100px) { body { grid-template-columns:1fr; } #toc { display:none; } .body { grid-template-columns:1fr; } } +"#; + +const REPORT_SCRIPT: &str = r#" +"#; + +/// Vega-Lite specs are inlined and embedded lazily, so the report works when +/// opened straight off disk and does not pay for 200 charts up front. +const VEGA_SCRIPT: &str = r#" + + + +"#; + +// ============================================================================ + +fn main() { + let args = Args::parse(); + + let paths = collect_sources(&args.paths, args.filter.as_deref()); + if paths.is_empty() { + eprintln!("No .qmd files found in {:?}", args.paths); + std::process::exit(1); + } + + let assets = args.out.join("assets"); + if let Err(e) = fs::create_dir_all(&assets) { + eprintln!("Could not create {}: {e}", assets.display()); + std::process::exit(1); + } + // Cells run in their own page's directory, so the renders have to land at a + // path that does not move with them. + let assets = fs::canonicalize(&assets).unwrap_or(assets); + + let mut sources = Vec::new(); + for path in paths { + let text = match fs::read_to_string(&path) { + Ok(text) => text, + Err(e) => { + eprintln!("warning: cannot read {}: {e}", path.display()); + continue; + } + }; + let cells = parse_cells(&text); + if cells.is_empty() { + continue; + } + let label = path.to_string_lossy().replace('\\', "/"); + let title = front_matter_title(&text).unwrap_or_else(|| label.clone()); + let dir = match path.parent() { + Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(), + _ => PathBuf::from("."), + }; + sources.push(Source { + label, + title, + dir, + cells, + }); + } + + let total: usize = sources.iter().map(|s| s.cells.len()).sum(); + eprintln!( + "Rendering {total} cells from {} files at {}×{} px, {} dpi\n", + sources.len(), + args.width, + args.height, + args.dpi + ); + + let start = Instant::now(); + let mut results = Vec::new(); + for source in sources { + eprintln!("{}", source.label); + results.push(run_source(source, &args, &assets)); + } + + if let Err(e) = write_report(&results, &args, &args.out) { + eprintln!("Could not write the report: {e}"); + std::process::exit(1); + } + + let problems: usize = results + .iter() + .flat_map(|r| &r.cells) + .filter(|c| c.is_problem()) + .count(); + eprintln!( + "\n{total} cells in {:.1}s — {problems} problems\nReport: {}", + start.elapsed().as_secs_f64(), + args.out.join("index.html").display() + ); +} diff --git a/ggsql-cli/src/main.rs b/ggsql-cli/src/main.rs index d1b0cb0de..50ffe60ec 100644 --- a/ggsql-cli/src/main.rs +++ b/ggsql-cli/src/main.rs @@ -7,12 +7,16 @@ Provides commands for executing ggsql queries with various data sources and outp use clap::{Parser, Subcommand, ValueEnum}; use ggsql::reader::{Reader, Spec}; use ggsql::validate::validate; +use ggsql::writer::{Writer, WriterOptions}; use ggsql::{parser, VERSION}; -use std::io::IsTerminal; +use std::io::{IsTerminal, Write}; use std::path::PathBuf; #[cfg(feature = "vegalite")] -use ggsql::writer::{VegaLiteWriter, Writer}; +use ggsql::writer::VegaLiteWriter; + +#[cfg(feature = "png")] +use ggsql::writer::PngWriter; mod docs { include!(concat!(env!("OUT_DIR"), "/docs_data.rs")); @@ -27,6 +31,32 @@ pub struct Cli { pub command: Commands, } +enum Output { + Text(String), + /// Only a raster writer produces bytes, so nothing constructs this when no + /// such writer is compiled in. + #[cfg_attr(not(feature = "png"), allow(dead_code))] + Bin(Vec), +} + +/// The writer to render with, plus the `--writer-option` settings for it. +struct WriterSpec { + name: String, + options: WriterOptions, +} + +impl WriterSpec { + /// Build from the raw flags, exiting with the parse error if an option is + /// not `key=value`. + fn new(name: String, options: Vec) -> Self { + let options = WriterOptions::parse(options).unwrap_or_else(|e| { + eprintln!("{}", e); + std::process::exit(1); + }); + Self { name, options } + } +} + #[derive(Subcommand)] pub enum Commands { /// Execute a ggsql query @@ -35,15 +65,29 @@ pub enum Commands { query: String, /// Data source connection string (duckdb://, sqlite://, odbc://) - #[arg(long, default_value = "duckdb://memory")] + #[arg(short, long, default_value = "duckdb://memory")] reader: String, - /// Output format (vegalite) - #[arg(long, default_value = "vegalite")] + /// Output format: vegalite (JSON), or png (raster image; requires the + /// `png` feature and a GPU adapter) + #[arg(short, long, default_value = "vegalite")] writer: String, + /// Settings for the chosen writer, as `key=value`. Repeatable, and one + /// flag may carry several settings separated by `;` (quote it, as most + /// shells read `;` themselves): `-D 'width=1600;dpi=150'`. The + /// png writer takes width, height, units, dpi, and background; + /// the vegalite writer takes none. + #[arg( + short = 'D', + long = "writer-option", + visible_alias = "writer-options", + value_name = "KEY=VALUE[;...]" + )] + writer_options: Vec, + /// Output file path - #[arg(long)] + #[arg(short, long)] output: Option, /// Show verbose output (execution details, statistics) @@ -57,15 +101,29 @@ pub enum Commands { file: PathBuf, /// Data source connection string (duckdb://, sqlite://, odbc://) - #[arg(long, default_value = "duckdb://memory")] + #[arg(short, long, default_value = "duckdb://memory")] reader: String, - /// Output format (vegalite) - #[arg(long, default_value = "vegalite")] + /// Output format: vegalite (JSON), or png (raster image; requires the + /// `png` feature and a GPU adapter) + #[arg(short, long, default_value = "vegalite")] writer: String, + /// Settings for the chosen writer, as `key=value`. Repeatable, and one + /// flag may carry several settings separated by `;` (quote it, as most + /// shells read `;` themselves): `-D 'width=1600;dpi=150'`. The + /// png writer takes width, height, units, dpi, and background; + /// the vegalite writer takes none. + #[arg( + short = 'D', + long = "writer-option", + visible_alias = "writer-options", + value_name = "KEY=VALUE[;...]" + )] + writer_options: Vec, + /// Output file path - #[arg(long)] + #[arg(short, long)] output: Option, /// Show verbose output (execution details, statistics) @@ -89,7 +147,7 @@ pub enum Commands { query: String, /// Data source connection string for column validation (duckdb://, sqlite://, polars://) - #[arg(long)] + #[arg(short, long)] reader: Option, }, @@ -148,26 +206,30 @@ fn main() -> anyhow::Result<()> { query, reader, writer, + writer_options, output, verbose, } => { if verbose { eprintln!("Executing query: {}", query); } - cmd_exec(query, reader, writer, output, verbose); + let writer = WriterSpec::new(writer, writer_options); + cmd_exec(query, reader, &writer, output, verbose); } Commands::Run { file, reader, writer, + writer_options, output, verbose, } => { if verbose { eprintln!("Running query from file: {}", file.display()); } - cmd_run(file, reader, writer, output, verbose); + let writer = WriterSpec::new(writer, writer_options); + cmd_run(file, reader, &writer, output, verbose); } Commands::Parse { query, format } => { @@ -194,7 +256,13 @@ fn main() -> anyhow::Result<()> { Ok(()) } -fn cmd_run(file: PathBuf, reader: String, writer: String, output: Option, verbose: bool) { +fn cmd_run( + file: PathBuf, + reader: String, + writer: &WriterSpec, + output: Option, + verbose: bool, +) { match std::fs::read_to_string(&file) { Ok(query) => cmd_exec(query, reader, writer, output, verbose), Err(e) => { @@ -204,10 +272,16 @@ fn cmd_run(file: PathBuf, reader: String, writer: String, output: Option, verbose: bool) { +fn cmd_exec( + query: String, + reader: String, + writer: &WriterSpec, + output: Option, + verbose: bool, +) { if verbose { eprintln!("Reader: {}", reader); - eprintln!("Writer: {}", writer); + eprintln!("Writer: {}", writer.name); if let Some(ref output_file) = output { eprintln!("Output: {}", output_file.display()); } @@ -223,7 +297,7 @@ fn cmd_exec(query: String, reader: String, writer: String, output: Option( query: &str, reader: &R, - writer: &str, + writer: &WriterSpec, output: Option, verbose: bool, ) { @@ -309,7 +383,7 @@ fn exec_with_reader( render_spec(spec, writer, output, verbose); } -fn render_spec(spec: Spec, writer: &str, output: Option, verbose: bool) { +fn render_spec(spec: Spec, writer: &WriterSpec, output: Option, verbose: bool) { if verbose { let metadata = spec.metadata(); eprintln!("\nQuery executed:"); @@ -323,47 +397,53 @@ fn render_spec(spec: Spec, writer: &str, output: Option, verbose: bool) std::process::exit(1); } - // Check writer - if writer != "vegalite" { - eprintln!("\nNote: Writer '{}' not yet implemented", writer); - eprintln!("Available writers: vegalite") - } - - #[cfg(not(feature = "vegalite"))] - { - eprintln!("VegaLite writer not compiled in. Rebuild with --features vegalite"); - std::process::exit(1) - } - - // Render - let vl_writer = VegaLiteWriter::new(); - let json_output = match vl_writer.render(&spec) { - Ok(r) => r, - Err(e) => { - eprintln!("Failed to generate Vega-Lite output: {}", e); - std::process::exit(1); + let render = match writer.name.as_str() { + "vegalite" => render_vegalite(&spec, &writer.options), + "png" => render_png(&spec, &writer.options), + other => { + eprintln!("Unknown writer '{}'", other); + eprintln!("Available writers: png, vegalite"); + std::process::exit(1) } }; - if output.is_none() { - // Empty output location, write to stdout - println!("{}", json_output); - return; - } - let output = output.unwrap(); - - // Write to file - match std::fs::write(&output, json_output) { - Ok(_) => { - if verbose { - eprintln!("\nVega-Lite JSON written to: {}", output.display()); - } + match (render, output) { + (Output::Text(txt), None) => { + println!("{}", txt); } - Err(e) => { - eprintln!("Failed to write to output file: {}", e); - std::process::exit(1); + (Output::Text(txt), Some(path)) => match std::fs::write(&path, txt) { + Ok(_) => { + if verbose { + eprintln!("\nVega-Lite JSON written to: {}", path.display()); + } + } + Err(e) => { + eprintln!("Failed to write to output file: {}", e); + std::process::exit(1); + } + }, + (Output::Bin(buf), None) => { + if std::io::stdout().is_terminal() { + eprintln!("Suppressing output in terminal. Pipe output to another process or use --output to save to a file."); + } else { + std::io::stdout().write_all(&buf).unwrap_or_else(|e| { + eprintln!("Failed to write buffer with the error: {}", e); + std::process::exit(1); + }); + } } - } + (Output::Bin(buf), Some(path)) => match std::fs::write(&path, buf) { + Ok(_) => { + if verbose { + eprintln!("\nPNG written to: {}", path.display()); + } + } + Err(e) => { + eprintln!("Failed to write to output file: {}", e); + std::process::exit(1); + } + }, + }; } fn cmd_parse(query: String, format: String) { @@ -710,3 +790,55 @@ fn cmd_skill(format: Option) { } } } + +fn render_vegalite(spec: &Spec, options: &WriterOptions) -> Output { + #[cfg(feature = "vegalite")] + { + // Configure from --writer-option, then render + let vl_writer = unwrap_writer(VegaLiteWriter::from_options(options)); + match vl_writer.render(spec) { + Ok(json) => Output::Text(json), + Err(e) => { + eprintln!("Failed to generate Vega-Lite output: {}", e); + std::process::exit(1); + } + } + } + #[cfg(not(feature = "vegalite"))] + { + let _ = (spec, options); + eprintln!("VegaLite writer not compiled in. Rebuild with --features vegalite"); + std::process::exit(1) + } +} + +fn render_png(spec: &Spec, options: &WriterOptions) -> Output { + #[cfg(feature = "png")] + { + // Configure from --writer-option, then render + let png_writer = unwrap_writer(PngWriter::from_options(options)); + match png_writer.render(spec) { + Ok(png) => Output::Bin(png), + Err(e) => { + eprintln!("Failed to generate PNG output: {}", e); + std::process::exit(1); + } + } + } + #[cfg(not(feature = "png"))] + { + let _ = (spec, options); + eprintln!("PNG writer not compiled in. Rebuild with --features png"); + std::process::exit(1) + } +} + +/// A writer built from its options, or the option error on stderr and a +/// non-zero exit — an unusable setting is the user's mistake, not a warning. +#[cfg(any(feature = "vegalite", feature = "png"))] +fn unwrap_writer(writer: ggsql::Result) -> W { + writer.unwrap_or_else(|e| { + eprintln!("{}", e); + std::process::exit(1); + }) +} diff --git a/src/CLAUDE.md b/src/CLAUDE.md index b9b68dc91..9082c55f1 100644 --- a/src/CLAUDE.md +++ b/src/CLAUDE.md @@ -65,7 +65,12 @@ The pipeline that takes a parsed `Plot` plus a `Reader` and produces a fully-res ### `writer/` -`Writer` trait in `mod.rs` (associated `Output` type so writers can return text or bytes). Only Vega-Lite is implemented today; `ggplot2`, `plotters` are reserved feature flags. Implementation deep-dive: [`writer/vegalite/CLAUDE.md`](writer/vegalite/CLAUDE.md). +`Writer` trait in `mod.rs` (associated `Output` type so writers can return text or bytes, and `from_options` for configuration a frontend collects as key–value pairs — `options.rs`'s `WriterOptions`, parsed from the CLI's `--writer-option`). Two implementations: + +- **Vega-Lite** (`vegalite` feature, default) — emits Vega-Lite JSON. Deep-dive: [`writer/vegalite/CLAUDE.md`](writer/vegalite/CLAUDE.md). +- **PNG** (`png` feature, non-default) — `PngWriter` renders PNG bytes via a GPU (wgpu/vello) backend. The module implementing it is `writer/hephaestus/`, after the renderer it wraps; that name is internal, and the module is private so only `PngWriter` is public. Deep-dive (architecture + known gaps): [`writer/hephaestus/CLAUDE.md`](writer/hephaestus/CLAUDE.md). Excluded from the MSRV 1.86 build (hephaestus needs 1.88) and needs a GPU adapter at render time. + +`ggplot2` and `plotters` are reserved feature flags with no implementation. ### `plot/` @@ -98,6 +103,7 @@ Defined in `Cargo.toml`: | `parquet` | ✓ | Parquet support in readers/data | | `spatial` | ✓ | Spatial/geometry support (geozero for WKT↔GeoJSON) | | `vegalite` | ✓ | Vega-Lite writer | +| `png` | — | PNG raster writer (GPU; excluded from the MSRV build) | | `builtin-data` | ✓ | Bundled penguins/airquality datasets | | `all-readers` | — | `duckdb` + `sqlite` + `odbc` | diff --git a/src/Cargo.toml b/src/Cargo.toml index 95614b06d..3c8c8ec58 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -39,6 +39,9 @@ adbc_core = { version = "0.23", optional = true } # Spatial geozero = { workspace = true, optional = true, features = ["with-wkb", "with-wkt", "with-geojson"] } +# Backend for the PNG writer (non-default; gated, excluded from the MSRV 1.86 build) +hephaestus = { version = "0.1.0", optional = true, default-features = false, features = ["vello", "png", "geom-wkb", "geom-wkt"] } + # Serialization serde.workspace = true serde_json.workspace = true @@ -70,5 +73,6 @@ adbc = ["dep:adbc_core"] odbc = ["dep:toml_edit", "dep:libloading"] spatial = ["dep:geozero", "rusqlite?/load_extension"] vegalite = [] +png = ["dep:hephaestus"] builtin-data = [] all-readers = ["duckdb", "sqlite", "odbc"] diff --git a/src/doc/API.md b/src/doc/API.md index 2dea4edc2..09e0c2ba4 100644 --- a/src/doc/API.md +++ b/src/doc/API.md @@ -389,11 +389,56 @@ pub trait Reader { ```rust pub trait Writer { - /// Render a plot specification to output format - fn write(&self, spec: &Plot, data: &HashMap) -> Result; + /// What this writer produces — `String` for Vega-Lite JSON, `Vec` for PNG + type Output; - /// Get the file extension for this writer's output - fn file_extension(&self) -> &str; + /// Build the writer from key–value options (see `WriterOptions`) + fn from_options(options: &WriterOptions) -> Result where Self: Sized; + + /// Render a plot specification and its data to the output format + fn write(&self, spec: &Plot, data: &HashMap) -> Result; + + /// Check whether a spec can be rendered by this writer, without rendering it + fn validate(&self, spec: &Plot) -> Result<()>; + + /// Render a prepared `Spec` from `reader.execute()` — the usual entry point + fn render(&self, spec: &Spec) -> Result; } ``` +--- + +### `WriterOptions` + +Free-form key–value configuration for a writer, for callers that collect settings +from a user rather than in code (the CLI's repeatable `--writer-option +key=value`). Keys are normalised: trimmed, lowercased, `-` folded to `_`. + +```rust +let options = WriterOptions::parse(["width=1600", "height=1200", "units=px"])?; +let png = PngWriter::from_options(&options)?.render(&spec)?; + +// One string may carry several options, separated by `;`. Equivalent to the above: +let options = WriterOptions::parse(["width=1600;height=1200;units=px"])?; + +// Or in code, without going through strings: +let options = WriterOptions::new().set("dpi", "150"); +``` + +`;` is the only separator — `,` is not, since values contain commas +(`background=rgba(0,0,0,0)`). + +| Method | Purpose | +| --- | --- | +| `parse(pairs)` | Build from `key=value` strings, `;`-separated within a string; errors on a missing `=` | +| `new()` / `set(key, value)` | Build programmatically | +| `get(key)` | Raw value, if supplied | +| `number(key)` | Value as a finite `f64`, erroring with the option's name | +| `one_of(key, allowed)` | Value checked against a closed set | +| `reject_unknown(known)` | Error naming keys the writer doesn't understand | +| `is_empty()` | Whether any option was supplied | + +Which keys a writer accepts is the writer's own business: +`VegaLiteWriter` takes none, `PngWriter` takes `width`, `height`, `units`, +`dpi`, and `background`. + diff --git a/src/execute/scale.rs b/src/execute/scale.rs index 0dc39c77f..d7ec25400 100644 --- a/src/execute/scale.rs +++ b/src/execute/scale.rs @@ -7,8 +7,9 @@ use crate::naming; use crate::plot::aesthetic::AestheticContext; use crate::plot::scale::{ - default_oob, gets_default_scale, infer_scale_target_type, infer_transform_from_input_range, - is_facet_aesthetic, transform::Transform, OOB_CENSOR, OOB_KEEP, OOB_SQUISH, + default_oob, gets_default_scale, infer_scale_target_type, infer_scale_type_from_input_range, + infer_transform_from_input_range, is_facet_aesthetic, transform::Transform, OOB_CENSOR, + OOB_KEEP, OOB_SQUISH, }; use crate::plot::{ AestheticValue, ArrayElement, ArrayElementType, ColumnInfo, Layer, ParameterValue, Plot, Scale, @@ -398,6 +399,16 @@ pub fn resolve_scale_types_and_transforms( ); if all_dtypes.is_empty() { + // No data trains this scale (e.g. a diagonal rule keeps its own + // position out of training). Lacking any other information, infer the + // type from an explicit input range if the user gave one. + if let Some(inferred) = scale + .input_range + .as_ref() + .and_then(|r| infer_scale_type_from_input_range(r)) + { + scale.scale_type = Some(inferred); + } continue; } diff --git a/src/plot/layer/geom/text.rs b/src/plot/layer/geom/text.rs index 20b07c2e0..d1eacde97 100644 --- a/src/plot/layer/geom/text.rs +++ b/src/plot/layer/geom/text.rs @@ -60,6 +60,11 @@ impl GeomTrait for Text { default: DefaultParamValue::Null, constraint: ParamConstraint::string(), }, + ParamDefinition { + name: "parse", + default: DefaultParamValue::Boolean(true), + constraint: ParamConstraint::boolean(), + }, super::types::AGGREGATE_PARAM, ]; PARAMS @@ -101,3 +106,47 @@ impl std::fmt::Display for Text { write!(f, "text") } } + +#[cfg(test)] +mod tests { + use crate::plot::types::ParameterValue; + use crate::plot::{Geom, Layer}; + + /// `parse` is on unless the user says otherwise, so a label carrying markdown + /// renders as rich text without asking. + #[test] + fn test_parse_defaults_to_true() { + let mut layer = Layer::new(Geom::text()); + layer.apply_default_params(); + assert_eq!( + layer.parameters.get("parse"), + Some(&ParameterValue::Boolean(true)) + ); + } + + /// An explicit `SETTING parse => false` survives default application. + #[test] + fn test_parse_setting_is_kept() { + let mut layer = Layer::new(Geom::text()); + layer + .parameters + .insert("parse".to_string(), ParameterValue::Boolean(false)); + layer.apply_default_params(); + assert_eq!( + layer.parameters.get("parse"), + Some(&ParameterValue::Boolean(false)) + ); + } + + /// `parse` is a boolean; anything else is a validation error rather than a + /// value coerced into one. + #[test] + fn test_parse_rejects_non_boolean() { + let mut layer = Layer::new(Geom::text()); + layer.parameters.insert( + "parse".to_string(), + ParameterValue::String("yes".to_string()), + ); + assert!(layer.validate_settings().is_err()); + } +} diff --git a/src/plot/layer/orientation.rs b/src/plot/layer/orientation.rs index e38a08ce3..7a39124ea 100644 --- a/src/plot/layer/orientation.rs +++ b/src/plot/layer/orientation.rs @@ -157,19 +157,26 @@ fn detect_from_scales( // is just customizing a scale (e.g., SCALE y SETTING expand) without intending // to change orientation. The geom's default_remappings will define orientation. // - // If the geom declares `pos1` as `Dummy` and the user hasn't mapped it, - // pos1 *is* the (synthetic) primary axis — leave the layer aligned so the - // stat fills it in. Auto-transposing in that case would push the dummy - // onto the secondary axis, which is never what the user means. + // When the geom declares `pos1` as `Dummy` (bar, boxplot) and the user + // mapped only `pos2`, the mapped scale's *type* says which axis they filled, + // because a dummy geom's two axes hold different things: + // - discrete `pos2` (`DRAW bar MAPPING species AS y`) is the category axis, + // so the layer transposes and the stat synthesizes the dummy on `pos1`; + // - continuous `pos2` (`DRAW boxplot MAPPING bill_len AS y`) is the *value* + // axis, which is where it already sits when aligned — transposing would + // push the categories onto the axis holding the measurements. + // This is Rule 3's discrete-axis-is-primary logic, applied early because + // only one scale exists for Rule 3 to compare. if has_pos1_mapping || has_pos2_mapping { - let pos1_is_dummy = matches!( - Geom::from_type(*geom).aesthetics().get("pos1"), - Some(DefaultAestheticValue::Dummy) - ); - if has_pos2 && !has_pos1 && (!pos1_is_dummy || has_pos1_mapping) { - return TRANSPOSED; - } - if has_pos1 && !has_pos2 { + if has_pos2 && !has_pos1 { + let pos1_is_dummy = matches!( + Geom::from_type(*geom).aesthetics().get("pos1"), + Some(DefaultAestheticValue::Dummy) + ); + if !pos1_is_dummy || has_pos1_mapping || pos2_scale.is_some_and(is_discrete_scale) { + return TRANSPOSED; + } + } else if has_pos1 && !has_pos2 { return ALIGNED; } } diff --git a/src/plot/layer/position/dodge.rs b/src/plot/layer/position/dodge.rs index 58a022ae4..9736fd751 100644 --- a/src/plot/layer/position/dodge.rs +++ b/src/plot/layer/position/dodge.rs @@ -7,8 +7,8 @@ //! - If both are discrete → 2D grid dodge (both offsets, arranged in a grid) use super::{ - compute_dodge_offsets, is_continuous_scale, non_facet_partition_cols, Layer, PositionTrait, - PositionType, + compute_dodge_offsets, groups_share_a_position, is_continuous_scale, non_facet_partition_cols, + Layer, PositionTrait, PositionType, }; use crate::array_util::{new_f64_array_non_null, value_to_string}; use crate::plot::types::{DefaultParamValue, ParamConstraint, ParamDefinition, ParameterValue}; @@ -174,6 +174,12 @@ fn apply_dodge_with_width( return Ok((df, None)); } + // Several groups, but nothing to separate unless two of them share a + // position — see `groups_share_a_position`. + if !groups_share_a_position(&df, &indices, dodge_pos1, dodge_pos2, spec) { + return Ok((df, None)); + } + // Get the default bar width from layer parameters (or use 0.9 as default) let bar_width = layer .parameters @@ -223,10 +229,12 @@ mod tests { use crate::plot::layer::Geom; use crate::plot::{AestheticValue, Mappings, Scale, ScaleType}; + /// Two groups (X, Y) meeting at every position, on either axis: dodge has + /// something to separate whichever axis is the discrete one. fn make_test_df() -> DataFrame { df! { "__ggsql_aes_pos1__" => vec!["A", "A", "B", "B"], - "__ggsql_aes_pos2__" => vec![10.0, 20.0, 15.0, 25.0], + "__ggsql_aes_pos2__" => vec![10.0, 10.0, 20.0, 20.0], "__ggsql_aes_pos2end__" => vec![0.0, 0.0, 0.0, 0.0], "__ggsql_aes_fill__" => vec!["X", "Y", "X", "Y"], } @@ -461,9 +469,10 @@ mod tests { // Test with 4 groups to verify 2x2 arrangement within 2x2 grid let dodge = Dodge; + // All four groups in one cell, which is what a 2D grid arrangement is for let df = df! { "__ggsql_aes_pos1__" => vec!["A", "A", "A", "A"], - "__ggsql_aes_pos2__" => vec![10.0, 20.0, 15.0, 25.0], + "__ggsql_aes_pos2__" => vec![10.0, 10.0, 10.0, 10.0], "__ggsql_aes_fill__" => vec!["G1", "G2", "G3", "G4"], } .unwrap(); @@ -569,6 +578,56 @@ mod tests { assert!(width.is_none()); } + #[test] + fn test_dodge_skipped_when_groups_have_their_own_position() { + // `fill` mapped to the same column as the categorical axis: three groups, + // but each one alone on its position, so there is nothing to dodge apart. + let dodge = Dodge; + + let df = df! { + "__ggsql_aes_pos1__" => vec!["A", "A", "B", "B", "C", "C"], + "__ggsql_aes_pos2__" => vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], + "__ggsql_aes_offset__" => vec![0.4, 0.3, 0.4, 0.3, 0.4, 0.3], + "__ggsql_aes_fill__" => vec!["A", "A", "B", "B", "C", "C"], + } + .unwrap(); + + let mut layer = Layer::new(Geom::violin()); + layer.partition_by = vec!["__ggsql_aes_fill__".to_string()]; + + let mut spec = Plot::new(); + spec.scales.push(make_discrete_scale("pos1")); + spec.scales.push(make_continuous_scale("pos2")); + + let (result, width) = dodge.apply_adjustment(df, &layer, &spec).unwrap(); + + assert!( + result.column("__ggsql_aes_pos1offset__").is_err(), + "no group shares a position, so no dodge offset should be created" + ); + assert!(width.is_none(), "the layer keeps its own width"); + + // The violin's half-widths are left alone too — narrowing them by the + // group count is part of the same adjustment. + let offsets = as_f64(result.column("__ggsql_aes_offset__").unwrap()).unwrap(); + assert!((offsets.value(0) - 0.4).abs() < 1e-9, "offsets unscaled"); + + // One group joining another on its position brings dodging back. + let df = df! { + "__ggsql_aes_pos1__" => vec!["A", "A", "B", "B", "C", "C"], + "__ggsql_aes_pos2__" => vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], + "__ggsql_aes_offset__" => vec![0.4, 0.3, 0.4, 0.3, 0.4, 0.3], + "__ggsql_aes_fill__" => vec!["A", "A", "B", "B", "C", "B"], + } + .unwrap(); + let (result, width) = dodge.apply_adjustment(df, &layer, &spec).unwrap(); + assert!( + result.column("__ggsql_aes_pos1offset__").is_ok(), + "groups B and C share position C, so the layer dodges" + ); + assert!(width.is_some()); + } + #[test] fn test_dodge_custom_width() { let dodge = Dodge; diff --git a/src/plot/layer/position/jitter.rs b/src/plot/layer/position/jitter.rs index b386aaf15..a1e71aae8 100644 --- a/src/plot/layer/position/jitter.rs +++ b/src/plot/layer/position/jitter.rs @@ -15,8 +15,8 @@ //! - `normal`: normal/Gaussian distribution with ~95% of points within the width use super::{ - compute_dodge_offsets, compute_group_indices, is_continuous_scale, non_facet_partition_cols, - Layer, PositionTrait, PositionType, + compute_dodge_offsets, compute_group_indices, groups_share_a_position, is_continuous_scale, + non_facet_partition_cols, Layer, PositionTrait, PositionType, }; use crate::array_util::{as_f64, cast_array, new_f64_array_non_null}; use crate::plot::layer::geom::types::SIDE_VALUES; @@ -560,9 +560,17 @@ fn apply_jitter(df: DataFrame, layer: &Layer, spec: &Plot) -> Result None }; - // Extract group info for dodge behavior + // Extract group info for dodge behavior. Groups that never meet on a + // position have nothing to be dodged apart, and jittering them within + // `1/n` of the band would only narrow the spread — see + // `groups_share_a_position`. let (n_groups, group_indices) = match &group_info { - Some(info) if info.n_groups > 1 => (info.n_groups, Some(&info.indices)), + Some(info) + if info.n_groups > 1 + && groups_share_a_position(&df, &info.indices, jitter_pos1, jitter_pos2, spec) => + { + (info.n_groups, Some(&info.indices)) + } _ => (1, None), }; diff --git a/src/plot/layer/position/mod.rs b/src/plot/layer/position/mod.rs index a865e575d..4330b3f89 100644 --- a/src/plot/layer/position/mod.rs +++ b/src/plot/layer/position/mod.rs @@ -32,6 +32,66 @@ pub fn is_continuous_scale(spec: &Plot, aesthetic: &str) -> Option { .map(|st| st.scale_type_kind() == ScaleTypeKind::Continuous) } +/// Whether any single position actually carries more than one group. +/// +/// Dodging exists to separate elements that would otherwise be drawn on top of +/// each other, so a grouping that never puts two groups in the same place needs +/// none: mapping `fill` to the same column as the categorical axis gives one +/// group per position, and dodging it anyway would squeeze every element into +/// `1/n` of its band and shift it away from its own category for nothing. +/// +/// A position is the value of each discrete axis being dodged, together with the +/// facet columns — two groups drawn in different panels do not overlap either. +/// When none of those columns is available the answer is "yes", so an unknown +/// case dodges as before rather than silently dropping the adjustment. +pub fn groups_share_a_position( + df: &DataFrame, + indices: &[usize], + dodge_pos1: bool, + dodge_pos2: bool, + spec: &Plot, +) -> bool { + let mut position_cols: Vec = [(dodge_pos1, "pos1"), (dodge_pos2, "pos2")] + .into_iter() + .filter(|(dodged, _)| *dodged) + .map(|(_, aesthetic)| crate::naming::aesthetic_column(aesthetic)) + .collect(); + if let Some(facet) = &spec.facet { + position_cols.extend( + facet + .layout + .internal_facet_names() + .into_iter() + .map(|aes| crate::naming::aesthetic_column(&aes)), + ); + } + + let columns: Vec<_> = position_cols + .iter() + .filter_map(|name| df.column(name).ok()) + .collect(); + if columns.is_empty() { + return true; + } + + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); + for (row, &group) in indices.iter().enumerate() { + let key = columns + .iter() + .map(|col| crate::array_util::value_to_string(col, row)) + .collect::>() + .join("\x00"); + match seen.get(&key) { + Some(&first) if first != group => return true, + Some(_) => {} + None => { + seen.insert(key, group); + } + } + } + false +} + /// Result of computing dodge offsets for position adjustment. pub struct DodgeOffsets { /// Offset for pos1 axis (None if not dodging pos1) diff --git a/src/plot/scale/breaks.rs b/src/plot/scale/breaks.rs index 102063ebd..1811da940 100644 --- a/src/plot/scale/breaks.rs +++ b/src/plot/scale/breaks.rs @@ -289,24 +289,26 @@ pub fn integer_breaks(min: f64, max: f64, n: usize, pretty: bool) -> Vec { } /// Filter breaks to only those within the given range. +/// +/// Both sides are compared through [`ArrayElement::to_f64`], which is the space a +/// scale's domain and breaks share once its transform has parsed them, so a +/// temporal break is constrained by a temporal domain just as a number is by a +/// numeric one. An element with no numeric form (a category, a boolean) is kept. pub fn filter_breaks_to_range( breaks: &[ArrayElement], range: &[ArrayElement], ) -> Vec { - let (min, max) = match (range.first(), range.last()) { - (Some(ArrayElement::Number(min)), Some(ArrayElement::Number(max))) => (*min, *max), - _ => return breaks.to_vec(), // Can't filter non-numeric + let (min, max) = match ( + range.first().and_then(|e| e.to_f64()), + range.last().and_then(|e| e.to_f64()), + ) { + (Some(min), Some(max)) => (min, max), + _ => return breaks.to_vec(), // Can't filter against a non-numeric range }; breaks .iter() - .filter(|b| { - if let ArrayElement::Number(v) = b { - *v >= min && *v <= max - } else { - true // Keep non-numeric breaks - } - }) + .filter(|b| b.to_f64().is_none_or(|v| v >= min && v <= max)) .cloned() .collect() } @@ -585,8 +587,18 @@ pub fn minor_breaks_linear(major_breaks: &[f64], n: usize, range: Option<(f64, f let step = interval / (n + 1) as f64; + // Extrapolating past the outermost majors only makes sense when the majors + // share one rhythm — then the minors outside continue the pattern of the + // ones inside. With user-supplied uneven majors (`breaks => (37, 42, 55)`) + // there is no such rhythm: `step` comes from the *first* interval only, so + // extrapolated minors sit at a spacing matching no part of the axis and read + // as stray ticks beyond the last label. Those axes get interior minors only. + let evenly_spaced = major_breaks + .windows(2) + .all(|w| (w[1] - w[0] - interval).abs() <= interval.abs() * 1e-9); + // If range extends before first major break, extrapolate backwards - if let Some((min, _)) = range { + if let (true, Some((min, _))) = (evenly_spaced, range) { let first_major = major_breaks[0]; let mut pos = first_major - step; while pos >= min { @@ -608,7 +620,7 @@ pub fn minor_breaks_linear(major_breaks: &[f64], n: usize, range: Option<(f64, f } // If range extends beyond last major break, extrapolate forwards - if let Some((_, max)) = range { + if let (true, Some((_, max))) = (evenly_spaced, range) { let last_major = *major_breaks.last().unwrap(); let mut pos = last_major + step; while pos <= max { diff --git a/src/plot/scale/mod.rs b/src/plot/scale/mod.rs index b334ab227..fe12bc7e4 100644 --- a/src/plot/scale/mod.rs +++ b/src/plot/scale/mod.rs @@ -97,3 +97,17 @@ pub fn infer_scale_target_type(scale: &Scale) -> Option { ScaleTypeKind::Identity => None, } } + +/// Infer a scale type from an explicit input range, used when no data trains the +/// scale (e.g. a diagonal rule that keeps its own position out of scale +/// training, but the user gave `SCALE x FROM (0, 10)`). A numeric or temporal +/// range is continuous; a string/boolean range is discrete. +pub fn infer_scale_type_from_input_range(range: &[ArrayElement]) -> Option { + match ArrayElement::infer_type(range)? { + ArrayElementType::Number + | ArrayElementType::Date + | ArrayElementType::DateTime + | ArrayElementType::Time => Some(ScaleType::continuous()), + ArrayElementType::Boolean | ArrayElementType::String => Some(ScaleType::discrete()), + } +} diff --git a/src/plot/scale/scale_type/continuous.rs b/src/plot/scale/scale_type/continuous.rs index 22c26c93b..9de87177f 100644 --- a/src/plot/scale/scale_type/continuous.rs +++ b/src/plot/scale/scale_type/continuous.rs @@ -128,6 +128,19 @@ impl ScaleTypeTrait for Continuous { ArrayConstraint::of_numbers(NumberConstraint::unconstrained()), ), }, + ParamDefinition { + name: "minor_breaks", + // No static default: the effective one is the transform's + // `default_minor_break_count()` (1 linear, 8 log family, 3 temporal), + // resolved in `ScaleTypeTrait::resolve`. + default: DefaultParamValue::Null, + // Number (minors *per major interval*, 0 for none), Array of numbers + // (explicit positions), or String (temporal interval). + constraint: ParamConstraint::number_or_array_or_string( + NumberConstraint::count(0.0), + ArrayConstraint::of_numbers(NumberConstraint::unconstrained()), + ), + }, ParamDefinition { name: "pretty", default: DefaultParamValue::Boolean(true), diff --git a/src/plot/scale/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index 4b2c7df83..3d52853cb 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -27,7 +27,9 @@ use std::sync::Arc; use super::transform::{Transform, TransformKind}; use crate::plot::aesthetic::{is_facet_aesthetic, is_position_aesthetic}; -use crate::plot::types::{validate_parameter, DefaultParamValue, ParamDefinition, Parameters}; +use crate::plot::types::{ + format_number, validate_parameter, DefaultParamValue, ParamDefinition, Parameters, +}; use crate::plot::{ArrayElement, ColumnInfo, ParameterValue}; // Scale type implementations @@ -810,13 +812,23 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { /// Labelled breaks: `(numeric_position, display_label)` pairs. /// - /// Default: pairs each `numeric_breaks()` value with its string form. + /// Default: labels each break from its own typed `ArrayElement`, so a + /// temporal break reads as its ISO string rather than as the epoch number + /// its position projects to. The label is the element's `to_key_string()`, + /// which is also how `label_mapping` is keyed — so the `RENAMING` and + /// label-template overrides the caller applies actually match. /// Discrete/ordinal override to pair position indices with input-range - /// category names. `label_mapping` overrides are applied by the caller. + /// category names. fn break_labels(&self, scale: &super::Scale) -> Vec<(f64, String)> { + if let Some(ParameterValue::Array(breaks)) = scale.properties.get("breaks") { + return breaks + .iter() + .filter_map(|b| b.to_f64().map(|v| (v, b.to_key_string()))) + .collect(); + } self.numeric_breaks(scale) .into_iter() - .map(|v| (v, format!("{v}"))) + .map(|v| (v, format_number(v))) .collect() } @@ -903,56 +915,13 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { Some(ParameterValue::String(interval_str)) => { // Temporal interval string like "2 months", "week" // Only valid for temporal transforms (Date, DateTime, Time) - use super::super::breaks::{ - temporal_breaks_date, temporal_breaks_datetime, temporal_breaks_time, - TemporalInterval, - }; - - if let Some(interval) = TemporalInterval::create_from_str(interval_str) { - if let Some(ref range) = scale.input_range { - let breaks: Vec = match resolved_transform - .transform_kind() - { - TransformKind::Date => { - let min = range[0].to_f64().unwrap_or(0.0) as i32; - let max = range[range.len() - 1].to_f64().unwrap_or(0.0) as i32; - temporal_breaks_date(min, max, interval) - .into_iter() - .map(ArrayElement::String) - .collect() - } - TransformKind::DateTime => { - let min = range[0].to_f64().unwrap_or(0.0) as i64; - let max = range[range.len() - 1].to_f64().unwrap_or(0.0) as i64; - temporal_breaks_datetime(min, max, interval) - .into_iter() - .map(ArrayElement::String) - .collect() - } - TransformKind::Time => { - let min = range[0].to_f64().unwrap_or(0.0) as i64; - let max = range[range.len() - 1].to_f64().unwrap_or(0.0) as i64; - temporal_breaks_time(min, max, interval) - .into_iter() - .map(ArrayElement::String) - .collect() - } - _ => vec![], // Non-temporal transforms don't support interval strings - }; - - if !breaks.is_empty() { - // Convert string breaks to appropriate temporal ArrayElement types - let converted: Vec = breaks - .iter() - .map(|elem| resolved_transform.parse_value(elem)) - .collect(); - // Filter to input range - let filtered = - super::super::breaks::filter_breaks_to_range(&converted, range); - scale - .properties - .insert("breaks".to_string(), ParameterValue::Array(filtered)); - } + if let Some(range) = scale.input_range.as_deref() { + if let Some(breaks) = + temporal_interval_breaks(interval_str, range, &resolved_transform) + { + scale + .properties + .insert("breaks".to_string(), ParameterValue::Array(breaks)); } } } @@ -960,6 +929,69 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { } } + // 5b. Resolve minor breaks against the majors from step 5. + // + // Break positions are ggsql's to own, minor as well as major: a writer reads + // these through `Scale::numeric_minor_breaks()` and never generates its own. + // `minor_breaks` mirrors `breaks` — a count, explicit positions, or a temporal + // interval string — except that its count is *per major interval* rather than a + // target for the whole axis, because that is the unit a minor break is defined + // in. Absent, the transform's own density applies (one midpoint for linear, the + // 2-9 ladder for the log family, three per interval for temporal). + // + // `Some(vec![])` and `None` mean different things downstream: the first is + // "resolved to no minors" (`minor_breaks => 0`, which a writer must honour by + // drawing none) and the second is "not resolved", which leaves a writer free to + // fall back on its own. Binned overrides `resolve` and so never reaches this: a + // binned axis's ticks are its bin edges, with nothing to subdivide. Runs before + // the label template because minor breaks carry no labels. + if self.supports_breaks() { + if let (Some(ParameterValue::Array(majors)), Some(range)) = + (scale.properties.get("breaks"), scale.input_range.as_deref()) + { + let positions: Vec = majors.iter().filter_map(|b| b.to_f64()).collect(); + let extent = match ( + range.first().and_then(|e| e.to_f64()), + range.last().and_then(|e| e.to_f64()), + ) { + (Some(min), Some(max)) => Some((min, max)), + _ => None, + }; + let derive = |n: usize| -> Vec { + resolved_transform + .calculate_minor_breaks(&positions, n, extent) + .into_iter() + .map(|v| resolved_transform.wrap_numeric(v)) + .collect() + }; + let minors = match scale.properties.get("minor_breaks") { + // Explicit positions, converted through the transform like + // explicit majors are. + Some(ParameterValue::Array(explicit)) => explicit + .iter() + .map(|elem| resolved_transform.parse_value(elem)) + .collect(), + // Minors per major interval; 0 asks for none. + Some(ParameterValue::Number(n)) => derive(*n as usize), + // Calendar interval, same syntax the majors accept. + Some(ParameterValue::String(interval)) => { + temporal_interval_breaks(interval, range, &resolved_transform) + .unwrap_or_default() + } + _ => derive(resolved_transform.default_minor_break_count()), + }; + // Drop anything outside the domain, the same way the majors are + // filtered. + let minors = match scale.input_range.as_ref() { + Some(range) => super::super::breaks::filter_breaks_to_range(&minors, range), + None => minors, + }; + scale + .properties + .insert("minor_breaks".to_string(), ParameterValue::Array(minors)); + } + } + // 6. Apply label template (RENAMING * => '...') // Default is '{}' to ensure we control formatting instead of Vega-Lite // For continuous scales, apply to breaks array @@ -1118,17 +1150,18 @@ pub(super) fn categorical_numeric_domain(scale: &super::Scale) -> Option<(f64, f } /// Labelled breaks for categorical scales: pairs position indices with category names. +/// +/// The label doubles as the lookup key into `label_mapping`, so it must be the +/// element's `to_key_string()` — the same form `RENAMING` writes its keys in. +/// Formatting a number through `to_json()` instead yields `"6.0"` against a key +/// of `"6"`, which silently drops every rename on a non-string domain. pub(super) fn categorical_break_labels(scale: &super::Scale) -> Vec<(f64, String)> { let Some(range) = scale.input_range.as_ref() else { return Vec::new(); }; let mut out = Vec::with_capacity(range.len()); for (i, elem) in range.iter().enumerate() { - let label = match elem { - ArrayElement::String(s) => s.clone(), - other => format!("{}", other.to_json()), - }; - out.push(((i + 1) as f64, label)); + out.push(((i + 1) as f64, elem.to_key_string())); } out } @@ -1720,7 +1753,6 @@ pub(crate) fn expand_numeric_range_selective( } /// Get expand factors from properties, using defaults for continuous/temporal scales. -#[allow(dead_code)] pub(crate) fn get_expand_factors(properties: &Parameters) -> (f64, f64) { properties .get("expand") @@ -1759,6 +1791,55 @@ pub(crate) fn get_expand_factors_for_aesthetic( (DEFAULT_EXPAND_MULT, DEFAULT_EXPAND_ADD) } +/// Break positions for a temporal interval string like `"2 months"` or `"week"`, +/// wrapped in the transform's value variant and filtered to `range`. +/// +/// `None` when the string isn't a recognisable interval, when the transform isn't +/// temporal (only Date / DateTime / Time have a calendar to step through), or when +/// the interval yields nothing inside the range — in each case the caller keeps +/// whatever it had. Shared by the `breaks` and `minor_breaks` settings, which accept +/// the same interval syntax. +pub(crate) fn temporal_interval_breaks( + interval_str: &str, + range: &[ArrayElement], + transform: &Transform, +) -> Option> { + use super::breaks::{ + filter_breaks_to_range, temporal_breaks_date, temporal_breaks_datetime, + temporal_breaks_time, TemporalInterval, + }; + + let interval = TemporalInterval::create_from_str(interval_str)?; + let min = range.first()?.to_f64()?; + let max = range.last()?.to_f64()?; + let breaks: Vec = match transform.transform_kind() { + TransformKind::Date => temporal_breaks_date(min as i32, max as i32, interval) + .into_iter() + .map(ArrayElement::String) + .collect(), + TransformKind::DateTime => temporal_breaks_datetime(min as i64, max as i64, interval) + .into_iter() + .map(ArrayElement::String) + .collect(), + TransformKind::Time => temporal_breaks_time(min as i64, max as i64, interval) + .into_iter() + .map(ArrayElement::String) + .collect(), + // Non-temporal transforms have no calendar to step through. + _ => return None, + }; + if breaks.is_empty() { + return None; + } + // Convert the ISO strings to the transform's temporal variant, then drop any + // that fell outside the domain. + let converted: Vec = breaks + .iter() + .map(|elem| transform.parse_value(elem)) + .collect(); + Some(filter_breaks_to_range(&converted, range)) +} + /// Clip an input range to a transform's valid domain. /// /// This prevents expansion from producing invalid values for transforms @@ -1911,12 +1992,14 @@ pub(crate) fn resolve_common_steps( // // Then clip to the transform's valid domain to prevent invalid values // (e.g., expansion producing negative values for log scales) + let mut applied_expand = (0.0, 0.0); if let Some(range) = base_range { let is_position = is_position_aesthetic(aesthetic); let is_deduced = !scale.explicit_input_range || input_range_has_nulls(original_user_range.as_deref().unwrap_or(&[])); if !is_discrete_range && is_position && is_deduced { + applied_expand = (mult, add); let expanded = expand_numeric_range_selective(&range, mult, add, original_user_range.as_deref()); scale.input_range = Some(clip_to_transform_domain(&expanded, &resolved_transform)); @@ -1926,6 +2009,28 @@ pub(crate) fn resolve_common_steps( } } + // Record the factors that were actually applied, normalised to [mult, add], + // so a consumer that has to expand a range of its own later — a writer + // computing a per-panel domain for a free facet dimension, via + // `Scale::expand_range` — pads it exactly as resolution padded the global + // one. Two things are only visible from here: `context.default_expand` (zero + // for a polar full-circle theta), and the decision *not* to expand at all, + // which an explicit `FROM (0, 100)` makes. + // + // Only where `expand` is a property the scale accepts in the first place + // (`resolve_properties` drops the default for a material aesthetic, and a + // discrete scale rejects the parameter outright) — recording it elsewhere + // would manufacture state that user input could not have produced. + if is_position_aesthetic(aesthetic) && scale.properties.contains_key("expand") { + scale.properties.insert( + "expand".to_string(), + ParameterValue::Array(vec![ + ArrayElement::Number(applied_expand.0), + ArrayElement::Number(applied_expand.1), + ]), + ); + } + // 4. Convert input_range values using transform (e.g., ISO strings → Date/DateTime/Time) // This ensures temporal scales properly parse user-provided date strings if let Some(ref input_range) = scale.input_range { @@ -3222,6 +3327,217 @@ mod tests { } } + #[test] + fn test_resolve_derives_minor_breaks_between_majors() { + use crate::plot::scale::Scale; + + let mut scale = Scale::new("x"); + scale.scale_type = Some(ScaleType::continuous()); + scale.input_range = Some(vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]); + scale + .properties + .insert("breaks".to_string(), ParameterValue::Number(5.0)); + + let context = ScaleDataContext::new(); + ScaleType::continuous() + .resolve(&mut scale, &context, "x") + .unwrap(); + + // Identity asks for one minor per major interval, so minors interleave the + // majors and land on none of them. + let majors = scale.numeric_breaks(); + let minors = scale.numeric_minor_breaks().expect("minors resolved"); + assert!(!minors.is_empty(), "minors should be derived from majors"); + for m in &minors { + assert!( + !majors.iter().any(|j| (j - m).abs() < 1e-9), + "minor {m} coincides with a major: {majors:?}" + ); + } + for w in majors.windows(2) { + assert_eq!( + minors.iter().filter(|m| **m > w[0] && **m < w[1]).count(), + 1, + "expected one minor in ({}, {}): {minors:?}", + w[0], + w[1] + ); + } + } + + #[test] + fn test_resolve_derives_temporal_minor_breaks_as_dates() { + use crate::plot::scale::Scale; + + let mut scale = Scale::new("x"); + scale.scale_type = Some(ScaleType::continuous()); + scale.transform = Some(Transform::date()); + scale.input_range = Some(vec![ + ArrayElement::Date(19738), // 2024-01-15 + ArrayElement::Date(19889), // 2024-06-15 + ]); + scale.properties.insert( + "breaks".to_string(), + ParameterValue::String("2 months".to_string()), + ); + + let context = ScaleDataContext::new(); + ScaleType::continuous() + .resolve(&mut scale, &context, "x") + .unwrap(); + + // Minors are wrapped by the transform, like the majors, so a writer gets + // typed values rather than raw day numbers. + match scale.properties.get("minor_breaks") { + Some(ParameterValue::Array(minors)) => { + assert!(!minors.is_empty()); + for m in minors { + assert!( + matches!(m, ArrayElement::Date(_)), + "minor should be a Date element: {m:?}" + ); + } + } + other => panic!("minors should be a resolved Array, got {other:?}"), + } + } + + #[test] + fn test_resolve_discrete_has_no_minor_breaks() { + use crate::plot::scale::Scale; + + let mut scale = Scale::new("x"); + scale.scale_type = Some(ScaleType::discrete()); + scale.input_range = Some(vec![ + ArrayElement::String("a".to_string()), + ArrayElement::String("b".to_string()), + ]); + + let context = ScaleDataContext::new(); + ScaleType::discrete() + .resolve(&mut scale, &context, "x") + .unwrap(); + + // Not applicable rather than empty, so a consumer keeps its own fallback. + assert_eq!(scale.numeric_minor_breaks(), None); + } + + /// A resolved continuous scale over `0..=100` with `n` requested major breaks and + /// the given `minor_breaks` setting. + fn resolved_with_minor_setting(setting: Option) -> crate::plot::scale::Scale { + use crate::plot::scale::Scale; + + let mut scale = Scale::new("x"); + scale.scale_type = Some(ScaleType::continuous()); + scale.input_range = Some(vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]); + scale + .properties + .insert("breaks".to_string(), ParameterValue::Number(5.0)); + if let Some(value) = setting { + scale.properties.insert("minor_breaks".to_string(), value); + } + let context = ScaleDataContext::new(); + ScaleType::continuous() + .resolve(&mut scale, &context, "x") + .unwrap(); + scale + } + + #[test] + fn test_minor_breaks_setting_count_is_per_major_interval() { + // Three per interval, unlike `breaks` whose count targets the whole axis. + let scale = resolved_with_minor_setting(Some(ParameterValue::Number(3.0))); + let majors = scale.numeric_breaks(); + let minors = scale.numeric_minor_breaks().expect("minors resolved"); + for w in majors.windows(2) { + assert_eq!( + minors.iter().filter(|m| **m > w[0] && **m < w[1]).count(), + 3, + "expected three minors in ({}, {}): {minors:?}", + w[0], + w[1] + ); + } + } + + #[test] + fn test_minor_breaks_setting_zero_suppresses() { + // Resolves to an empty array — "draw none" — not to the default density. + let scale = resolved_with_minor_setting(Some(ParameterValue::Number(0.0))); + assert_eq!(scale.numeric_minor_breaks(), Some(Vec::new())); + } + + #[test] + fn test_minor_breaks_setting_explicit_positions_are_filtered_to_domain() { + let scale = resolved_with_minor_setting(Some(ParameterValue::Array(vec![ + ArrayElement::Number(-10.0), // outside the domain, dropped + ArrayElement::Number(10.0), + ArrayElement::Number(90.0), + ArrayElement::Number(150.0), // outside the domain, dropped + ]))); + assert_eq!(scale.numeric_minor_breaks(), Some(vec![10.0, 90.0])); + } + + #[test] + fn test_minor_breaks_setting_interval_string_on_a_date_scale() { + use crate::plot::scale::Scale; + + let mut scale = Scale::new("x"); + scale.scale_type = Some(ScaleType::continuous()); + scale.transform = Some(Transform::date()); + scale.input_range = Some(vec![ + ArrayElement::Date(19738), // 2024-01-15 + ArrayElement::Date(19889), // 2024-06-15 + ]); + scale.properties.insert( + "breaks".to_string(), + ParameterValue::String("2 months".to_string()), + ); + scale.properties.insert( + "minor_breaks".to_string(), + ParameterValue::String("week".to_string()), + ); + + let context = ScaleDataContext::new(); + ScaleType::continuous() + .resolve(&mut scale, &context, "x") + .unwrap(); + + // Weekly minors under bi-monthly majors: far more minors than majors, and + // every one inside the *resolved* domain (which expansion widened past the + // data extent). + let majors = scale.numeric_breaks(); + let minors = scale.numeric_minor_breaks().expect("minors resolved"); + assert!( + minors.len() > majors.len(), + "weekly minors ({}) should outnumber bi-monthly majors ({})", + minors.len(), + majors.len() + ); + let (min, max) = scale.numeric_domain().expect("resolved domain"); + for m in &minors { + assert!(*m >= min && *m <= max, "minor {m} outside [{min}, {max}]"); + } + } + + #[test] + fn test_minor_breaks_setting_rejects_a_negative_count() { + use crate::plot::scale::Scale; + + let mut scale = Scale::new("x"); + scale.scale_type = Some(ScaleType::continuous()); + scale.input_range = Some(vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]); + scale + .properties + .insert("minor_breaks".to_string(), ParameterValue::Number(-1.0)); + + let context = ScaleDataContext::new(); + let err = ScaleType::continuous() + .resolve(&mut scale, &context, "x") + .expect_err("a negative minor count is not a count"); + assert!(err.contains("minor_breaks"), "unexpected message: {err}"); + } + #[test] fn test_resolve_string_interval_breaks_datetime() { use crate::plot::scale::Scale; diff --git a/src/plot/scale/types.rs b/src/plot/scale/types.rs index 6cc3289f6..3d9786ea4 100644 --- a/src/plot/scale/types.rs +++ b/src/plot/scale/types.rs @@ -14,6 +14,15 @@ fn default_label_template() -> String { "{}".to_string() } +/// One bin of a resolved binned scale: the edges bounding it and the text that +/// names it. See [`Scale::binned_bins`]. +#[derive(Debug, Clone, PartialEq)] +pub struct BinLabel { + pub lower: ArrayElement, + pub upper: ArrayElement, + pub label: String, +} + /// Scale configuration (from SCALE clause) /// /// Syntax: `SCALE [TYPE] aesthetic [FROM ...] [TO ...] [VIA ...] [SETTING ...] [RENAMING ...]` @@ -52,8 +61,9 @@ pub struct Scale { #[serde(default)] pub explicit_transform: bool, /// Additional scale properties (SETTING clause) - /// Note: `breaks` can be either a Number (count) or Array (explicit positions). - /// If scalar at parse time, it's converted to Array during resolution. + /// Note: `breaks` and `minor_breaks` can each be a Number (count), Array + /// (explicit positions) or String (temporal interval). Whatever the user wrote, + /// both are converted to an Array of positions during resolution. pub properties: Parameters, /// Whether this scale has been resolved (set by resolve() method) /// Used to skip re-resolution of pre-resolved scales (e.g., Binned scales) @@ -118,11 +128,60 @@ impl Scale { } } + /// Numeric minor break positions (after resolution), from the `minor_breaks` + /// setting. + /// + /// Minor breaks carry no labels — they are the sub-ticks / sub-gridlines between + /// majors. Returns an `Option`, unlike [`numeric_breaks`](Self::numeric_breaks), + /// because "resolved to none" and "not resolved" have to be told apart: + /// + /// - `Some(positions)`, possibly **empty** — resolution ran. An empty vector is + /// the user asking for no minors (`SETTING minor_breaks => 0`) and a consumer + /// must honour it by drawing none. + /// - `None` — no minor breaks were resolved, either because the scale type has + /// none (discrete, ordinal, binned — a binned axis's ticks are its bin edges) + /// or because the scale is unresolved. A consumer is free to fall back on its + /// own algorithm. + pub fn numeric_minor_breaks(&self) -> Option> { + match self.properties.get("minor_breaks") { + Some(ParameterValue::Array(breaks)) => { + Some(breaks.iter().filter_map(|b| b.to_f64()).collect()) + } + _ => None, + } + } + /// Labelled breaks: `(numeric_position, display_label)` pairs. /// /// Delegates to the scale type, then applies `label_mapping` overrides. - /// Suppressed labels (`None` in the mapping) become empty strings. + /// Suppressed labels (`None` in the mapping) become empty strings — the + /// break is kept, but goes unlabelled. Use [`Self::visible_break_labels`] + /// when a suppressed break should disappear entirely. pub fn break_labels(&self) -> Vec<(f64, String)> { + self.labelled_breaks() + .into_iter() + .map(|(pos, label)| (pos, label.unwrap_or_default())) + .collect() + } + + /// Labelled breaks with suppressed ones **dropped**, not blanked. + /// + /// A binned scale under `oob => 'squish'` suppresses its two terminal + /// breaks: the outermost bins are open-ended, so the edge values they would + /// be labelled with are not real boundaries. Leaving the break in place with + /// an empty label still draws its tick and gridline, which reads as a + /// boundary that isn't there — so the whole break goes. + pub fn visible_break_labels(&self) -> Vec<(f64, String)> { + self.labelled_breaks() + .into_iter() + .filter_map(|(pos, label)| label.map(|l| (pos, l))) + .collect() + } + + /// Breaks paired with their resolved label, where `None` means the label was + /// explicitly suppressed (as opposed to merely empty). The two public break + /// accessors differ only in what they do with that `None`. + fn labelled_breaks(&self) -> Vec<(f64, Option)> { let raw = match &self.scale_type { Some(st) => st.break_labels(self), None => self @@ -135,14 +194,74 @@ impl Scale { let mut out = Vec::with_capacity(raw.len()); for (pos, label) in raw { match mappings.and_then(|m| m.get(&label)) { - Some(Some(renamed)) => out.push((pos, renamed.clone())), - Some(None) => out.push((pos, String::new())), - None => out.push((pos, label)), + Some(Some(renamed)) => out.push((pos, Some(renamed.clone()))), + Some(None) => out.push((pos, None)), + None => out.push((pos, Some(label))), } } out } + /// The bins of a binned scale, each with its edges and display label. + /// + /// One definition of the bin-labelling contract, for every consumer that has + /// to name a bin: `"lower – upper"` (en dash) with per-edge `RENAMING` + /// applied to each side, and an open form (`≤`/`<` at the bottom, `>`/`≥` at + /// the top, per the scale's `closed` side) where a *terminal* edge's label is + /// suppressed — which is what `oob => 'squish'` does, because the outermost + /// bins then reach past the edge value and it is no longer a real boundary. + /// + /// Empty for a scale with no resolved break array, and for any scale type + /// other than binned. + pub fn binned_bins(&self) -> Vec { + if self.scale_type.as_ref().map(|st| st.scale_type_kind()) + != Some(super::ScaleTypeKind::Binned) + { + return Vec::new(); + } + let Some(ParameterValue::Array(breaks)) = self.properties.get("breaks") else { + return Vec::new(); + }; + if breaks.len() < 2 { + return Vec::new(); + } + let closed_right = matches!( + self.properties.get("closed"), + Some(ParameterValue::String(s)) if s == "right" + ); + let mapping = self.label_mapping.as_ref(); + let last = breaks.len() - 2; + + (0..=last) + .map(|i| { + let (lower, upper) = (breaks[i].clone(), breaks[i + 1].clone()); + let (lo_key, hi_key) = (lower.to_key_string(), upper.to_key_string()); + let suppressed = |key: &str| matches!(mapping.and_then(|m| m.get(key)), Some(None)); + let label_of = |key: &str| { + mapping + .and_then(|m| m.get(key)) + .cloned() + .flatten() + .unwrap_or_else(|| key.to_string()) + }; + let label = if i == 0 && suppressed(&lo_key) { + let symbol = if closed_right { "≤" } else { "<" }; + format!("{symbol} {}", label_of(&hi_key)) + } else if i == last && suppressed(&hi_key) { + let symbol = if closed_right { ">" } else { "≥" }; + format!("{symbol} {}", label_of(&lo_key)) + } else { + format!("{} – {}", label_of(&lo_key), label_of(&hi_key)) + }; + BinLabel { + lower, + upper, + label, + } + }) + .collect() + } + /// Numeric domain as `(min, max)` from the resolved input range. /// /// Delegates to the scale type for type-specific logic (e.g. discrete @@ -159,6 +278,38 @@ impl Scale { } } } + + /// Apply this scale's resolved expansion to a caller-computed `(min, max)`. + /// + /// [`numeric_domain`](Self::numeric_domain) is already expanded, so this is + /// only for a consumer that had to derive a range ggsql did *not* resolve — + /// today, a writer computing a per-panel domain for a **free** facet + /// dimension. Going through this method rather than re-deriving the formula + /// keeps a free panel padded exactly like a fixed axis, honours + /// `SETTING expand`, and picks up context-dependent factors the caller can't + /// see (a polar full-circle theta resolves to zero expansion). + /// + /// Mirrors resolution: expand, then clip to the transform's allowed domain. + /// On an unresolved scale the factors fall back to the continuous defaults. + pub fn expand_range(&self, min: f64, max: f64) -> (f64, f64) { + let (mult, add) = super::scale_type::get_expand_factors(&self.properties); + let expanded = super::scale_type::expand_numeric_range( + &[ArrayElement::Number(min), ArrayElement::Number(max)], + mult, + add, + ); + let clipped = match &self.transform { + Some(t) => super::scale_type::clip_to_transform_domain(&expanded, t), + None => expanded, + }; + match ( + clipped.first().and_then(|e| e.to_f64()), + clipped.last().and_then(|e| e.to_f64()), + ) { + (Some(lo), Some(hi)) => (lo, hi), + _ => (min, max), + } + } } /// Output range specification (TO clause) @@ -352,6 +503,48 @@ mod tests { ); } + #[test] + fn test_temporal_break_labels_are_iso_strings() { + // 1208 days since epoch = 1973-04-23. A temporal break labels itself from + // its own value, not from the epoch number its position projects to. + let mut s = continuous_scale((1208.0, 1264.0), vec![]); + s.properties.insert( + "breaks".to_string(), + ParameterValue::Array(vec![ + ArrayElement::Date(1208), + ArrayElement::Date(1236), + ArrayElement::Date(1264), + ]), + ); + assert_eq!( + s.break_labels(), + vec![ + (1208.0, "1973-04-23".to_string()), + (1236.0, "1973-05-21".to_string()), + (1264.0, "1973-06-18".to_string()) + ] + ); + } + + #[test] + fn test_temporal_break_labels_honour_mapping() { + // `label_mapping` is keyed by `to_key_string()`, so a RENAMING override on + // a temporal break has to be found under the ISO key. + let mut s = continuous_scale((1208.0, 1236.0), vec![]); + s.properties.insert( + "breaks".to_string(), + ParameterValue::Array(vec![ArrayElement::Date(1208), ArrayElement::Date(1236)]), + ); + let mut mapping = HashMap::new(); + mapping.insert("1973-04-23".to_string(), Some("Apr 23".to_string())); + mapping.insert("1973-05-21".to_string(), None); + s.label_mapping = Some(mapping); + assert_eq!( + s.break_labels(), + vec![(1208.0, "Apr 23".to_string()), (1236.0, String::new())] + ); + } + #[test] fn test_break_labels_with_mapping() { let mut s = discrete_scale(&["A", "B", "C"]); @@ -368,4 +561,85 @@ mod tests { ] ); } + + #[test] + fn test_numeric_minor_breaks_is_none_until_resolved() { + // Unresolved, so a consumer may fall back to its own algorithm. A setting + // value that resolution hasn't converted yet reads the same way. + let mut s = continuous_scale((0.0, 100.0), vec![0.0, 50.0, 100.0]); + assert_eq!(s.numeric_minor_breaks(), None); + s.properties + .insert("minor_breaks".to_string(), ParameterValue::Number(3.0)); + assert_eq!(s.numeric_minor_breaks(), None); + } + + #[test] + fn test_numeric_minor_breaks_reads_resolved_positions() { + let mut s = continuous_scale((0.0, 100.0), vec![0.0, 50.0, 100.0]); + s.properties.insert( + "minor_breaks".to_string(), + ParameterValue::Array(vec![ArrayElement::Number(25.0), ArrayElement::Number(75.0)]), + ); + assert_eq!(s.numeric_minor_breaks(), Some(vec![25.0, 75.0])); + } + + #[test] + fn test_numeric_minor_breaks_distinguishes_resolved_none() { + // `minor_breaks => 0` resolves to an empty array, which must not read as + // "unresolved" — a consumer has to draw none rather than invent some. + let mut s = continuous_scale((0.0, 100.0), vec![0.0, 50.0, 100.0]); + s.properties.insert( + "minor_breaks".to_string(), + ParameterValue::Array(Vec::new()), + ); + assert_eq!(s.numeric_minor_breaks(), Some(Vec::new())); + } + + #[test] + fn test_expand_range_uses_the_continuous_default() { + // No `expand` property → the 5%-of-span default, both ends. + let s = continuous_scale((0.0, 100.0), vec![]); + assert_eq!(s.expand_range(0.0, 100.0), (-5.0, 105.0)); + } + + #[test] + fn test_expand_range_honours_the_setting() { + // Multiplier only, then the [mult, add] pair resolution writes back. + let mut s = continuous_scale((0.0, 100.0), vec![]); + s.properties + .insert("expand".to_string(), ParameterValue::Number(0.1)); + assert_eq!(s.expand_range(0.0, 100.0), (-10.0, 110.0)); + + s.properties.insert( + "expand".to_string(), + ParameterValue::Array(vec![ArrayElement::Number(0.1), ArrayElement::Number(1.0)]), + ); + assert_eq!(s.expand_range(0.0, 100.0), (-11.0, 111.0)); + } + + #[test] + fn test_expand_range_zero_is_exact() { + // What a polar full-circle theta resolves to: no padding at all, so a + // free panel doesn't open a gap in the pie. + let mut s = continuous_scale((0.0, 100.0), vec![]); + s.properties.insert( + "expand".to_string(), + ParameterValue::Array(vec![ArrayElement::Number(0.0), ArrayElement::Number(0.0)]), + ); + assert_eq!(s.expand_range(0.0, 100.0), (0.0, 100.0)); + } + + #[test] + fn test_expand_range_clips_to_the_transform_domain() { + // Mirrors resolution: expanding below zero on a log scale clips to the + // transform's allowed minimum rather than producing an invalid domain. + let mut s = continuous_scale((1.0, 1000.0), vec![]); + s.transform = Some(Transform::log()); + let (lo, hi) = s.expand_range(1.0, 1000.0); + assert_eq!(lo, f64::MIN_POSITIVE); + assert!( + (hi - 1049.95).abs() < 1e-9, + "upper end expands normally: {hi}" + ); + } } diff --git a/src/writer/hephaestus/CLAUDE.md b/src/writer/hephaestus/CLAUDE.md new file mode 100644 index 000000000..3360d5718 --- /dev/null +++ b/src/writer/hephaestus/CLAUDE.md @@ -0,0 +1,540 @@ +# `writer/hephaestus/` — PNG writer internals + +`PngWriter` renders a resolved ggsql `Spec` to **PNG bytes** via +[hephaestus](https://github.com/posit-dev/hephaestus), a 2D scene renderer with a +grammar-of-graphics plot API. Behind the non-default `png` cargo feature. + +**hephaestus is not a public name.** The user-facing writer is `png` +(`--writer png`, `--features png`, `ggsql::writer::PngWriter`); the module is +named after the renderer it wraps and is private, so nothing but `PngWriter`, +`Color` and `rgba` leaves the crate. More hephaestus-backed writers (svg, pdf, +window) are expected, each with its own public name. Keep the renderer's name out +of anything a user reads — CLI help, error messages, `/doc/`. + +This file is the **architecture**: the abstractions, the invariants, and how to +extend them. For how the writer's behaviour got here, read +[`/CHANGELOG.md`](../../../CHANGELOG.md) and the commit history; what is +deliberately not done yet is in [Known gaps](#known-gaps) below. + +For ggsql language semantics see [`/doc/syntax/`](../../../doc/syntax/); for the +sibling writer's internals, [`../vegalite/CLAUDE.md`](../vegalite/CLAUDE.md). + +## The governing principle + +**ggsql owns every scale domain; the writer never computes its own extents.** +The `Spec` arrives with each `Scale` fully resolved — type, domain (already +expanded, transform-aware, trained globally over all layers and the whole +position family), transform, breaks, formatted labels, and a concrete output +range for material aesthetics. The writer's job is to *pass those through* to +hephaestus, which performs the value→pixel mapping at draw time. This mirrors +the Vega-Lite writer, which passes `input_range` into `scale.domain`. + +The practical consequence: when something looks wrong, the fix is usually a +missing *pass-through*, not a better computation here. The same shape held +upstream — every hephaestus gap this writer hit was a missing setter, not a +missing algorithm. + +There are exactly **two scoped exceptions**, both flagged in the code and both +debt that would disappear if ggsql resolved more: + +| Exception | Where | Why | +| --- | --- | --- | +| Free facet dimensions | `scales::{free_position_scale, free_binned_scale}` | ggsql resolves one global domain; a `free` panel needs its own. Only the *extent* is computed — the padding around it is still ggsql's, via `Scale::expand_range`. | +| Spatial `pos1`/`pos2` | `mod.rs::map_bbox` | A spatial layer positions by geometry, so ggsql resolves no position scales. The bbox still comes from ggsql (`Projection.computed["bbox"]`), falling back to the geometry extent only for a bare `spatial` geom. | + +## Configuration + +Raster output needs concrete dimensions, so unlike the Vega-Lite writer this one +carries state: `width`, `height` (both pixels), `dpi`, and `background`. +`PngWriter::new` + `.background()` set them directly; +`Writer::from_options` builds the same thing from the frontend-agnostic +key–value [`WriterOptions`](../options.rs) (`-D width=1600` on the CLI). The user-facing table of keys lives in the struct's rustdoc and in +[`/doc/get_started/tooling/cli.qmd`](../../../doc/get_started/tooling/cli.qmd); +what matters here: + +- **`units` interprets supplied dimensions only.** `to_pixels` converts a + physical unit through inches at `dpi`, so a figure given in inches grows with + resolution. The defaults are pixel counts and so are unit-independent. +- **DPI is not just print resolution.** hephaestus converts the theme's physical + sizes (text, strokes, spacing — all points) at render DPI, so `dpi` also sets + how large the chrome is relative to a pixel canvas. +- **Every option is validated, none is ignored.** `reject_unknown` first, then a + per-key error naming the option; `whole_pixels` rejects a dimension outside + `1..=MAX_DIMENSION` so a slipped unit conversion fails with a message rather + than by exhausting GPU memory. + +## Render flow + +Unlike the Vega-Lite writer, which emits a declarative document and lets the VL +runtime do layout and scale application, hephaestus **is** the runtime. So +`write` builds a live object graph and renders it. + +``` +PngWriter::write(&Plot, &HashMap) + │ + ├─ facet::build_panels(spec, data) → (Composition, Vec) + │ 1×1 grid + one Panel when unfaceted; else grid(nrow, ncol, cells) + ├─ PlotComposition::new(&composition).shape_registry(..) + ├─ wiring::plot_label → composition title / subtitle / caption + ├─ projection::composition_axis_titles → one centred x / y title, outer chrome + ├─ for scale in spec.scales: scales::build_scale(scale, RangeKind) + │ → view.insert_scale(scale.aesthetic, hs) ← the fixed/shared scales + ├─ map_bbox → insert continuous "pos1"/"pos2" for a map / spatial plot + │ + ├─ for panel in panels: ← one hephaestus Plot each + │ ├─ facet::panel_dataframe(layer_df, panel) per-layer row slice + │ ├─ facet::PanelScales::new(spec, panel) free dims → "pos1__p{idx}" + │ ├─ free dims: scales::free_position_scale → view.insert_scale + │ ├─ for (layer, df) in slices: geom::build_into_plot(&mut plot, &Ctx{..}) + │ │ geoms set channels, plot.set_binding(channel, scale), push legends + │ ├─ projection::apply_projection(plot, spec, panel, &ps) ← axes live here + │ ├─ map: plot.aspect_ratio(1.0).aspect_mode(Range) ← square units + │ ├─ panel.strip_top / strip_right → plot.strip(AxisSide::…) + │ └─ view.attach_plot(plot) + │ + ├─ legend_sink (captured from the *first* panel) → view.add_legend(..) + ├─ view.validate() + └─ render_png: VelloRenderer → RGBA8 buffer → hephaestus::png::encode_png +``` + +Layers draw in `spec.layers` order, which is DRAW order, which is z-order. + +## Module map + +| File | Role | +| --- | --- | +| [`mod.rs`](mod.rs) | `PngWriter` (size / dpi / background), `Writer` impl including `from_options`, the orchestration above, `map_bbox`, `render_png`, and the writer's test suite. | +| [`wiring.rs`](wiring.rs) | The shared, geom-generic machinery: `Ctx`, `GeomSpec` + its parts, `build_and_add`, `wire_positions`, `wire_material`, `MaterialSource`/`resolve_material`, `BandAxes`, `side`/band helpers, `material_legend`, label resolution. | +| [`scales.rs`](scales.rs) | ggsql `Scale` → hephaestus `Scale`. `RangeKind`, transform + palette + break mapping, temporal scales, free-panel scales, `binned_bins`/`bin_at_centre`. | +| [`channels.rs`](channels.rs) | DataFrame column → typed channel data (`ChannelData`, `column_to_*`), group keys, WKB/WKT geometry decoding. | +| [`facet.rs`](facet.rs) | `FACET` → `Composition` + `Vec`; level ordering, strip labelling, per-panel row slicing, `PanelScales`. | +| [`projection.rs`](projection.rs) | `PROJECT` → hephaestus `Projection`, **and the axes** (they depend on the coord). | +| [`geom/`](geom/) | One module per geom family, each declaring a `GeomSpec` or supplying a custom builder. `geom/mod.rs` is the dispatch + `is_supported`. | + +## Core abstractions + +### `Ctx` — what a geom is given + +Read-only per (layer, panel): the `Plot`, the `Layer`, that panel's sliced +`DataFrame`, `transposed`, the scale names to bind positions to +(`pos1_scale`/`pos2_scale` — panel-aware, so free facets work), and the legend +sink. Geoms write bindings directly onto the `HPlot` and push legends through +`Ctx::push_legend`; there is no accumulator to thread. + +### `GeomSpec` — the declarative path + +Most geoms are just data. A module returns a `GeomSpec` and +`wiring::build_and_add::` does the rest (see [`geom/point.rs`](geom/point.rs) +for the minimal case): + +| Field | Meaning | +| --- | --- | +| `positions: Vec` | hephaestus `channel` ← ggsql `aesthetic`, plus which `PanelAxis` it drives (so the right `pos` scale is bound and the right dodge/jitter offsets picked up). | +| `material: Vec` | ggsql aesthetic → hephaestus channel, a `RangeKind`, and a `MatDefault` fallback matching ggsql's own geom default. Several aesthetics may target one channel (`fill`/`color`/`colour` → `fill`); the first that resolves wins. | +| `raw_strings` | Unscaled string channels from a mapped aesthetic (text labels). | +| `raw_numbers` | Constant panel-space values that bypass scales (a rule's 0..1 span), materialised one per row. | +| `data_channels` | Per-row values the geom computes itself (bar/tile band edges, an area's per-mark baseline-outline gate). Channels listed here are *claimed*: `wire_positions` won't overwrite them with the raw offsets, because the geom already folded those in. | +| `legend_key: LegendKind` | Point / Line / Rect / Text swatch, so a line legend shows a line and a text legend shows a glyph. | +| `grouped: bool` | Derive hephaestus `keys` from `layer.partition_by`, for multi-vertex marks (line, area, polygon). | + +### The three ways ggsql delivers an aesthetic + +This is the single most important thing to get right, and it mirrors the +Vega-Lite writer's `build_encoding_channel` exactly. `wire_material` (whole +column) and `resolve_material` (row-subsettable, for composites) both dispatch +the same three ways: + +| `AestheticValue` | Meaning | Handling | +| --- | --- | --- | +| `Literal(..)` | A fixed value — **every geom default and every `SETTING` constant** arrives this way, not as a materialized column | `Raw` constant channel value (`set_literal_channel` / `constant_material`), converted by `RangeKind` | +| `Column` with a non-identity scale | Data-mapped | Set the column, `plot.set_binding(channel, aesthetic)`, record one legend | +| `Column` with an identity scale, or `AnnotationColumn` | Visual-space values already | Per-row `Raw` | + +`MatDefault` is a true last-resort fallback, only for an aesthetic ggsql didn't +map at all. Keying off columns alone silently drops every literal — which is how +`SETTING color => 'red'` once rendered black. + +**Only a ggsql-mapped column goes through a scale; everything the writer +resolves itself is `Raw`.** A hephaestus binding belongs to the *plot channel*, +not to the geom that set it, so one layer mapping `colour` binds `stroke` to a +categorical scale for **every** layer in the panel. A plain (non-`Raw`) constant +on that channel — a literal, a `MatDefault`, a composite's +`MaterialSource::Constant` — is then looked up in that scale's domain, resolves +to `Null`, and the mark silently disappears. `Raw` bypasses the binding, which +is what a value already in visual space wants anyway. The same holds for a +position given as a constant: `wire_positions` materialises it per row through +`constant_position` so it still travels through its position scale, because a +hephaestus geom whose geometry varies per row rejects a constant position +channel outright (`"x" must be data, not constant`). + +### `MaterialSource` — composites + +A composite geom (boxplot, violin) decomposes one ggsql layer into several +hephaestus geoms that must all be styled *identically*, each from its own row +subset. `wiring::resolve_material` resolves an aesthetic once — registering the +binding and one legend if data-mapped — and returns a `MaterialSource` that +components `.apply(&mut builder, channel, &row_indices)`. `resolve_color` / +`resolve_optional_color` are the color-typed wrappers (the latter for aesthetics +whose ggsql default is `Null`, e.g. a text geom's `stroke`, where "unmapped" +must leave the channel unset). This is the raster analog of Vega-Lite's *shared +encoding* on a composite mark. + +### `BandAxes` — banded geoms and orientation + +Boxplot, violin and range measure *across* the axis they sit on. `BandAxes` +names channels by **role** rather than by axis — `band()`, `value()`, `dodge()`, +`band_channels()`, `band_fraction_channels()`, `band_offset_channels()` — and +flips them when ggsql transposed the layer. Bindings never swap: a hephaestus +channel always drives the same panel axis; only the column feeding it moves. + +`side_sign` + `band_edges(half, side)` halve a mark onto one side of the band +(`'both'` → `±half`, else centreline → `±half`). hephaestus band offsets are +positive-right on x and positive-up on y — the convention every ggsql offset +uses — so `'top'`/`'right'` are positive in *either* orientation and one +predicate covers both. The Vega-Lite writer's `side_is_positive` reads the same +way; it reverses the *scale domain* of a `yOffset` channel rather than the sign, +because VL's y offsets point down. + +## Channel naming + +hephaestus channels are named per **panel axis**; scales are registered under the +**ggsql aesthetic**. `plot.set_binding(channel, scale_name)` ties them together +and is idempotent, so repeated bindings across layers and components are +harmless. + +**Each hephaestus geom declares the channels it accepts, and setting one it +doesn't declare panics** (`geom::state::validate_known_channels`, at build time +rather than at draw). So the names below are per-geom, not global: only the +fill-bearing geoms take `fill_opacity`, only `RibbonGeom` takes the `2`-suffixed +far-edge channels. A misnamed channel fails loudly rather than rendering as the +default — check the target geom's `CHANNELS` catalog upstream when adding one. + +| Concept | hephaestus channel | +| --- | --- | +| Positions | `x`, `x2`, `y`, `y2` | +| Band fraction offsets (dodge/jitter, width) | `x_band`, `x2_band`, `y_band`, `y2_band` | +| Absolute (pt) offsets — hinge caps | `x_offset`, `x2_offset`, `y_offset`, `y2_offset` | +| Color | `fill`, `stroke` (`stroke2` = a ribbon's far edge; `text_stroke` = a glyph outline) | +| Scalars | `size`, `linewidth`, `linetype`, `shape`, `fill_opacity` / `stroke_opacity` | +| Geometry / text | `geometry`; `text`, `markdown`, `anchor_x`, `anchor_y`, `angle`, `weight`, `italic`, `family` | + +| Scale registry key | Source | +| --- | --- | +| `pos1`, `pos2`, `fill`, `stroke`, `size`, `shape`, `linetype`, … | The ggsql aesthetic name, from `spec.scales` | +| `pos1__p{index}`, `pos2__p{index}` | A **free** facet dimension's per-panel scale | + +Under Polar, ggsql assigns pos1→radius and pos2→theta (as the Vega-Lite writer +does); `projection.rs` tells hephaestus so via `PolarProjection`'s +`angle_channel`/`radius_channel` rather than renaming anything. + +## Scales + +`scales::build_scale(Option<&GScale>, RangeKind) -> Option` is the whole +translation. It returns `None` when ggsql resolved no scale type — the writer +registers nothing rather than fabricating a scale. + +- `ScaleTypeKind` maps 1:1 (Continuous / Discrete / Ordinal / Binned / + Identity), as do the transforms; cast and temporal transforms map to identity + because values arrive already projected to `f64`. +- A **temporal** continuous scale becomes `scale::temporal` in the unit its + transform names (days / µs since epoch, ns since midnight — exactly + `ArrayElement`'s units), so hephaestus's own ticks read as dates. +- `RangeKind` selects how a resolved `OutputRange::Array` becomes a hephaestus + range: `range_colors` / `range_numbers` / `range_strings` / `range_linetypes`, + and nothing for `Position`. Palettes are already concrete by the time the + writer runs. +- **`RangeKind` is also the one place a value converts**, for the aesthetics no + scale touches: `wire_material`, `set_literal_channel` and `constant_material` + all dispatch on it, so a conversion written once serves the literal, the + identity column, *and* the legend key. That is why the text geom's face is + expressed as kinds (`Text` for a family, `Bool` for italic, `FontWeight` for a + CSS keyword or number, `Angle` for ggsql's degrees → hephaestus's radians) + rather than as per-row code in the geom: a channel converted inside a geom + cannot be pinned onto a key. A new unit-converted aesthetic belongs here. +- **Breaks are ggsql's, majors and minors alike.** `apply_breaks` feeds + `break_labels()` in as `with_breaks_labeled`, so ticks (and `RENAMING` + overrides) match ggsql — and therefore the Vega-Lite writer — exactly. + `apply_minor_breaks` does the same for `numeric_minor_breaks()`, where + `Some(vec![])` ("resolved to none") must stay distinct from `None` ("not + resolved, fall back to hephaestus's automatic minors"). A *suppressed* label + means different things on either side of the categorical divide, so the two + take different accessors: a categorical scale keeps the break and blanks it + (`break_labels`, since `RENAMING => null` must not shift the axis), + a numeric one drops it whole (`visible_break_labels`, since a binned + `oob => 'squish'` terminal is not a real boundary). +- **`reverse` is the writer's to apply**, like VL's `scale.reverse`: ggsql + resolves the property but never touches the domain, so the writer sets + hephaestus's `Direction::Reversed` on the scale. Reversal is a property of the + *mapping*, so one flag covers every scale kind and both roles — a position axis + runs backwards, a material scale walks its palette from the far end — while the + domain, the breaks, the bin edges, and the order a legend lists its keys in all + stay as ggsql resolved them. VL's `reverse` flips the range rather than the + domain too, so both writers order a reversed legend the same way. +- **Linetypes go through core's `linetype_to_stroke_dash`**, not hephaestus's + builtins by name: ggsql resolves an ordinal linetype range to ggplot2-style hex + patterns, and core's parser is what VL uses, so routing through it is what keeps + the two writers drawing the same dashes. +- **A null category travels as `channels::NULL_CATEGORY`.** ggsql trains a + categorical domain over nulls, but hephaestus's `DataColumn` has no + null-carrying variant, so domain and data agree on a sentinel string instead + (`scales::category_value` and `column_to_channel`). Labels are unaffected — + they travel separately through `with_breaks_labeled`. + +## Faceting and panels + +ggsql resolves faceting fully (layout, `free` flags, Wrap's `ncol`, per-row +`__ggsql_aes_facet1__`/`facet2__`), so `facet.rs` only lays it out: a +`Composition` of named patches plus a `Vec` the write loop iterates. +Unfaceted is the same path with one panel, so there is no branch. + +- **Ordering** mirrors the Vega-Lite writer's `resolve_facet_ordering`: binned + facets by bin centre, else the facet scale's `input_range` then numeric-aware + ascending, then `reverse`. +- **Slicing** is `DataFrame::take` on matching row indices. A layer with no facet + column is used whole in every panel. +- **Every cell is a panel, empty or not.** A Grid row × column combination absent + from the data still gets its `HPlot` — background, grid, edge axis and strip — + because the grid must stay rectangular and the strips must keep describing every + row and column (ggplot2's `facet_grid`, and the Vega-Lite writer). Two things + follow, both in the `write` loop: the panel builds no geoms (nothing to draw over + zero rows), so it must bind `x`/`y` itself — hephaestus derives the panel grid + from the scales bound to the projection's channels, which a geom would otherwise + have bound — and it must not count as the legend-capturing panel. A **free** + dimension has no extent to compute there either, so `PanelScales::use_shared` + points that dimension back at the global scale. +- **Strip labels** come from `Level { key, value, is_null, label }` — `key` + selects rows, `label` is the text. Discrete levels honour `RENAMING` + (suppressed → `Some("")`, *not* `None`, so hephaestus still reserves the strip + slot and panels stay aligned); binned levels join the column's bin **centre** + back to its bin via `scales::bin_at_centre` and label it with the bin's range. +- **Edge-only axes** (the ggplot2 look): `Panel::{first_col, last_row}` are + honoured in `projection::apply_proj_cartesian`. A free dimension forces its + axis onto every panel. + +## Projections and axes + +`projection::apply_projection` dispatches on `CoordKind` and **creates the axes**, +because the axis kind depends on the coord: Cartesian gets bottom/left rails, +Polar an angular ring + a radial rail, Map neither (the clip boundary and +graticules are the chrome). `has_real_axis` suppresses an axis whose position +scale is a synthetic `__ggsql_stat_dummy` (a pie's radius, a bar with no x), +mirroring the VL writer's `AxisInfo::suppress`. + +**Axis titles are not on the rails.** A rail is per panel, so titling it would +label every facet row and column — and every panel of a free dimension. The +figure has one x and one y, so it gets one centred title each, installed on the +composition by `write` from `projection::composition_axis_titles` alongside the +plot-level labels. Same suppression rules (`has_real_axis`, Cartesian only), and +no unfaceted special case: a 1×1 composition puts the title where a panel's own +would have gone. + +A **categorical angle** makes a radar rather than a pie. ggsql resolves that and +records `properties["radar"]`; the writer swaps `PolarProjection::full_circle` +for `::radar(n)`, which brings `PolarEdgeStyle::Chord` (polylines bend at each +category boundary instead of arcing between them) and `theta_break_fracs` at the +band centres `(i + 0.5) / n` — exactly where `Scale::map` puts a discrete scale's +categories, so spokes, grid polygons and data line up with no further wiring. The +radial rail's `theta_frac` is a **0–1 fraction of the sweep**, not an angle. + +Map coordinates arrive **pre-projected from SQL**, so hephaestus reprojects +nothing: a `CustomProjection` takes `computed["panel_boundary"]` as its clip +surface and `graticule_lon`/`graticule_lat` as its grid, all decoded from WKT by +`channels::wkt_to_*`. Custom's coordinate math equals Cartesian, which is exactly +what pre-projected data wants. Because those coordinates are already in one +linear unit on both axes, the panel's `aspect_ratio` is **1.0** — it is the +data-space x-unit : y-unit ratio, not a panel width:height ratio, so feeding it +the bbox's own proportions stretches every map by exactly that factor. + +The bbox becomes the `pos1`/`pos2` domains through `map_range`, which pads a real +span by `MAP_PADDING` (10%, split around its centre) so the framing matches the +Vega-Lite writer's projection fit (`span * 1.1`) and a shape on the boundary is +not drawn against the panel edge. + +## Legends + +Legends live on the **composition**, never on a per-panel plot, so a faceted plot +gets one shared legend. `Ctx` carries a `RefCell>` sink that is +`Some` only while building the **first** panel — every panel produces the same +legends, since all are built from the globally resolved scales — and `write` +registers the captured set once. This covers the single-panel case with no +special-casing. + +Beyond that, deduplication is hephaestus's: `collapse_legends` merges legends +whose scales are equivalent, which is what makes `color AS ` (mapped onto +*both* `fill` and `stroke`, hence two scales) render as one swatch. Do not build +a writer-side dedup map. + +A legend key paints only what it is told to paint — nothing is inherited from the +plot — so `pin_constants` dresses each key in the layer's own constants, walking +the same `MaterialSpec` table the geom wired itself from. That table already +encodes the geom's aliasing (`color` → `fill` for an area, → `stroke` for a +line), so the key ends up styled like the marks it describes. Exactly two rules, +and everything else pins: + +- **Never pin the scaled channel**, or the key overrides the thing it exists to show. +- **Never pin a channel a scale owns.** A data-mapped aesthetic's column holds + domain values, not visual ones, and it carries its own legend. When that + channel is the key's *body* colour, a non-colour legend falls back to a neutral + grey; a colour-scaled legend takes no fallback at all, because ggsql maps + `color` onto both `fill` and `stroke` and hephaestus only collapses those two + legends while their keys stay equivalent. + +That includes the channels that decide how much room the glyph takes. hephaestus +sizes each swatch **cell** from the key it holds, so `size`, `linewidth` and +`shape` pin like any other constant — `SETTING shape => 'star'` puts stars in the +legend, and `SETTING size => 12` gets a cell that fits the marker. It also +includes `fill_opacity` / `stroke_opacity`, which a key carries separately just as +a geom does: `opacity => 0` on a point geom pins straight through and leaves the +key as open a circle as the marks are. + +**A geom's `MaterialSpec` table is therefore the whole vocabulary of what its key +can wear** — a channel the geom sets outside that table is invisible to +`pin_constants`, however constant it is. So every aesthetic the key kind consumes +belongs in the table, even one that needs converting first: a text key takes +`family`, `weight`, `italic` and `angle`, which is why those are `RangeKind`s +rather than per-row code in `geom/text.rs`. `SETTING typeface => 'Times New +Roman', italic => true` dresses the swatch in the same face as the marks, and a +rotated layer gets a rotated key (as ggplot2's `draw_key_text` does — the cell is +sized from the rotated glyph, so nothing clips). + +One legend is recorded per **aesthetic**, not per channel. A geom may drive +several channels from one aesthetic — a ribbon sends `stroke` to both edge curves +— and they describe one scale, so they get one swatch. Recording a second does +not merely duplicate it: the extra key is `scaled` on the mirror channel +(`stroke2`), which no key kind consumes, so it resolves to nothing and hephaestus +paints its "row isn't empty" placeholder in ink over the real key. Cross-*layer* +dedup is still hephaestus's `collapse_legends`. + +`ggsql_theme()` is the one hook for chrome the writer overrides — currently just +suppressing the colorbar frame hephaestus otherwise inherits from its default +`RectElement`. Anything the two writers must agree on that is neither a scale nor +a channel belongs there. + +## Adding a geom + +1. Add a module under [`geom/`](geom/) returning a `GeomSpec`, and dispatch it in + `geom/mod.rs::build_into_plot` via `build_and_add::`. Add + the `GeomType` to `is_supported` — `validate` rejects anything not listed. +2. Get the ggsql defaults right in the `material` table: check what the geom's + ggsql definition actually sets, since those arrive as `Literal`s and the + `MatDefault` only fires when nothing is mapped. +3. Reach for a **custom builder** (as [`geom/text.rs`](geom/text.rs), + [`geom/spatial.rs`](geom/spatial.rs) and the composites do) only when the geom + has no plain x/y columns, computes its positions, or reads a layer *parameter* + rather than an aesthetic. Even then, route materials through `wire_material` / + `resolve_material` — that is what keeps data-mapped aesthetics working and what + dresses the legend key. A material aesthetic needing a **unit or keyword + conversion** is not a reason to hand-roll it: add a `RangeKind` and keep it in + the table (see `text`'s font face). +4. Check the **densified** path: under a map `PROJECT`, ggsql expands segment / + rule / ribbon / tile into per-vertex rows and remaps the extent aesthetics + onto plain `pos1`/`pos2`. [`geom/densified.rs`](geom/densified.rs) runs + *before* the `GeomType` match and reuses `line::spec` / `polygon::spec` whole. + A geom that densifies but isn't handled there fails *silently*, not loudly. +5. When in doubt about behaviour, read what the Vega-Lite writer does for the + same geom and match it — the two writers are meant to agree. + +## Testing + +Tests live at the bottom of [`mod.rs`](mod.rs): + +```sh +cargo test --features png --lib writer::hephaestus +``` + +Two kinds, plus a third that doesn't exist yet: + +- **`renders_*` smoke tests** — render succeeds and the output carries the PNG + signature. `assert_png_or_skip` tolerates a headless machine with no GPU + adapter (it skips rather than fails), so a green run does not prove a render + happened locally. +- **Exact-text assertions** — `facet_strips_*` and the `binned_bins` / + `bin_at_centre` / temporal-scale unit tests need no GPU and are the real + regression net. +- **Snapshot PNG tests do not exist.** Visual correctness is + verified by eyeballing, usually against the Vega-Lite render of the same + query. Assume a hephaestus version bump needs re-eyeballing: + +```sh +cargo run -p ggsql-cli --features png -- exec "" \ + --reader "duckdb://memory" --writer png --output /tmp/out.png +``` + +For eyeballing *at scale* — after a hephaestus bump, or when hunting the kind of +small omission that only shows up across the whole feature surface — use the +visual-test harness instead of one-off queries. It renders every executable +```` ```{ggsql} ```` cell in [`/doc/`](../../../doc/) (≈190 in `doc/syntax/` +alone) and writes one HTML report pairing each query with its render, optionally +beside the Vega-Lite render of the same `Spec`: + +```sh +cargo run -p ggsql-cli --features png --example visual_test -- --compare +open target/visual-test/index.html +``` + +It never stops on a failure — an error or a panic is captured against its cell — +so one run inventories every gap at once. Implementation notes: +[`/ggsql-cli/CLAUDE.md`](../../../ggsql-cli/CLAUDE.md). + +## Operational constraints + +- **A GPU adapter is required at render time.** Vello/wgpu is hephaestus's only + working backend. CI installs Mesa's lavapipe; headless containers need + something equivalent. +- **fontconfig is a build-time dependency on Linux.** Text layout goes through + parley/fontique, which links the system fontconfig to enumerate fonts, so + `libfontconfig1-dev` (or the distro equivalent supplying `fontconfig.pc`) must + be installed before building with `--features png`. macOS uses CoreText and + needs nothing extra. +- **Raster only.** No SVG/PDF — hephaestus's other backends are declared + placeholders. +- **MSRV split.** hephaestus needs rustc ≥1.88; ggsql's MSRV is CRAN-locked at + 1.86. The feature is therefore non-default and excluded from the MSRV job (CI + runs the png steps with `cargo +stable`), which also means this writer + is not viable for the R/CRAN target and is not the wasm default. Always check a + change still builds under `cargo +1.86 build` *without* the feature. +- **The dependency is the published `0.1.0` crate** (`src/Cargo.toml`), so + nothing here blocks publishing ggsql. hephaestus's own semver contract extends + to the `kurbo`, `peniko` and `wgpu` types in its public API, so a bump in any + of those is a breaking change to this writer even when hephaestus's own API + holds still. + +## Known gaps + +Deliberately not done, in rough order of how likely they are to bite: + +- **No snapshot PNG tests** (see [Testing](#testing)) — visual correctness is + checked by eyeballing, with the harness for doing it at scale. +- **No axis label thinning.** ggsql's resolved breaks are drawn as-is, so a + narrow facet panel can crowd or overlap long labels — which is why + `free_continuous_scale` narrows the *global* breaks to a panel rather than + letting hephaestus invent per-panel ones. +- **Legend titles and break labels don't parse markdown.** [`ggsql_theme`](wiring.rs) + sets `markdown` on the root text element, so the flag cascades to every slot — + but hephaestus only consults it where a slot goes through + `chrome::text::measure_for_element` / `draw_text_element_in_rect` (plot title, + subtitle, caption, axis titles, strip labels). Legend titles + (`chrome/legend/mod.rs`, `chrome/legend/colorbar.rs`), legend key labels + (`chrome/legend/measure.rs`, `chrome/legend/render_keys.rs`) and tick labels + (`chrome/axis.rs`, `chrome/linear_axis.rs`, `chrome/polar.rs`) build a + `TextRun::new` directly and draw their markers literally. **Fixing this is + upstream work**; nothing changes in this writer when it lands. +- **No switch on rich-text chrome.** [`ggsql_theme`](wiring.rs) turns markdown on + for the whole chrome cascade, so a title that wants a literal `*` has no way to + ask for one. The text layer has `parse`; chrome waits for ggsql to grow a theme + concept, which is where the same switch belongs. +- **Rich text costs ~1pt of layout.** A plain string measures slightly larger + through the rich shaper than through the plain one, so every axis title claims a + little more room and the panel comes out a few px smaller than it did before + markdown was on. Aligning the sheet's line height with the theme's (see + `ggsql_theme`) removed the bulk of it; the ~1pt that remains is the rich block + model's own box, which no sheet entry reaches. Visually imperceptible, but it is + why a residual diff over the harness shows nearly every cell as "changed". + +## See also + +- [`../vegalite/CLAUDE.md`](../vegalite/CLAUDE.md) — the sibling writer to mirror. +- [`../../CLAUDE.md`](../../CLAUDE.md) — the core crate: feature flags, pipeline. +- [`../../plot/CLAUDE.md`](../../plot/CLAUDE.md) — the AST and scale types this + writer consumes. +- [`/doc/syntax/`](../../../doc/syntax/) — authoritative ggsql syntax reference. diff --git a/src/writer/hephaestus/channels.rs b/src/writer/hephaestus/channels.rs new file mode 100644 index 000000000..a1765e2ae --- /dev/null +++ b/src/writer/hephaestus/channels.rs @@ -0,0 +1,288 @@ +//! Bridging ggsql aesthetic mappings and DataFrame columns to the typed data +//! hephaestus geoms consume. + +use arrow::array::{Array, ArrayRef, BinaryArray, LargeBinaryArray, LargeStringArray, StringArray}; +use arrow::datatypes::DataType; + +use hephaestus::color::Color; +use hephaestus::plot::geom::{BuildableGeom, GeomBuilder}; +use hephaestus::scales::geometry::{Coord, Geometry, Polygon as GeoPolygon}; + +use super::scales::parse_color; +use crate::array_util::{as_bool, as_f64, as_str, cast_array, value_to_string}; +use crate::{AestheticValue, DataFrame, GgsqlError, Layer, Result}; + +/// A column extracted in the type hephaestus expects for a channel: numeric +/// columns become `f64`s, text columns become category strings. +#[derive(Clone)] +pub enum ChannelData { + Floats(Vec), + Strings(Vec), +} + +impl ChannelData { + /// Select a subset of rows by index, preserving the channel's value type. + pub fn select(&self, idx: &[usize]) -> ChannelData { + match self { + ChannelData::Floats(v) => ChannelData::Floats(idx.iter().map(|&i| v[i]).collect()), + ChannelData::Strings(v) => { + ChannelData::Strings(idx.iter().map(|&i| v[i].clone()).collect()) + } + } + } + + /// Set this column on a geom builder under the given channel. + pub fn apply(self, builder: &mut GeomBuilder, channel: &str) { + match self { + ChannelData::Floats(values) => { + builder.set(channel, values); + } + ChannelData::Strings(values) => { + builder.set(channel, values); + } + } + } +} + +/// The DataFrame column name backing the given internal aesthetic, if it maps +/// to a column (rather than a literal value). +pub fn aesthetic_column_name<'a>(layer: &'a Layer, aesthetic: &str) -> Option<&'a str> { + match layer.mappings.get(aesthetic)? { + AestheticValue::Column { name, .. } => Some(name.as_str()), + AestheticValue::AnnotationColumn { name } => Some(name.as_str()), + AestheticValue::Literal(_) => None, + } +} + +/// The category a null stands in for when a scaled column reaches hephaestus. +/// +/// ggsql trains a categorical domain over the nulls as well, so `NULL` is a +/// level like any other and gets its own colour and legend key. hephaestus's +/// `DataColumn` has no null-carrying variant, though, so a null row cannot be +/// handed over as the `Value::Null` sitting in the domain — it would resolve to +/// nothing and the mark would draw unfilled. Both sides therefore agree on this +/// sentinel instead: [`scales::category_value`] puts it in the domain and in the +/// break positions, and [`column_to_channel`] puts it in the data. The visible +/// text is unaffected, because labels travel separately (`with_breaks_labeled`). +/// +/// It carries the internal `__ggsql_` prefix so a real category cannot collide +/// with it. +pub const NULL_CATEGORY: &str = "__ggsql_null__"; + +/// Extract a column as the channel type implied by its arrow dtype: text and +/// booleans → category strings, everything else → `f64`. +/// +/// This is the *scaled* path — the values here are looked up in a scale's +/// domain — so a null becomes [`NULL_CATEGORY`] rather than the empty string +/// [`column_to_strings`] uses for raw, unscaled text. +/// +/// A boolean is a category, not a number: ggsql trains a discrete domain over +/// it (as the Vega-Lite writer's `nominal` type does) and hephaestus matches +/// data to domain by `Value` variant, so a `true` handed over as `1.0` would +/// find no `Bool` entry and resolve to nothing. Both sides therefore agree on +/// the category name — [`scales::category_value`] renders the domain the same +/// way, and `wiring::constant_position` a literal. +pub fn column_to_channel(df: &DataFrame, name: &str) -> Result { + let array = df.column(name)?; + if is_text_column(df, name) || matches!(array.data_type(), DataType::Boolean) { + Ok(ChannelData::Strings(read_strings(df, name, NULL_CATEGORY)?)) + } else { + Ok(ChannelData::Floats(column_to_f64(df, name)?)) + } +} + +/// Whether a column holds text, and so must be read with [`column_to_strings`] +/// rather than cast to `f64`. +/// +/// Arrow's cast is a *safe* one: a text column cast to `Float64` comes back as +/// all-nulls — an `Ok` full of `NaN` — rather than an error. A caller that +/// accepts either a number or a keyword therefore has to ask this first; trying +/// [`column_to_f64`] and falling back on `Err` never reaches the keywords. +pub fn is_text_column(df: &DataFrame, name: &str) -> bool { + df.column(name) + .is_ok_and(|array| matches!(array.data_type(), DataType::Utf8 | DataType::LargeUtf8)) +} + +/// Read a numeric column as `f64`, casting from any numeric/temporal source +/// type and mapping nulls to `NaN`. +pub fn column_to_f64(df: &DataFrame, name: &str) -> Result> { + let array = df.column(name)?; + let casted; + let f64_array = if matches!(array.data_type(), DataType::Float64) { + as_f64(array)? + } else { + casted = cast_array(array, &DataType::Float64)?; + as_f64(&casted)? + }; + Ok(f64_array.iter().map(|v| v.unwrap_or(f64::NAN)).collect()) +} + +/// Read a column as strings, casting non-text columns to text. Nulls become +/// empty strings — this is the raw, unscaled path (text labels, shape and +/// linetype names), where an empty string is the right "nothing here". +pub fn column_to_strings(df: &DataFrame, name: &str) -> Result> { + read_strings(df, name, "") +} + +/// Read a column as strings, substituting `null_as` for null cells. Callers +/// differ only in what a null should become: nothing at all, or the sentinel +/// category a scale's domain knows about. +fn read_strings(df: &DataFrame, name: &str, null_as: &str) -> Result> { + let array = df.column(name)?; + let casted; + let str_array: &StringArray = if matches!(array.data_type(), DataType::Utf8) { + as_str(array)? + } else { + casted = cast_array(array, &DataType::Utf8)?; + as_str(&casted)? + }; + Ok((0..str_array.len()) + .map(|i| { + if str_array.is_null(i) { + null_as.to_string() + } else { + str_array.value(i).to_string() + } + }) + .collect()) +} + +/// Build a per-row group key from the layer's partition columns (concatenated +/// values), used as the hephaestus `keys` for multi-vertex geoms. Returns +/// `None` when there are no partition columns (single group). +pub fn build_group_keys(df: &DataFrame, partition_by: &[String]) -> Result>> { + if partition_by.is_empty() { + return Ok(None); + } + let arrays: Vec<&ArrayRef> = partition_by + .iter() + .map(|c| df.column(c)) + .collect::>()?; + let keys = (0..df.height()) + .map(|i| { + let mut key = String::new(); + for arr in &arrays { + key.push_str(&value_to_string(arr, i)); + key.push('\u{1f}'); // unit separator avoids cross-column collisions + } + key + }) + .collect(); + Ok(Some(keys)) +} + +/// Read a boolean column (arrow Boolean, or text `true`/`1`). Nulls → false. +pub fn column_to_bool(df: &DataFrame, name: &str) -> Result> { + let array = df.column(name)?; + if matches!(array.data_type(), DataType::Boolean) { + let a = as_bool(array)?; + Ok((0..a.len()).map(|i| !a.is_null(i) && a.value(i)).collect()) + } else { + Ok(column_to_strings(df, name)? + .iter() + .map(|s| matches!(s.to_lowercase().as_str(), "true" | "1")) + .collect()) + } +} + +/// Read a color column (visual-space literal values) as parsed colors, +/// defaulting unparseable entries to black. +pub fn column_to_colors(df: &DataFrame, name: &str) -> Result> { + Ok(column_to_strings(df, name)? + .iter() + .map(|s| parse_color(s).unwrap_or(Color::BLACK)) + .collect()) +} + +/// Read a geometry column into hephaestus `Geometry` values. ggsql's spatial +/// pipeline re-encodes the geometry aesthetic as WKB (arrow `Binary`), which we +/// decode via `Geometry::from_wkb`; hex-encoded WKB strings (PostGIS over ODBC) +/// are decoded too, mirroring the Vega-Lite writer's `parse_geometry_from_array`. +/// Null rows become `Geometry::Empty` (drawn as nothing). +pub fn column_to_geometry(df: &DataFrame, name: &str) -> Result> { + let array = df.column(name)?; + let parse = |bytes: &[u8]| -> Result { + Geometry::from_wkb(bytes) + .map_err(|e| GgsqlError::WriterError(format!("could not parse WKB geometry: {e:?}"))) + }; + (0..array.len()) + .map(|i| { + if array.is_null(i) { + return Ok(Geometry::Empty); + } + match array.data_type() { + DataType::Binary => parse( + array + .as_any() + .downcast_ref::() + .ok_or_else(|| geom_type_err("Binary"))? + .value(i), + ), + DataType::LargeBinary => parse( + array + .as_any() + .downcast_ref::() + .ok_or_else(|| geom_type_err("LargeBinary"))? + .value(i), + ), + DataType::Utf8 => parse(&decode_hex_wkb( + array + .as_any() + .downcast_ref::() + .ok_or_else(|| geom_type_err("Utf8"))? + .value(i), + )?), + DataType::LargeUtf8 => parse(&decode_hex_wkb( + array + .as_any() + .downcast_ref::() + .ok_or_else(|| geom_type_err("LargeUtf8"))? + .value(i), + )?), + other => Err(GgsqlError::WriterError(format!( + "geometry column has unsupported type {other:?}; expected WKB (Binary)" + ))), + } + }) + .collect() +} + +fn geom_type_err(kind: &str) -> GgsqlError { + GgsqlError::WriterError(format!("failed to read geometry column as {kind}")) +} + +/// Decode a hex-encoded WKB string (optionally `\x`-prefixed, as PostGIS emits +/// over ODBC) into raw bytes. +fn decode_hex_wkb(hex: &str) -> Result> { + let hex = hex.strip_prefix("\\x").unwrap_or(hex); + (0..hex.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(hex.get(i..i + 2).unwrap_or(""), 16) + .map_err(|_| GgsqlError::WriterError(format!("invalid hex in WKB at position {i}"))) + }) + .collect() +} + +/// Parse a WKT string into a set of polylines (one per LineString). A +/// MultiLineString flattens to its parts; a bare LineString yields one line; +/// other geometry types contribute nothing. Used to feed graticule grid lines +/// to a map's Custom projection. +pub fn wkt_to_lines(wkt: &str) -> Vec> { + match Geometry::from_wkt(wkt) { + Ok(Geometry::MultiLineString(lines)) => lines, + Ok(Geometry::LineString(line)) => vec![line], + _ => Vec::new(), + } +} + +/// Parse a WKT boundary string into polygon outlines for a Custom projection's +/// drawing surface. A Polygon yields one outline (with its holes); a +/// MultiPolygon yields all its parts; non-areal geometries yield nothing. +pub fn wkt_to_outline(wkt: &str) -> Vec { + match Geometry::from_wkt(wkt) { + Ok(Geometry::Polygon(p)) => vec![p], + Ok(Geometry::MultiPolygon(polys)) => polys, + _ => Vec::new(), + } +} diff --git a/src/writer/hephaestus/facet.rs b/src/writer/hephaestus/facet.rs new file mode 100644 index 000000000..78ed23086 --- /dev/null +++ b/src/writer/hephaestus/facet.rs @@ -0,0 +1,478 @@ +//! FACET → multi-panel composition. +//! +//! ggsql resolves faceting fully at execution time: the layout (Wrap/Grid), the +//! `free` bool array, Wrap's `ncol`, and per-row facet assignment materialized in +//! the ordinary aesthetic columns `__ggsql_aes_facet1__` (and `facet2__` for +//! Grid). This module turns that into a hephaestus [`Composition`] of named +//! panels plus a [`Panel`] list the writer loops over — one hephaestus `Plot` per +//! panel, sharing the composition's scale registry. +//! +//! Panel ordering mirrors the Vega-Lite writer's `resolve_facet_ordering`: the +//! facet aesthetic's `SCALE` (its `input_range`, then `reverse`) drives the +//! order, falling back to a numeric-aware ascending sort of the present values. + +use std::cmp::Ordering; +use std::collections::HashSet; + +use arrow::array::{Array, UInt32Array}; +use hephaestus::composition::{grid, spacer, Composition, Element, Patch}; + +use super::channels::{column_to_f64, column_to_strings}; +use crate::naming; +use crate::plot::{ArrayElement, FacetLayout, ParameterValue, Scale, ScaleTypeKind}; +use crate::{DataFrame, Plot, Result}; + +/// Patch id for the single (unfaceted) panel. +pub const PANEL_ID: &str = "ggsql_panel"; + +/// The value selecting one facet cell's rows: the facet column's text form, +/// paired with whether the cell was NULL. +/// +/// [`column_to_strings`] renders a NULL as `""`, so text alone would make a +/// genuine empty-string category and a NULL the same panel. The Vega-Lite +/// writer keeps them apart (a null level labels as `"null"` and gets its own +/// facet), so this writer carries the null flag alongside the text everywhere a +/// level is identified or matched. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct LevelKey { + text: String, + is_null: bool, +} + +/// One facet cell: which facet values it holds, its grid position (for edge-only +/// axes), and the strip-label text to show. +pub struct Panel { + /// hephaestus patch id, unique per panel. + pub id: String, + /// 0-based panel index (order of enumeration), for per-panel scale names. + pub index: usize, + /// Facet1 (Wrap panel / Grid row) value selecting this panel's rows. + pub facet1: Option, + /// Facet2 (Grid column) value; `None` for Wrap. + pub facet2: Option, + /// Top strip label (Wrap header, or Grid column header on the top row). + pub strip_top: Option, + /// Right strip label (Grid row header on the right column). + pub strip_right: Option, + /// Whether this panel is in the left column (draws the y-axis when fixed). + pub first_col: bool, + /// Whether this panel is the bottom-most present panel in its column (draws + /// the x-axis when fixed). + pub last_row: bool, +} + +impl Panel { + /// The unfaceted single panel: draws both axes, no strips. + fn single() -> Panel { + Panel { + id: PANEL_ID.to_string(), + index: 0, + facet1: None, + facet2: None, + strip_top: None, + strip_right: None, + first_col: true, + last_row: true, + } + } +} + +/// The unfaceted layout: one full-size panel, no strips. +/// +/// Also what a `FACET` over an empty result set collapses to: there are no +/// levels to lay out, and a grid of zero cells is not a composition hephaestus +/// will build. The figure then reads like the unfaceted empty plot — one empty +/// panel with its axes — rather than a bare canvas. +fn single_panel() -> (Composition, Vec) { + ( + grid(1, 1, vec![Element::from(Patch::new(PANEL_ID))]), + vec![Panel::single()], + ) +} + +/// Build the panel grid for a plot. Returns a single-cell composition + one +/// [`Panel`] when there is no `FACET`, otherwise the faceted grid. +pub fn build_panels( + spec: &Plot, + data: &std::collections::HashMap, +) -> Result<(Composition, Vec)> { + let Some(facet) = &spec.facet else { + return Ok(single_panel()); + }; + let layer0 = super::layer_dataframe(&spec.layers[0], 0, data)?; + match &facet.layout { + FacetLayout::Wrap { .. } => build_wrap(spec, facet, layer0), + FacetLayout::Grid { .. } => build_grid(spec, layer0), + } +} + +/// Wrap: N panels flowed row-major into `ncol` columns. +fn build_wrap( + spec: &Plot, + facet: &crate::plot::Facet, + layer0: &DataFrame, +) -> Result<(Composition, Vec)> { + let levels = ordered_levels(spec, layer0, "facet1")?; + if levels.is_empty() { + return Ok(single_panel()); + } + let n = levels.len(); + let ncol = wrap_ncol(facet, n); + let nrow = n.div_ceil(ncol); + + let mut panels = Vec::with_capacity(n); + for (idx, level) in levels.iter().enumerate() { + let col = idx % ncol; + // Bottom-most present panel in this column: no panel sits `ncol` cells + // below it. Governs where the x-axis shows when the last row is partial. + let last_row = idx + ncol >= n; + panels.push(Panel { + id: format!("facet_{idx}"), + index: idx, + facet1: Some(level.key.clone()), + facet2: None, + strip_top: Some(level.label.clone()), + strip_right: None, + first_col: col == 0, + last_row, + }); + } + + // Cells row-major, padding the trailing slots of a partial last row. + let mut cells: Vec = Vec::with_capacity(nrow * ncol); + for slot in 0..(nrow * ncol) { + if slot < panels.len() { + cells.push(Element::from(Patch::new(panels[slot].id.clone()))); + } else { + cells.push(Element::from(spacer())); + } + } + Ok((grid(nrow, ncol, cells), panels)) +} + +/// Grid: rows = facet1 levels, columns = facet2 levels. Column strips on the top +/// row, row strips on the right column. +fn build_grid(spec: &Plot, layer0: &DataFrame) -> Result<(Composition, Vec)> { + let rows = ordered_levels(spec, layer0, "facet1")?; + let cols = ordered_levels(spec, layer0, "facet2")?; + if rows.is_empty() || cols.is_empty() { + return Ok(single_panel()); + } + let nrow = rows.len(); + let ncol = cols.len(); + + let mut panels = Vec::with_capacity(nrow * ncol); + let mut cells: Vec = Vec::with_capacity(nrow * ncol); + let mut index = 0; + for (r, rowv) in rows.iter().enumerate() { + for (c, colv) in cols.iter().enumerate() { + let id = format!("facet_{r}_{c}"); + panels.push(Panel { + id: id.clone(), + index, + facet1: Some(rowv.key.clone()), + facet2: Some(colv.key.clone()), + strip_top: (r == 0).then(|| colv.label.clone()), + strip_right: (c == ncol - 1).then(|| rowv.label.clone()), + first_col: c == 0, + last_row: r == nrow - 1, + }); + cells.push(Element::from(Patch::new(id))); + index += 1; + } + } + Ok((grid(nrow, ncol, cells), panels)) +} + +/// The resolved Wrap column count (ggsql computes it during resolution); falls +/// back to a single row if somehow absent. +fn wrap_ncol(facet: &crate::plot::Facet, n: usize) -> usize { + match facet.properties.get("ncol") { + Some(ParameterValue::Number(c)) if *c >= 1.0 => (*c as usize).min(n).max(1), + _ => n.max(1), + } +} + +/// One distinct facet level: the key selecting its rows, the numeric form of the +/// same cell (the bin-join key for a binned facet), and the strip text. +struct Level { + key: LevelKey, + value: f64, + label: String, +} + +/// The per-row facet key of one facet column: its text form paired with the null +/// bitmap, so a NULL cell selects a different panel than an empty-string +/// category — see [`LevelKey`]. +fn level_keys(df: &DataFrame, column: &str) -> Result> { + let array = df.column(column)?; + Ok(column_to_strings(df, column)? + .into_iter() + .enumerate() + .map(|(i, text)| LevelKey { + text, + is_null: array.is_null(i), + }) + .collect()) +} + +/// Distinct facet levels present in the data, ordered per the facet scale and +/// labelled for the strip. +fn ordered_levels(spec: &Plot, df: &DataFrame, internal_aes: &str) -> Result> { + let col = naming::aesthetic_column(internal_aes); + let keys = level_keys(df, &col)?; + // The numeric form of the same column, for the binned join. A text facet + // column can't cast; `value` is only read for binned scales. + let values = column_to_f64(df, &col).unwrap_or_else(|_| vec![f64::NAN; keys.len()]); + + let mut seen = HashSet::new(); + let mut distinct: Vec = Vec::new(); + for (i, key) in keys.iter().enumerate() { + if seen.insert(key.clone()) { + distinct.push(Level { + key: key.clone(), + value: values[i], + label: String::new(), + }); + } + } + + let scale = spec.find_scale(internal_aes); + let mut ordered = order_levels(distinct, scale); + for level in &mut ordered { + level.label = facet_label(scale, level); + } + Ok(ordered) +} + +/// Order distinct facet levels, mirroring the Vega-Lite writer's +/// `resolve_facet_ordering`: a binned facet sorts by its (numeric) bin centre; +/// everything else follows the scale's `input_range`, then any present-but-unlisted +/// values sorted numeric-aware ascending. Reversed when the scale sets +/// `reverse => true`. +fn order_levels(mut distinct: Vec, scale: Option<&Scale>) -> Vec { + let reverse = super::scales::is_reversed(scale); + + let mut ordered = if is_binned(scale) { + // Bin centres sort naturally; NULL (censored) panels go last. + distinct.sort_by(|a, b| match (a.value.is_finite(), b.value.is_finite()) { + (true, true) => a.value.total_cmp(&b.value), + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + (false, false) => Ordering::Equal, + }); + distinct + } else { + match scale.and_then(|s| s.input_range.as_ref()) { + Some(range) => { + let order: Vec = range.iter().map(element_to_key).collect(); + let (mut ranked, mut extra): (Vec, Vec) = (Vec::new(), Vec::new()); + for key in &order { + if let Some(pos) = distinct.iter().position(|l| &l.key == key) { + ranked.push(distinct.remove(pos)); + } + } + extra.extend(distinct); + sort_levels(&mut extra); + ranked.extend(extra); + ranked + } + None => { + sort_levels(&mut distinct); + distinct + } + } + }; + if reverse { + ordered.reverse(); + } + ordered +} + +/// Numeric-aware ascending sort by key: numeric when every key parses as `f64`, +/// otherwise lexical. +fn sort_levels(levels: &mut [Level]) { + if levels.iter().all(|l| l.key.text.parse::().is_ok()) { + levels.sort_by(|a, b| { + let a = a.key.text.parse::().unwrap(); + let b = b.key.text.parse::().unwrap(); + a.partial_cmp(&b).unwrap_or(Ordering::Equal) + }); + } else { + levels.sort_by(|a, b| a.key.text.cmp(&b.key.text)); + } +} + +/// Whether a facet scale is binned (numeric/temporal facet columns default to it). +fn is_binned(scale: Option<&Scale>) -> bool { + scale + .and_then(|s| s.scale_type.as_ref()) + .map(|st| st.scale_type_kind()) + == Some(ScaleTypeKind::Binned) +} + +/// Strip text for one facet level, mirroring the Vega-Lite writer's +/// `build_indexed_facet_label_expr` (discrete + `RENAMING`) and +/// `build_binned_facet_label_expr` (bin ranges). Computed here from typed values +/// rather than as a Vega expression over serialized data, so a temporal binned +/// facet — which Vega-Lite silently fails to match — labels correctly. +fn facet_label(scale: Option<&Scale>, level: &Level) -> String { + // NULL keys as the literal string "null", matching ggsql's RENAMING key for + // a null level (`RENAMING null => 'The rest'`). + if level.key.is_null { + return match scale.and_then(|s| s.label_mapping.as_ref()) { + Some(mapping) => match mapping.get("null") { + Some(Some(label)) => label.clone(), + Some(None) => String::new(), + None => "null".to_string(), + }, + None => "null".to_string(), + }; + } + if is_binned(scale) { + // The column carries the bin centre; label it with the bin's range. + let bins = scale.map(super::scales::binned_bins).unwrap_or_default(); + if let Some(i) = super::scales::bin_at_centre(&bins, level.value) { + return bins[i].label.clone(); + } + return level.key.text.clone(); + } + discrete_label(scale, level) +} + +/// A discrete/ordinal level's label: the `RENAMING` override for its domain +/// value, an empty strip when suppressed, else the raw value. +fn discrete_label(scale: Option<&Scale>, level: &Level) -> String { + let Some(scale) = scale else { + return level.key.text.clone(); + }; + let Some(mapping) = scale.label_mapping.as_ref() else { + return level.key.text.clone(); + }; + // `label_mapping` is keyed on the domain element's `to_key_string()`, which + // can differ from the column's arrow-cast text (e.g. "5" vs "5.0"), so find + // the matching domain element first. + let key = scale + .input_range + .as_ref() + .and_then(|range| { + range + .iter() + .find(|e| element_matches(e, level)) + .map(|e| e.to_key_string()) + }) + .unwrap_or_else(|| level.key.text.clone()); + match mapping.get(&key) { + Some(Some(label)) => label.clone(), + Some(None) => String::new(), + None => level.key.text.clone(), + } +} + +/// Whether a domain element denotes the same value as this level: by key first, +/// then numerically (a `DOUBLE` column's `"5.0"` still matches `Number(5.0)`). +fn element_matches(element: &ArrayElement, level: &Level) -> bool { + if element_to_key(element) == level.key { + return true; + } + if level.key.is_null { + return false; + } + match element.to_f64() { + Some(n) => level.value.is_finite() && n == level.value, + None => false, + } +} + +/// An `input_range` element as the key the facet column carries for it: the text +/// form the column casts to (whole numbers as integers, matching an integer +/// column's cast to text), plus whether the element is the null level. +fn element_to_key(element: &ArrayElement) -> LevelKey { + let text = match element { + ArrayElement::String(s) => s.clone(), + ArrayElement::Number(n) if n.fract() == 0.0 && n.is_finite() => format!("{}", *n as i64), + ArrayElement::Number(n) => n.to_string(), + ArrayElement::Boolean(b) => b.to_string(), + ArrayElement::Null => String::new(), + other => format!("{other:?}"), + }; + LevelKey { + text, + is_null: matches!(element, ArrayElement::Null), + } +} + +/// The scale names a panel binds its position channels to, and whether each +/// dimension is free. For fixed dimensions the name is the shared `pos1`/`pos2`; +/// for free dimensions it is a per-panel name (`pos1__p{index}`), so each panel +/// resolves through its own domain. +pub struct PanelScales { + pub pos1: String, + pub pos2: String, + pub free_x: bool, + pub free_y: bool, +} + +impl PanelScales { + pub fn new(spec: &Plot, panel: &Panel) -> Self { + let free_x = spec.facet.as_ref().is_some_and(|f| f.is_free("pos1")); + let free_y = spec.facet.as_ref().is_some_and(|f| f.is_free("pos2")); + PanelScales { + pos1: if free_x { + format!("pos1__p{}", panel.index) + } else { + "pos1".to_string() + }, + pos2: if free_y { + format!("pos2__p{}", panel.index) + } else { + "pos2".to_string() + }, + free_x, + free_y, + } + } + + /// Point one dimension back at the shared `pos1`/`pos2` scale, for a panel + /// whose free per-panel scale could not be built (an empty facet cell has no + /// extent to free the dimension over). The dimension stays flagged free, so + /// its axis is still drawn on this panel like on every other. + pub fn use_shared(&mut self, aesthetic: &str) { + match aesthetic { + "pos1" => self.pos1 = "pos1".to_string(), + "pos2" => self.pos2 = "pos2".to_string(), + _ => {} + } + } +} + +/// The rows of `df` belonging to `panel`, sliced via `DataFrame::take`. A layer +/// with no facet column (annotation/global layers) is used whole for every panel. +pub fn panel_dataframe(df: &DataFrame, panel: &Panel) -> Result { + let Some(want1) = &panel.facet1 else { + return Ok(df.clone()); + }; + let f1 = naming::aesthetic_column("facet1"); + if df.column(&f1).is_err() { + return Ok(df.clone()); + } + let c1 = level_keys(df, &f1)?; + let c2 = match &panel.facet2 { + Some(_) => Some(level_keys(df, &naming::aesthetic_column("facet2"))?), + None => None, + }; + + let mut idx: Vec = Vec::new(); + for i in 0..df.height() { + if &c1[i] != want1 { + continue; + } + if let (Some(c2), Some(want2)) = (&c2, &panel.facet2) { + if &c2[i] != want2 { + continue; + } + } + idx.push(i as u32); + } + df.take(&UInt32Array::from(idx)) +} diff --git a/src/writer/hephaestus/geom/area.rs b/src/writer/hephaestus/geom/area.rs new file mode 100644 index 000000000..c209590f0 --- /dev/null +++ b/src/writer/hephaestus/geom/area.rs @@ -0,0 +1,222 @@ +//! `area`, `ribbon`, and `density` geoms → hephaestus `RibbonGeom` (a filled +//! band between two curves). Orientation-aware: aligned bands run along x with +//! the extent on y (`y`/`y2`); transposed bands run along y with the extent on +//! x (`x`/`x2`). + +use std::collections::HashMap; + +use hephaestus::color::rgb8; + +use super::super::channels::{aesthetic_column_name, build_group_keys, column_to_f64}; +use super::super::scales::RangeKind; +use super::super::wiring::{ + Ctx, GeomSpec, LegendKind, MatDefault, MaterialSpec, PanelAxis, PositionSpec, +}; +use crate::plot::layer::geom::GeomType; + +pub fn spec(ctx: &Ctx) -> GeomSpec { + let ribbon = ctx.layer.geom.geom_type() == GeomType::Ribbon; + + let baseline = if ctx.transposed { "pos1end" } else { "pos2end" }; + + let positions = if !ctx.transposed { + // Band along x; extent on y. ribbon → [pos2min, pos2max]; area/density + // → [pos2end (baseline), pos2]. + let (lo, hi) = if ribbon { + ("pos2min", "pos2max") + } else { + ("pos2end", "pos2") + }; + vec![ + PositionSpec::new("x", "pos1", PanelAxis::X), + PositionSpec::new("y", lo, PanelAxis::Y), + PositionSpec::new("y2", hi, PanelAxis::Y), + ] + } else { + // Band along y; extent on x. + let (lo, hi) = if ribbon { + ("pos1min", "pos1max") + } else { + ("pos1end", "pos1") + }; + vec![ + PositionSpec::new("y", "pos2", PanelAxis::Y), + PositionSpec::new("x", lo, PanelAxis::X), + PositionSpec::new("x2", hi, PanelAxis::X), + ] + }; + + GeomSpec { + positions, + material: vec![ + MaterialSpec::new( + "fill", + "fill", + RangeKind::Color, + MatDefault::Color(rgb8(0, 0, 0)), + ), + // A ribbon's two edge curves are stroked independently: `stroke` + // outlines curve A (the baseline / lower edge), `stroke2` curve B + // (the data curve). Wiring only the first leaves the band's visible + // silhouette unbordered, so every outline aesthetic is sent to both. + // Whether curve A's outline is *visible* is then decided per mark by + // `baseline_outline` — see there for why an area's is usually not. + MaterialSpec::new("stroke", "stroke", RangeKind::Color, MatDefault::None), + MaterialSpec::new("stroke", "stroke2", RangeKind::Color, MatDefault::None), + MaterialSpec::new( + "opacity", + "fill_opacity", + RangeKind::Number, + MatDefault::Number(0.8), + ), + MaterialSpec::new( + "linewidth", + "linewidth", + RangeKind::Number, + MatDefault::None, + ), + MaterialSpec::new( + "linewidth", + "linewidth2", + RangeKind::Number, + MatDefault::None, + ), + MaterialSpec::new( + "linetype", + "linetype", + RangeKind::Linetype, + MatDefault::None, + ), + MaterialSpec::new( + "linetype", + "linetype2", + RangeKind::Linetype, + MatDefault::None, + ), + ], + raw_strings: &[], + raw_numbers: vec![], + // A ribbon's two edges are both data, so both are outlined unconditionally. + data_channels: if ribbon { + vec![] + } else { + vec![("stroke_opacity", baseline_outline(ctx, baseline))] + }, + legend_key: LegendKind::Rect, + grouped: true, + } +} + +/// Whether each row's mark takes an outline on curve A — the baseline of an +/// `area` or `density` band — as a per-row `stroke_opacity` for that curve. +/// +/// A baseline that holds one value is the axis, not part of the shape: stroking +/// it draws a rule along `y = 0` under the chart, which is why ggplot2's +/// `geom_area`/`geom_density` outline only their upper edge. A baseline that +/// *wanders* is genuine silhouette — the bottom band of a centred stack +/// (streamgraph) rides on `-total/2` and wants its own border just as much as +/// its upper edge does. So the test is per mark on the resolved data rather than +/// per geom: within a normal stack the bottom band sits on the axis (no outline) +/// while the bands above it ride on their neighbour's upper edge (outlined, and +/// coincident with the outline that neighbour already draws). +/// +/// hephaestus resolves a ribbon's outline channels once per mark, from the +/// mark's first row, so a per-row 0/1 here switches whole marks. Marks are the +/// `keys` [`build_and_add`](super::super::wiring::build_and_add) derives from +/// `partition_by`, so the grouping below has to match it. Zeroing the opacity is +/// what expresses "no outline" per mark: curve A is stroked whenever its channel +/// is bound at all, and the binding belongs to the geom as a whole. +fn baseline_outline(ctx: &Ctx, baseline: &str) -> Vec { + let n = ctx.df.height(); + // No column at all: the baseline is a bare constant, so it cannot wander. + let Some(column) = aesthetic_column_name(ctx.layer, baseline) else { + return vec![AXIS; n]; + }; + let (Ok(values), Ok(keys)) = ( + column_to_f64(ctx.df, column), + build_group_keys(ctx.df, &ctx.layer.partition_by), + ) else { + return vec![AXIS; n]; + }; + silhouette_opacity(&values, keys.as_deref()) +} + +/// Opacity for a baseline that is part of the shape, and for one that is the +/// axis. +const SILHOUETTE: f64 = 1.0; +const AXIS: f64 = 0.0; + +/// The rule itself: a mark's baseline is silhouette when its values are not all +/// the same. `keys` groups rows into marks (`None` = one mark). +fn silhouette_opacity(values: &[f64], keys: Option<&[String]>) -> Vec { + let mut marks: HashMap<&str, Vec> = HashMap::new(); + for i in 0..values.len() { + marks.entry(keys.map_or("", |k| &k[i])).or_default().push(i); + } + + let mut opacity = vec![AXIS; values.len()]; + for rows in marks.values() { + // Nulls arrive as NaN and are not drawn, so they say nothing about the + // baseline's shape. + let mut finite = rows.iter().map(|&i| values[i]).filter(|v| v.is_finite()); + let wanders = match finite.next() { + Some(first) => finite.any(|v| v != first), + None => false, + }; + if wanders { + for &i in rows { + opacity[i] = SILHOUETTE; + } + } + } + opacity +} + +#[cfg(test)] +mod tests { + use super::{silhouette_opacity, AXIS, SILHOUETTE}; + + fn keys(names: &[&str]) -> Vec { + names.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn flat_baseline_is_the_axis() { + // A plain area: `pos2end` is 0 everywhere. + let opacity = silhouette_opacity(&[0.0, 0.0, 0.0], None); + assert_eq!(opacity, vec![AXIS; 3]); + } + + #[test] + fn wandering_baseline_is_silhouette() { + // A centred stack's only band, riding on -total/2. + let opacity = silhouette_opacity(&[-3.0, -4.5, -2.0], None); + assert_eq!(opacity, vec![SILHOUETTE; 3]); + } + + #[test] + fn normal_stack_outlines_every_band_but_the_bottom_one() { + // Group "a" sits on the axis; "b" rides on a's upper edge. + let opacity = silhouette_opacity( + &[0.0, 0.0, 0.0, 3.0, 5.0, 4.0], + Some(&keys(&["a", "a", "a", "b", "b", "b"])), + ); + assert_eq!( + opacity, + vec![AXIS, AXIS, AXIS, SILHOUETTE, SILHOUETTE, SILHOUETTE] + ); + } + + #[test] + fn centred_stack_outlines_every_band() { + let opacity = + silhouette_opacity(&[-4.0, -6.0, -1.0, 0.5], Some(&keys(&["a", "a", "b", "b"]))); + assert_eq!(opacity, vec![SILHOUETTE; 4]); + } + + #[test] + fn nulls_do_not_make_a_baseline_wander() { + let opacity = silhouette_opacity(&[0.0, f64::NAN, 0.0], None); + assert_eq!(opacity, vec![AXIS; 3]); + } +} diff --git a/src/writer/hephaestus/geom/boxplot.rs b/src/writer/hephaestus/geom/boxplot.rs new file mode 100644 index 000000000..39cb8d39f --- /dev/null +++ b/src/writer/hephaestus/geom/boxplot.rs @@ -0,0 +1,295 @@ +//! `boxplot` composite geom. ggsql's stat emits one row per (category, +//! component); the component is tagged by the `type` aesthetic. We decompose +//! into: box (`RectGeom`, q1→q3 filling the category band), whiskers +//! (`SegmentGeom`, box edge → fence), median (`SegmentGeom` spanning the band), +//! optional whisker caps (`SegmentGeom`, the `hinge` SETTING), and outliers +//! (`PointGeom`). All components share the `pos1`/`pos2` scales. +//! +//! Which axis carries the categories and which the summary values follows the +//! layer's orientation (`BandAxes`): a transposed boxplot has its categories on +//! `pos2` and its values in the `pos1` family. + +use hephaestus::color::rgb8; +use hephaestus::plot::geom::{BuildableGeom, GeomBuilder}; +use hephaestus::plot::{Plot as HPlot, PointGeom, RectGeom, SegmentGeom}; + +use super::super::channels::{column_to_channel, column_to_f64, column_to_strings}; +use super::super::scales::RangeKind; +use super::super::wiring::{ + apply_material, band_edges, band_half_width, dodge_offsets, require_column, resolve_color, + resolve_material, side_sign, BandAxes, Ctx, LegendKind, MatDefault, MaterialSource, + MaterialSpec, +}; +use super::hinge::{caps, hinge_points}; +use crate::Result; + +/// The layer aesthetics this composite styles, with ggsql's boxplot defaults. +/// Used both to resolve them and to dress the legend keys in the layer's look. +fn material() -> [MaterialSpec; 5] { + [ + MaterialSpec::new( + "fill", + "fill", + RangeKind::Color, + MatDefault::Color(rgb8(255, 255, 255)), + ), + MaterialSpec::new( + "stroke", + "stroke", + RangeKind::Color, + MatDefault::Color(rgb8(60, 60, 60)), + ), + MaterialSpec::new( + "linewidth", + "linewidth", + RangeKind::Number, + MatDefault::None, + ), + MaterialSpec::new( + "linetype", + "linetype", + RangeKind::Linetype, + MatDefault::None, + ), + MaterialSpec::new( + "opacity", + "fill_opacity", + RangeKind::Number, + MatDefault::None, + ), + ] +} + +pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { + let (layer, df) = (ctx.layer, ctx.df); + let n = df.height(); + + let axes = BandAxes::new(ctx); + let cat_aes = axes.band(); + let value_aes = axes.value(); + let value2_aes = format!("{value_aes}end"); + + let cat_col = require_column(ctx, cat_aes)?; + let type_col = require_column(ctx, "type")?; + let value_col = require_column(ctx, value_aes)?; + let value2_col = require_column(ctx, &value2_aes)?; + + let cat = column_to_channel(df, cat_col)?; + let v1 = column_to_f64(df, value_col)?; + let v2 = column_to_f64(df, value2_col)?; + let types = column_to_strings(df, type_col)?; + + let rows_of = |t: &str| -> Vec { (0..n).filter(|&i| types[i] == t).collect() }; + let box_i = rows_of("box"); + let med_i = rows_of("median"); + let whisk_i: Vec = (0..n) + .filter(|&i| types[i] == "lower_whisker" || types[i] == "upper_whisker") + .collect(); + let out_i = rows_of("outlier"); + + // Bind the position channels (panel-aware for free facet scales). A channel + // always drives the same panel axis, whatever the orientation. + for (channel, scale) in [ + ("x", ctx.pos1_scale), + ("x2", ctx.pos1_scale), + ("y", ctx.pos2_scale), + ("y2", ctx.pos2_scale), + ] { + plot.set_binding(channel, scale); + } + + // What this composite styles, in one table: the ggsql defaults a legend key + // should wear when nothing is mapped, and the aliasing each resolve below + // uses. A composite has no `GeomSpec`, so it declares the same table itself. + let material = material(); + + // Resolve fill + stroke once (data-mapped → shared scale/legend, else + // constant), mirroring the VL writer's shared-encoding model: every + // component draws with the same resolved fill/stroke. + let fill = resolve_color( + ctx, + plot, + "fill", + "fill", + rgb8(255, 255, 255), + LegendKind::Rect, + &material, + )?; + let stroke = resolve_color( + ctx, + plot, + "stroke", + "stroke", + rgb8(60, 60, 60), + LegendKind::Rect, + &material, + )?; + // Outline width + dash pattern, resolved the same way and applied to every + // component — the Vega-Lite writer puts `strokeWidth`/`strokeDash` in the + // boxplot's shared encoding, so all five marks pick them up. + let linewidth = resolve_material( + ctx, + plot, + "linewidth", + "linewidth", + RangeKind::Number, + LegendKind::Line, + &material, + )?; + let linetype = resolve_material( + ctx, + plot, + "linetype", + "linetype", + RangeKind::Linetype, + LegendKind::Line, + &material, + )?; + // `opacity` retargets to the box's fill, mirroring the Vega-Lite writer + // (`opacity` → `fillOpacity` for a fill-bearing geom); the stroke-only + // components have no fill to fade. Resolved like the rest, so a mapped + // column fades each box by its own datum instead of by the first row's. + let alpha = resolve_material( + ctx, + plot, + "opacity", + "fill_opacity", + RangeKind::Number, + LegendKind::Rect, + &material, + )?; + // Outlier marker size and shape. Their legend key is a point, since that is + // the component they describe; they stay out of `material()` because a box + // swatch draws neither. + let size = resolve_material( + ctx, + plot, + "size", + "size", + RangeKind::Number, + LegendKind::Point, + &material, + )?; + let shape = resolve_material( + ctx, + plot, + "shape", + "shape", + RangeKind::Shape, + LegendKind::Point, + &material, + )?; + // Box width (band fraction, dodge-aware) + per-row dodge offsets. `side` + // narrows the box to one half of the band; the full `width` is kept for the + // dodge calculation (ggsql already applied it), so a half-box pairs cleanly + // with a half-violin on the same band. + let offsets = dodge_offsets(df, axes.dodge()); + let (near, far) = band_edges(band_half_width(layer, 0.75), side_sign(layer)); + + let (band_ch, band_ch2) = axes.band_channels(); + let (frac_ch, frac_ch2) = axes.band_fraction_channels(); + let (value_ch, value_ch2) = axes.value_channels(); + + // Box: a rect from q1 to q3 occupying `width` of the band (dodge-offset). + if !box_i.is_empty() { + let mut b = RectGeom::builder(); + cat.select(&box_i).apply(&mut b, band_ch); + cat.select(&box_i).apply(&mut b, band_ch2); + b.set(value_ch, pick(&v1, &box_i)); + b.set(value_ch2, pick(&v2, &box_i)); + b.set(frac_ch, shift(&offsets, &box_i, near)); + b.set(frac_ch2, shift(&offsets, &box_i, far)); + fill.apply(&mut b, "fill", &box_i); + stroke.apply(&mut b, "stroke", &box_i); + outline(&mut b, linewidth.as_ref(), linetype.as_ref(), &box_i); + apply_material(&mut b, alpha.as_ref(), "fill_opacity", &box_i); + plot.add_geom(b.build()); + } + + // Whiskers: segments at the band centre, box edge → fence. They stay on the + // centreline under `side`, like the outliers. + if !whisk_i.is_empty() { + let mut b = SegmentGeom::builder(); + cat.select(&whisk_i).apply(&mut b, band_ch); + cat.select(&whisk_i).apply(&mut b, band_ch2); + b.set(value_ch, pick(&v1, &whisk_i)); + b.set(value_ch2, pick(&v2, &whisk_i)); + b.set(frac_ch, shift(&offsets, &whisk_i, 0.0)); + b.set(frac_ch2, shift(&offsets, &whisk_i, 0.0)); + stroke.apply(&mut b, "stroke", &whisk_i); + outline(&mut b, linewidth.as_ref(), linetype.as_ref(), &whisk_i); + plot.add_geom(b.build()); + } + + // Median: a segment spanning the box's half of the band at the median value. + if !med_i.is_empty() { + let mut b = SegmentGeom::builder(); + cat.select(&med_i).apply(&mut b, band_ch); + cat.select(&med_i).apply(&mut b, band_ch2); + b.set(value_ch, pick(&v1, &med_i)); + b.set(value_ch2, pick(&v1, &med_i)); + b.set(frac_ch, shift(&offsets, &med_i, near)); + b.set(frac_ch2, shift(&offsets, &med_i, far)); + stroke.apply(&mut b, "stroke", &med_i); + outline(&mut b, linewidth.as_ref(), linetype.as_ref(), &med_i); + plot.add_geom(b.build()); + } + + // Whisker caps at the fence ends, `hinge` points wide (absent by default). + if let (Some(hinge), false) = (hinge_points(layer), whisk_i.is_empty()) { + let mut b = caps( + ctx, + axes, + cat.select(&whisk_i), + pick(&v2, &whisk_i), + shift(&offsets, &whisk_i, 0.0), + hinge, + ); + stroke.apply(&mut b, "stroke", &whisk_i); + outline(&mut b, linewidth.as_ref(), linetype.as_ref(), &whisk_i); + plot.add_geom(b.build()); + } + + // Outliers: points at their value wearing the layer's fill and stroke — + // the Vega-Lite writer puts `fill`/`fillOpacity` in the boxplot's shared + // encoding, so its outlier marks are filled too. Honors `size`/`shape`. + if !out_i.is_empty() { + let mut b = PointGeom::builder(); + cat.select(&out_i).apply(&mut b, band_ch); + b.set(value_ch, pick(&v1, &out_i)); + b.set(frac_ch, shift(&offsets, &out_i, 0.0)); + fill.apply(&mut b, "fill", &out_i); + stroke.apply(&mut b, "stroke", &out_i); + apply_material(&mut b, alpha.as_ref(), "fill_opacity", &out_i); + // `PointGeom` has no dash pattern — a marker outline can't be dashed. + outline(&mut b, linewidth.as_ref(), None, &out_i); + apply_material(&mut b, size.as_ref(), "size", &out_i); + apply_material(&mut b, shape.as_ref(), "shape", &out_i); + plot.add_geom(b.build()); + } + + Ok(()) +} + +/// Apply the layer's resolved outline width and dash pattern to one component's +/// rows. `linetype` is `None` for geoms with no dash channel. +fn outline( + b: &mut GeomBuilder, + linewidth: Option<&MaterialSource>, + linetype: Option<&MaterialSource>, + idx: &[usize], +) { + apply_material(b, linewidth, "linewidth", idx); + apply_material(b, linetype, "linetype", idx); +} + +/// Select rows by index. +fn pick(v: &[f64], idx: &[usize]) -> Vec { + idx.iter().map(|&i| v[i]).collect() +} + +/// Per-row band offsets for the selected rows, shifted by `delta` (e.g. the box +/// width's two edges, 0 for a centred line/point). +fn shift(offsets: &[f64], idx: &[usize], delta: f64) -> Vec { + idx.iter().map(|&i| offsets[i] + delta).collect() +} diff --git a/src/writer/hephaestus/geom/densified.rs b/src/writer/hephaestus/geom/densified.rs new file mode 100644 index 000000000..ec4181949 --- /dev/null +++ b/src/writer/hephaestus/geom/densified.rs @@ -0,0 +1,46 @@ +//! Layers ggsql expanded into per-vertex rows under a map `PROJECT`. +//! +//! Projecting a straight edge onto a curved surface bends it, so ggsql densifies +//! the edge in SQL (`plot/layer/geom/{segment,rule,ribbon,tile}.rs` +//! `apply_projection`): each original row becomes a run of vertex rows, the +//! extent aesthetics (`pos1end`/`pos2end`, `pos1min`/`pos2max`, …) are remapped +//! onto plain `pos1`/`pos2`, and `__ggsql_densify_id__` — appended to the +//! layer's `partition_by` — ties one row's vertices back together. The layer is +//! flagged with the `densified` parameter. +//! +//! The mark therefore changes: an open shape (`segment`, `rule`) draws as a +//! polyline and a closed one (`ribbon`, `tile`) as a filled outline. Those are +//! exactly the `line` and `polygon` geoms — same vertex columns, same grouping +//! by `partition_by`, same material tables — so their specs are reused whole. +//! The Vega-Lite writer makes the same swap, to a `line` mark with +//! `interpolate: linear-closed` for the closed shapes. + +use hephaestus::plot::{LineGeom, Plot as HPlot, PolygonGeom}; + +use super::super::wiring::{build_and_add, Ctx}; +use super::{line, polygon}; +use crate::plot::layer::geom::GeomType; +use crate::plot::ParameterValue; +use crate::{GgsqlError, Layer, Result}; + +/// Whether ggsql expanded this layer's rows into projected vertices. +pub fn applies(layer: &Layer) -> bool { + matches!( + layer.parameters.get("densified"), + Some(ParameterValue::Boolean(true)) + ) +} + +/// Draw the expanded vertices: a polyline per original row for segment/rule, a +/// closed polygon per original row for ribbon/tile. +pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { + match ctx.layer.geom.geom_type() { + GeomType::Segment | GeomType::Rule => build_and_add::(plot, line::spec(ctx), ctx), + GeomType::Ribbon | GeomType::Tile => { + build_and_add::(plot, polygon::spec(ctx), ctx) + } + other => Err(GgsqlError::WriterError(format!( + "png writer cannot draw a densified '{other}' geom" + ))), + } +} diff --git a/src/writer/hephaestus/geom/hinge.rs b/src/writer/hephaestus/geom/hinge.rs new file mode 100644 index 000000000..cb19e0596 --- /dev/null +++ b/src/writer/hephaestus/geom/hinge.rs @@ -0,0 +1,60 @@ +//! End caps — the `hinge` SETTING shared by `boxplot` (whisker caps, default +//! off) and `range` (interval caps, default 10pt). +//! +//! A cap is a short `SegmentGeom` drawn across the banded axis at an interval +//! endpoint. Its length is given in **points**, not in data or band units, so it +//! keeps a fixed size at any panel width — the raster analog of the Vega-Lite +//! writer's `tick` mark with a pixel `size`. Under `side != 'both'` the cap is +//! halved and drawn on the chosen side only, like the box it belongs to. + +use hephaestus::plot::geom::GeomBuilder; +use hephaestus::plot::SegmentGeom; + +use super::super::channels::ChannelData; +use super::super::wiring::{band_edges, side_sign, BandAxes, Ctx}; +use crate::plot::ParameterValue; +use crate::Layer; + +/// The `hinge` SETTING (cap width in points), or `None` when it is `null` (a +/// boxplot's default) or zero — both meaning "no caps". +pub fn hinge_points(layer: &Layer) -> Option { + match layer.parameters.get("hinge")? { + ParameterValue::Number(pts) if *pts > 0.0 => Some(*pts), + _ => None, + } +} + +/// A cap per row: a segment centred on the row's banded-axis position (`band`, +/// plus its per-row dodge `offsets`) at value-axis position `values`, spanning +/// `hinge` points across the band. +/// +/// Returns the builder with only its positions set, so the caller can style the +/// caps' stroke to match the mark they belong to (a composite's pre-resolved +/// material, or the generic material table) before adding it to the plot. +pub fn caps( + ctx: &Ctx, + axes: BandAxes, + band: ChannelData, + values: Vec, + offsets: Vec, + hinge: f64, +) -> GeomBuilder { + let (band_ch, band_ch2) = axes.band_channels(); + let (frac_ch, frac_ch2) = axes.band_fraction_channels(); + let (offset_ch, offset_ch2) = axes.band_offset_channels(); + let (value_ch, value_ch2) = axes.value_channels(); + // A cap spans `hinge` points across the band, halved and pushed to one side + // when `side` selects a half-band. + let (near, far) = band_edges(hinge / 2.0, side_sign(ctx.layer)); + + let mut b = SegmentGeom::builder(); + band.clone().apply(&mut b, band_ch); + band.apply(&mut b, band_ch2); + b.set(frac_ch, offsets.clone()); + b.set(frac_ch2, offsets); + b.set(offset_ch, near); + b.set(offset_ch2, far); + b.set(value_ch, values.clone()); + b.set(value_ch2, values); + b +} diff --git a/src/writer/hephaestus/geom/line.rs b/src/writer/hephaestus/geom/line.rs new file mode 100644 index 000000000..50d88e93c --- /dev/null +++ b/src/writer/hephaestus/geom/line.rs @@ -0,0 +1,59 @@ +//! `line`, `path`, and `smooth` geoms → hephaestus `LineGeom`. +//! +//! Rows are grouped into separate polylines by the layer's partition columns; +//! within a group hephaestus connects rows in source order (ggsql pre-orders +//! line by pos1, path keeps raw order, smooth emits the fitted curve). + +use hephaestus::color::rgb8; + +use super::super::scales::RangeKind; +use super::super::wiring::{ + Ctx, GeomSpec, LegendKind, MatDefault, MaterialSpec, PanelAxis, PositionSpec, +}; +use crate::plot::layer::geom::GeomType; + +pub fn spec(ctx: &Ctx) -> GeomSpec { + // ggsql defaults: line is black @ 1.5pt; smooth is blue (#3366FF) @ 2pt. + let (stroke, linewidth) = if ctx.layer.geom.geom_type() == GeomType::Smooth { + (rgb8(51, 102, 255), 2.0) + } else { + (rgb8(0, 0, 0), 1.5) + }; + GeomSpec { + positions: vec![ + PositionSpec::new("x", "pos1", PanelAxis::X), + PositionSpec::new("y", "pos2", PanelAxis::Y), + ], + material: vec![ + MaterialSpec::new( + "stroke", + "stroke", + RangeKind::Color, + MatDefault::Color(stroke), + ), + MaterialSpec::new( + "linewidth", + "linewidth", + RangeKind::Number, + MatDefault::Number(linewidth), + ), + MaterialSpec::new( + "opacity", + "stroke_opacity", + RangeKind::Number, + MatDefault::None, + ), + MaterialSpec::new( + "linetype", + "linetype", + RangeKind::Linetype, + MatDefault::None, + ), + ], + raw_strings: &[], + raw_numbers: vec![], + data_channels: vec![], + legend_key: LegendKind::Line, + grouped: true, + } +} diff --git a/src/writer/hephaestus/geom/mod.rs b/src/writer/hephaestus/geom/mod.rs new file mode 100644 index 000000000..ad5cdc677 --- /dev/null +++ b/src/writer/hephaestus/geom/mod.rs @@ -0,0 +1,91 @@ +//! ggsql geom → hephaestus geom dispatch. Each module declares its channel +//! specs; [`build_into_plot`] picks the concrete hephaestus geom and builds it +//! through the shared wiring. Composite geoms (boxplot, violin) and the geoms +//! needing unit conversion (text, spatial) supply their own builder instead. + +mod area; +mod boxplot; +mod densified; +mod hinge; +mod line; +mod point; +mod polygon; +mod rect; +mod segment; +mod spatial; +mod text; +mod violin; + +use hephaestus::plot::{ + LineGeom, Plot as HPlot, PointGeom, PolygonGeom, RectGeom, RibbonGeom, SegmentGeom, +}; + +use super::wiring::{build_and_add, Ctx}; +use crate::plot::layer::geom::GeomType; +use crate::{GgsqlError, Result}; + +/// Build the layer's geom into `plot`: set its channels, bind them to the scales +/// named in `ctx`, and record any legends on `ctx`. +pub fn build_into_plot(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { + // A layer ggsql expanded into projected vertices draws as a polyline or a + // polygon rather than as its usual mark, whatever the geom (see `densified`). + // Checked first, mirroring the Vega-Lite writer's renderers, so a densified + // rule takes this path rather than the diagonal-abline one. + if densified::applies(ctx.layer) { + return densified::build(plot, ctx); + } + match ctx.layer.geom.geom_type() { + GeomType::Point => build_and_add::(plot, point::spec(ctx), ctx), + GeomType::Line | GeomType::Path | GeomType::Smooth => { + build_and_add::(plot, line::spec(ctx), ctx) + } + GeomType::Bar | GeomType::Histogram | GeomType::Tile => { + build_and_add::(plot, rect::spec(ctx), ctx) + } + GeomType::Area | GeomType::Ribbon | GeomType::Density => { + build_and_add::(plot, area::spec(ctx), ctx) + } + GeomType::Polygon => build_and_add::(plot, polygon::spec(ctx), ctx), + GeomType::Rule if segment::is_diagonal(ctx.layer) => segment::build_diagonal(plot, ctx), + // A range's `hinge` caps are extra segments beside the interval itself. + GeomType::Range => { + build_and_add::(plot, segment::spec(ctx), ctx)?; + segment::build_hinges(plot, ctx) + } + GeomType::Segment | GeomType::Rule => { + build_and_add::(plot, segment::spec(ctx), ctx) + } + GeomType::Text => text::build(plot, ctx), + GeomType::Spatial => spatial::build(plot, ctx), + GeomType::Boxplot => boxplot::build(plot, ctx), + GeomType::Violin => violin::build(plot, ctx), + other => Err(GgsqlError::WriterError(format!( + "png writer does not support the '{other}' geom yet" + ))), + } +} + +/// Geoms this writer can render (used by `validate`). +pub fn is_supported(geom: GeomType) -> bool { + matches!( + geom, + GeomType::Point + | GeomType::Line + | GeomType::Path + | GeomType::Smooth + | GeomType::Bar + | GeomType::Histogram + | GeomType::Tile + | GeomType::Area + | GeomType::Ribbon + | GeomType::Density + | GeomType::Polygon + | GeomType::Segment + | GeomType::Range + | GeomType::Rule + | GeomType::Text + | GeomType::Spatial + | GeomType::Boxplot + | GeomType::Violin + ) +} diff --git a/src/writer/hephaestus/geom/point.rs b/src/writer/hephaestus/geom/point.rs new file mode 100644 index 000000000..e85e2e208 --- /dev/null +++ b/src/writer/hephaestus/geom/point.rs @@ -0,0 +1,45 @@ +//! `point` geom: one marker per row. + +use hephaestus::color::rgb8; + +use super::super::scales::RangeKind; +use super::super::wiring::{ + Ctx, GeomSpec, LegendKind, MatDefault, MaterialSpec, PanelAxis, PositionSpec, +}; + +pub fn spec(_ctx: &Ctx) -> GeomSpec { + GeomSpec { + positions: vec![ + PositionSpec::new("x", "pos1", PanelAxis::X), + PositionSpec::new("y", "pos2", PanelAxis::Y), + ], + material: vec![ + MaterialSpec::new( + "fill", + "fill", + RangeKind::Color, + MatDefault::Color(rgb8(0, 0, 0)), + ), + MaterialSpec::new("stroke", "stroke", RangeKind::Color, MatDefault::None), + MaterialSpec::new("size", "size", RangeKind::Number, MatDefault::Number(3.0)), + MaterialSpec::new( + "opacity", + "fill_opacity", + RangeKind::Number, + MatDefault::Number(0.8), + ), + MaterialSpec::new( + "linewidth", + "linewidth", + RangeKind::Number, + MatDefault::None, + ), + MaterialSpec::new("shape", "shape", RangeKind::Shape, MatDefault::None), + ], + raw_strings: &[], + raw_numbers: vec![], + data_channels: vec![], + legend_key: LegendKind::Point, + grouped: false, + } +} diff --git a/src/writer/hephaestus/geom/polygon.rs b/src/writer/hephaestus/geom/polygon.rs new file mode 100644 index 000000000..412841c54 --- /dev/null +++ b/src/writer/hephaestus/geom/polygon.rs @@ -0,0 +1,50 @@ +//! `polygon` geom → hephaestus `PolygonGeom`. Rows are grouped into separate +//! closed polygons by the layer's partition columns. + +use hephaestus::color::rgb8; + +use super::super::scales::RangeKind; +use super::super::wiring::{ + Ctx, GeomSpec, LegendKind, MatDefault, MaterialSpec, PanelAxis, PositionSpec, +}; + +pub fn spec(_ctx: &Ctx) -> GeomSpec { + GeomSpec { + positions: vec![ + PositionSpec::new("x", "pos1", PanelAxis::X), + PositionSpec::new("y", "pos2", PanelAxis::Y), + ], + material: vec![ + MaterialSpec::new( + "fill", + "fill", + RangeKind::Color, + MatDefault::Color(rgb8(0, 0, 0)), + ), + MaterialSpec::new("stroke", "stroke", RangeKind::Color, MatDefault::None), + MaterialSpec::new( + "opacity", + "fill_opacity", + RangeKind::Number, + MatDefault::Number(0.8), + ), + MaterialSpec::new( + "linewidth", + "linewidth", + RangeKind::Number, + MatDefault::None, + ), + MaterialSpec::new( + "linetype", + "linetype", + RangeKind::Linetype, + MatDefault::None, + ), + ], + raw_strings: &[], + raw_numbers: vec![], + data_channels: vec![], + legend_key: LegendKind::Rect, + grouped: true, + } +} diff --git a/src/writer/hephaestus/geom/rect.rs b/src/writer/hephaestus/geom/rect.rs new file mode 100644 index 000000000..937903a8a --- /dev/null +++ b/src/writer/hephaestus/geom/rect.rs @@ -0,0 +1,191 @@ +//! `bar`, `histogram`, and `tile` geoms → hephaestus `RectGeom`. +//! +//! Bars occupy a `width`-fraction of their category band, offset per dodge +//! group (both come from ggsql: the `width` param and the `pos1offset`/ +//! `pos2offset` columns + `Layer::adjusted_width`); the value axis runs +//! baseline→value. Histograms span explicit bin edges; tiles span min/max +//! extents (continuous) or fill the band (discrete). Bars/histograms are +//! orientation-aware. + +use hephaestus::color::rgb8; + +use super::super::channels::{aesthetic_column_name, column_to_f64}; +use super::super::scales::RangeKind; +use super::super::wiring::{ + band_half_width, dodge_offsets, Ctx, GeomSpec, LegendKind, MatDefault, MaterialSpec, PanelAxis, + PositionSpec, +}; +use crate::plot::layer::geom::GeomType; + +pub fn spec(ctx: &Ctx) -> GeomSpec { + let (positions, raw_numbers, data_channels) = match ctx.layer.geom.geom_type() { + GeomType::Bar => { + let (positions, bands) = bar(ctx); + (positions, vec![], bands) + } + GeomType::Histogram => (histogram(ctx.transposed), vec![], vec![]), + GeomType::Tile => { + let (positions, bands) = tile(ctx); + (positions, vec![], bands) + } + _ => (Vec::new(), vec![], vec![]), + }; + + GeomSpec { + positions, + material: vec![ + MaterialSpec::new( + "fill", + "fill", + RangeKind::Color, + MatDefault::Color(rgb8(0, 0, 0)), + ), + MaterialSpec::new("stroke", "stroke", RangeKind::Color, MatDefault::None), + MaterialSpec::new( + "opacity", + "fill_opacity", + RangeKind::Number, + MatDefault::Number(0.8), + ), + MaterialSpec::new( + "linewidth", + "linewidth", + RangeKind::Number, + MatDefault::None, + ), + MaterialSpec::new( + "linetype", + "linetype", + RangeKind::Linetype, + MatDefault::None, + ), + ], + raw_strings: &[], + raw_numbers, + data_channels, + legend_key: LegendKind::Rect, + grouped: false, + } +} + +/// Categorical bar: x/x2 share the category column; the band edges come from +/// `width`/dodge as per-row band offsets. The value axis runs baseline→value. +fn bar(ctx: &Ctx) -> (Vec, Vec<(&'static str, Vec)>) { + // A synthetic band — the `__ggsql_stat_dummy` a bar with no category mapping + // sits on — has no neighbours to leave a gap for, so it takes the whole band + // rather than the layer's `width`. Under polar that band axis is the radius, + // where a 0.9 width would open a hole in the middle of a pie and leave a gap + // at its rim; on a Cartesian axis it is the single full-width bar ggplot2 + // draws for an ungrouped count. + let band_axis = if ctx.transposed { "pos2" } else { "pos1" }; + let dummy = ctx + .spec + .find_scale(band_axis) + .is_some_and(|scale| scale.is_dummy()); + let half = if dummy { + 0.5 + } else { + band_half_width(ctx.layer, 0.9) + }; + if !ctx.transposed { + let offsets = dodge_offsets(ctx.df, "pos1offset"); + let lo = offsets.iter().map(|o| o - half).collect(); + let hi = offsets.iter().map(|o| o + half).collect(); + ( + vec![ + PositionSpec::new("x", "pos1", PanelAxis::X), + PositionSpec::new("x2", "pos1", PanelAxis::X), + PositionSpec::new("y", "pos2end", PanelAxis::Y), + PositionSpec::new("y2", "pos2", PanelAxis::Y), + ], + vec![("x_band", lo), ("x2_band", hi)], + ) + } else { + let offsets = dodge_offsets(ctx.df, "pos2offset"); + let lo = offsets.iter().map(|o| o - half).collect(); + let hi = offsets.iter().map(|o| o + half).collect(); + ( + vec![ + PositionSpec::new("y", "pos2", PanelAxis::Y), + PositionSpec::new("y2", "pos2", PanelAxis::Y), + PositionSpec::new("x", "pos1end", PanelAxis::X), + PositionSpec::new("x2", "pos1", PanelAxis::X), + ], + vec![("y_band", lo), ("y2_band", hi)], + ) + } +} + +/// Histogram: bins span explicit edges on the main axis, value runs baseline→count. +fn histogram(transposed: bool) -> Vec { + if !transposed { + vec![ + PositionSpec::new("x", "pos1", PanelAxis::X), + PositionSpec::new("x2", "pos1end", PanelAxis::X), + PositionSpec::new("y", "pos2end", PanelAxis::Y), + PositionSpec::new("y2", "pos2", PanelAxis::Y), + ] + } else { + vec![ + PositionSpec::new("y", "pos2", PanelAxis::Y), + PositionSpec::new("y2", "pos2end", PanelAxis::Y), + PositionSpec::new("x", "pos1end", PanelAxis::X), + PositionSpec::new("x2", "pos1", PanelAxis::X), + ] + } +} + +/// Tile/heatmap. Each direction is parameterised on its own, because ggsql's +/// tile stat resolves them independently (`tile::process_direction`) and a tile +/// may be discrete on one axis and continuous on the other — which the +/// Vega-Lite writer's `TileRenderer` also handles per axis. +fn tile(ctx: &Ctx) -> (Vec, Vec<(&'static str, Vec)>) { + let (mut positions, mut bands) = tile_axis(ctx, PanelAxis::X); + let (y_positions, y_bands) = tile_axis(ctx, PanelAxis::Y); + positions.extend(y_positions); + bands.extend(y_bands); + (positions, bands) +} + +/// One tile direction: a continuous one spans the explicit min/max extents ggsql +/// resolved, a discrete one sits on the category centre and occupies a +/// `width`/`height` fraction of its band (1.0 = full band, like VL's +/// `datum.width * bandwidth`), so its edges are at ±fraction/2. +fn tile_axis(ctx: &Ctx, axis: PanelAxis) -> (Vec, Vec<(&'static str, Vec)>) { + let (centre, min, max, size, near, far, near_band, far_band) = match axis { + PanelAxis::X => ( + "pos1", "pos1min", "pos1max", "width", "x", "x2", "x_band", "x2_band", + ), + PanelAxis::Y => ( + "pos2", "pos2min", "pos2max", "height", "y", "y2", "y_band", "y2_band", + ), + }; + if aesthetic_column_name(ctx.layer, min).is_some() { + ( + vec![ + PositionSpec::new(near, min, axis), + PositionSpec::new(far, max, axis), + ], + vec![], + ) + } else { + let (lo, hi) = band_edges(ctx, size); + ( + vec![ + PositionSpec::new(near, centre, axis), + PositionSpec::new(far, centre, axis), + ], + vec![(near_band, lo), (far_band, hi)], + ) + } +} + +/// Per-row band edges (`-fraction/2`, `+fraction/2`) for a discrete tile's +/// `width`/`height` column; a missing column defaults to a full (1.0) band. +fn band_edges(ctx: &Ctx, aesthetic: &str) -> (Vec, Vec) { + let name = crate::naming::aesthetic_column(aesthetic); + let fracs = column_to_f64(ctx.df, &name).unwrap_or_else(|_| vec![1.0; ctx.df.height()]); + let lo = fracs.iter().map(|f| -f / 2.0).collect(); + let hi = fracs.iter().map(|f| f / 2.0).collect(); + (lo, hi) +} diff --git a/src/writer/hephaestus/geom/segment.rs b/src/writer/hephaestus/geom/segment.rs new file mode 100644 index 000000000..4dc7243a1 --- /dev/null +++ b/src/writer/hephaestus/geom/segment.rs @@ -0,0 +1,269 @@ +//! `segment`, `range`, and `rule` geoms → hephaestus `SegmentGeom`. +//! +//! - segment: an explicit (pos1,pos2)→(pos1end,pos2end) line. +//! - range: a bar-less interval; aligned spans pos2min→pos2max at fixed pos1 +//! (transposed swaps). +//! - rule: a panel-spanning reference line at a fixed pos1 (vertical) or pos2 +//! (horizontal); the free axis uses scale-bypassing 0..1 panel fractions. + +use hephaestus::color::rgb8; +use hephaestus::plot::{Plot as HPlot, SegmentGeom}; + +use super::super::channels::{aesthetic_column_name, column_to_channel, column_to_f64}; +use super::super::scales::RangeKind; +use super::super::wiring::{ + constant_number, dodge_offsets, wire_material, BandAxes, Ctx, GeomSpec, LegendKind, MatDefault, + MaterialSpec, PanelAxis, PositionSpec, +}; +use super::hinge::{caps, hinge_points}; +use crate::plot::layer::geom::GeomType; +use crate::plot::ParameterValue; +use crate::{Layer, Result}; + +pub fn spec(ctx: &Ctx) -> GeomSpec { + let (positions, raw_numbers) = match ctx.layer.geom.geom_type() { + GeomType::Segment => ( + vec![ + PositionSpec::new("x", "pos1", PanelAxis::X), + PositionSpec::new("y", "pos2", PanelAxis::Y), + PositionSpec::new("x2", "pos1end", PanelAxis::X), + PositionSpec::new("y2", "pos2end", PanelAxis::Y), + ], + vec![], + ), + GeomType::Range if !ctx.transposed => ( + vec![ + PositionSpec::new("x", "pos1", PanelAxis::X), + PositionSpec::new("x2", "pos1", PanelAxis::X), + PositionSpec::new("y", "pos2min", PanelAxis::Y), + PositionSpec::new("y2", "pos2max", PanelAxis::Y), + ], + vec![], + ), + GeomType::Range => ( + vec![ + PositionSpec::new("y", "pos2", PanelAxis::Y), + PositionSpec::new("y2", "pos2", PanelAxis::Y), + PositionSpec::new("x", "pos1min", PanelAxis::X), + PositionSpec::new("x2", "pos1max", PanelAxis::X), + ], + vec![], + ), + GeomType::Rule => rule(ctx), + _ => (Vec::new(), vec![]), + }; + + GeomSpec { + positions, + material: material(), + raw_strings: &[], + raw_numbers, + data_channels: vec![], + legend_key: LegendKind::Line, + grouped: false, + } +} + +/// The stroke material table shared by every segment-family geom, including the +/// diagonal rule (which builds its positions itself but styles them the same). +fn material() -> Vec { + vec![ + MaterialSpec::new( + "stroke", + "stroke", + RangeKind::Color, + MatDefault::Color(rgb8(0, 0, 0)), + ), + MaterialSpec::new( + "linewidth", + "linewidth", + RangeKind::Number, + MatDefault::Number(1.0), + ), + MaterialSpec::new( + "opacity", + "stroke_opacity", + RangeKind::Number, + MatDefault::None, + ), + MaterialSpec::new( + "linetype", + "linetype", + RangeKind::Linetype, + MatDefault::None, + ), + ] +} + +/// `range` end caps: the `hinge` SETTING (10pt by default, `null` to hide) draws +/// a cap across the band at **both** interval endpoints, on top of the interval's +/// own segment. Mirrors the Vega-Lite writer, which adds two `tick` layers of +/// `hinge` px beside the rule. +/// +/// The caps take the same material table as the segment, so a data-mapped +/// stroke/width/dash styles them like the interval (its legend collapses into the +/// segment's, being the same scale). +pub fn build_hinges(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { + let Some(hinge) = hinge_points(ctx.layer) else { + return Ok(()); + }; + if ctx.df.height() == 0 { + return Ok(()); + } + let axes = BandAxes::new(ctx); + // A range with no mapped position on the banded axis (ggsql's dummy axis) has + // nothing to centre the caps on. + let Some(band_col) = aesthetic_column_name(ctx.layer, axes.band()) else { + return Ok(()); + }; + let band = column_to_channel(ctx.df, band_col)?; + // Follow the interval's own position adjustment, which `wire_positions` put on + // its band channels. + let offsets = dodge_offsets(ctx.df, axes.dodge()); + + for bound in ["min", "max"] { + let aesthetic = format!("{}{bound}", axes.value()); + let Some(col) = aesthetic_column_name(ctx.layer, &aesthetic) else { + continue; + }; + let values = column_to_f64(ctx.df, col)?; + let mut b = caps(ctx, axes, band.clone(), values, offsets.clone(), hinge); + wire_material(&mut b, &material(), plot, ctx, LegendKind::Line)?; + plot.add_geom(b.build()); + } + Ok(()) +} + +/// Whether this rule is a diagonal (abline): has a non-zero `slope`. +pub fn is_diagonal(layer: &Layer) -> bool { + matches!( + layer.parameters.get("diagonal"), + Some(ParameterValue::Boolean(true)) + ) +} + +/// A diagonal rule (abline): **one line per data row**, each spanning the +/// position scales' resolved range with `secondary = slope * primary + +/// intercept`. Mirrors the Vega-Lite writer, whose `calculate` transforms compute +/// that expression per row from `datum.__ggsql_aes_slope__` and the intercept +/// field — so `MAPPING slope AS slope, y AS y` draws a line per row (with its own +/// slope, intercept, and material aesthetics), while `SETTING slope => 1, y => 0` +/// gives one row of literals and hence one line. +/// +/// The spanning range comes straight from the scales (explicit `FROM` or +/// data-trained); when a scale is unresolved it falls back to 0..1 like any +/// continuous scale. Positions are computed rather than read from a column, so +/// this builds its own geom, but materials go through the shared `wire_material` +/// so a data-mapped `stroke`/`linetype`/`linewidth` is scaled and legended +/// exactly as on a plain segment. +pub fn build_diagonal(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { + let n = ctx.df.height(); + if n == 0 { + return Ok(()); + } + let slopes = slope_values(ctx, n)?; + + let (x, x2, y, y2) = if !ctx.transposed { + // y-intercept (`pos2`); x is the spanning axis. + let intercepts = intercept_values(ctx, "pos2", n)?; + let (x0, x1) = primary_range(ctx, "pos1"); + ( + vec![x0; n], + vec![x1; n], + secondary(&slopes, &intercepts, x0), + secondary(&slopes, &intercepts, x1), + ) + } else { + // x-intercept (`pos1`); y is the spanning axis. + let intercepts = intercept_values(ctx, "pos1", n)?; + let (y0, y1) = primary_range(ctx, "pos2"); + ( + secondary(&slopes, &intercepts, y0), + secondary(&slopes, &intercepts, y1), + vec![y0; n], + vec![y1; n], + ) + }; + + for (channel, scale) in [ + ("x", ctx.pos1_scale), + ("x2", ctx.pos1_scale), + ("y", ctx.pos2_scale), + ("y2", ctx.pos2_scale), + ] { + plot.set_binding(channel, scale); + } + + let mut b = SegmentGeom::builder(); + b.set("x", x); + b.set("x2", x2); + b.set("y", y); + b.set("y2", y2); + wire_material(&mut b, &material(), plot, ctx, LegendKind::Line)?; + plot.add_geom(b.build()); + Ok(()) +} + +/// `slope * primary + intercept` at one end of the spanning range. +fn secondary(slopes: &[f64], intercepts: &[f64], primary: f64) -> Vec { + slopes + .iter() + .zip(intercepts) + .map(|(s, i)| s * primary + i) + .collect() +} + +/// Resolved (min, max) for a position scale, or 0..1 when unresolved. +fn primary_range(ctx: &Ctx, aesthetic: &str) -> (f64, f64) { + ctx.spec + .find_scale(aesthetic) + .and_then(|s| s.numeric_domain()) + .unwrap_or((0.0, 1.0)) +} + +/// Per-row slopes: the mapped `slope` column, else the literal or SETTING +/// parameter repeated for every row. +fn slope_values(ctx: &Ctx, n: usize) -> Result> { + if let Some(col) = aesthetic_column_name(ctx.layer, "slope") { + return column_to_f64(ctx.df, col); + } + let param = match ctx.layer.parameters.get("slope") { + Some(ParameterValue::Number(v)) => *v, + _ => 0.0, + }; + Ok(vec![constant_number(ctx, "slope", param); n]) +} + +/// Per-row intercepts from the position aesthetic holding them (`pos2` for a +/// y-intercept, `pos1` when transposed): its column, else the literal value. +fn intercept_values(ctx: &Ctx, aesthetic: &str, n: usize) -> Result> { + if let Some(col) = aesthetic_column_name(ctx.layer, aesthetic) { + return column_to_f64(ctx.df, col); + } + Ok(vec![constant_number(ctx, aesthetic, 0.0); n]) +} + +/// A non-diagonal rule is a reference line spanning the whole panel on its free +/// axis. The free axis uses raw 0..1 panel fractions, so no scale/axis is +/// created for it. +fn rule(ctx: &Ctx) -> (Vec, Vec<(&'static str, f64)>) { + if aesthetic_column_name(ctx.layer, "pos1").is_some() { + // Vertical line at x = pos1, spanning full height. + ( + vec![ + PositionSpec::new("x", "pos1", PanelAxis::X), + PositionSpec::new("x2", "pos1", PanelAxis::X), + ], + vec![("y", 0.0), ("y2", 1.0)], + ) + } else { + // Horizontal line at y = pos2, spanning full width. + ( + vec![ + PositionSpec::new("y", "pos2", PanelAxis::Y), + PositionSpec::new("y2", "pos2", PanelAxis::Y), + ], + vec![("x", 0.0), ("x2", 1.0)], + ) + } +} diff --git a/src/writer/hephaestus/geom/spatial.rs b/src/writer/hephaestus/geom/spatial.rs new file mode 100644 index 000000000..19b7aa8ca --- /dev/null +++ b/src/writer/hephaestus/geom/spatial.rs @@ -0,0 +1,74 @@ +//! `spatial` geom → hephaestus `GeometryGeom`. A custom builder (not the generic +//! position/material path) because the geom carries no `x`/`y` columns: each row +//! is a single `Geometry` value whose coordinates resolve through the plot's +//! bound `x`/`y` scales at draw time. Under a `PROJECT map` the plot's projection +//! (a Custom clip surface built from `projection.rs`) shapes those coordinates; +//! with no `PROJECT` the geometry draws in raw data space under Cartesian. + +use hephaestus::color::rgb8; +use hephaestus::plot::{GeometryGeom, Plot as HPlot}; + +use super::super::channels::column_to_geometry; +use super::super::scales::RangeKind; +use super::super::wiring::{wire_material, Ctx, LegendKind, MatDefault, MaterialSpec}; +use crate::naming; +use crate::Result; + +pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { + let df = ctx.df; + + // The geometry aesthetic is always materialised to the internal WKB column. + let geoms = column_to_geometry(df, &naming::aesthetic_column("geometry"))?; + + let mut b = GeometryGeom::builder(); + b.set("geometry", geoms); + + // Coordinates map through the panel's pos1/pos2 scales (bbox-framed; see + // `PngWriter::write`). GeometryGeom has no x/y channel, but its draw + // resolves each coordinate against these bound scales. + plot.set_binding("x", ctx.pos1_scale); + plot.set_binding("y", ctx.pos2_scale); + + // Every material aesthetic goes through the shared path, so each is honored + // whether it's the ggsql literal default, a `SETTING` constant, or + // data-mapped (scale-bound + legended) — a choropleth is just a data-mapped + // `fill`. ggsql's spatial defaults (grey fill, black border, opacity 0.8, + // linewidth 0.2, solid) arrive as literals; the `MatDefault`s match them so + // a legend key still carries the layer's look when nothing is mapped. + let material = [ + MaterialSpec::new( + "fill", + "fill", + RangeKind::Color, + MatDefault::Color(rgb8(0x74, 0x74, 0x74)), + ), + MaterialSpec::new( + "stroke", + "stroke", + RangeKind::Color, + MatDefault::Color(rgb8(0, 0, 0)), + ), + MaterialSpec::new( + "opacity", + "fill_opacity", + RangeKind::Number, + MatDefault::Number(0.8), + ), + MaterialSpec::new( + "linewidth", + "linewidth", + RangeKind::Number, + MatDefault::Number(0.2), + ), + MaterialSpec::new( + "linetype", + "linetype", + RangeKind::Linetype, + MatDefault::None, + ), + ]; + wire_material(&mut b, &material, plot, ctx, LegendKind::Rect)?; + + plot.add_geom(b.build()); + Ok(()) +} diff --git a/src/writer/hephaestus/geom/text.rs b/src/writer/hephaestus/geom/text.rs new file mode 100644 index 000000000..acb61aa07 --- /dev/null +++ b/src/writer/hephaestus/geom/text.rs @@ -0,0 +1,194 @@ +//! `text` geom → hephaestus `TextGeom`. A custom builder (not the generic +//! position/material path) because `vjust`/`hjust` accept keywords and flip for +//! hephaestus's top-origin `anchor_y`, and because `offset` is a layer parameter +//! rather than an aesthetic. Everything else goes through `wire_material`, so a +//! scaled `fontsize` maps through its resolved scale like any other geom's — and +//! the font face a layer holds constant reaches its legend key. + +use hephaestus::color::rgb8; +use hephaestus::plot::geom::Raw; +use hephaestus::plot::{Plot as HPlot, TextGeom}; + +use super::super::channels::{ + aesthetic_column_name, column_to_channel, column_to_f64, column_to_strings, is_text_column, +}; +use super::super::scales::RangeKind; +use super::super::wiring::{ + constant_number, require_column, wire_material, Ctx, LegendKind, MatDefault, MaterialSpec, +}; +use crate::plot::types::{ArrayElement, ParameterValue}; +use crate::plot::AestheticValue; +use crate::Result; + +pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { + let (layer, df) = (ctx.layer, ctx.df); + let n = df.height(); + + let pos1 = require_column(ctx, "pos1")?; + let pos2 = require_column(ctx, "pos2")?; + let label = require_column(ctx, "label")?; + + let mut b = TextGeom::builder(); + + // Positions: bind to the panel's pos1/pos2 scales (panel-aware for free). + let p1 = column_to_channel(df, pos1)?; + let p2 = column_to_channel(df, pos2)?; + plot.set_binding("x", ctx.pos1_scale); + plot.set_binding("y", ctx.pos2_scale); + p1.apply(&mut b, "x"); + p2.apply(&mut b, "y"); + + // Label string. + b.set("text", Raw(column_to_strings(df, label)?)); + + // `parse` decides whether each label is read as markdown (hephaestus's + // `markdown` channel, which routes the row through the rich-text shaper) or + // as a literal string. ggsql defaults it on, so the channel is always bound + // rather than left to hephaestus's own theme default of off. + b.set("markdown", Raw(vec![parse(layer); n])); + + // Color, glyph outline, size, opacity and the font face: the shared material + // path, so each is honored whether it arrives as a `SETTING` literal, a + // scaled column (`SCALE fontsize TO (6, 20)` maps through its resolved scale) + // or an identity column — and so a data-mapped one dresses its legend key in + // the constants the layer holds. `text_stroke` and `family` have no default + // because ggsql's defaults for `stroke` and `typeface` are Null: hephaestus + // skips the outline pass entirely while the channel is unset, and an empty + // family is not "use the default" but a font lookup that misses. The glyph + // outline's width is hephaestus's theme default, as ggsql's text geom has no + // `linewidth` aesthetic. + wire_material(&mut b, &material(), plot, ctx, LegendKind::Text)?; + + // Justification needs conversion no `RangeKind` covers, so it is resolved per + // row here: a mapped column, else the layer's literal repeated, else centred. + b.set("anchor_x", Raw(justification(ctx, "hjust"))); + // ggsql vjust: 0 = bottom, 1 = top; hephaestus anchor_y: 0 = top, 1 = bottom. + let anchor_y: Vec = justification(ctx, "vjust") + .iter() + .map(|v| 1.0 - v) + .collect(); + b.set("anchor_y", Raw(anchor_y)); + + // `offset` nudges the label off its anchor point, in points. It is a layer + // parameter rather than an aesthetic, so it bypasses the material table + // entirely. hephaestus's offsets are already in points and its y grows up, + // so both components pass through unchanged — unlike the Vega-Lite writer, + // which converts to pixels and negates y for VL's downward axis. + let (dx, dy) = offset(layer); + if dx != 0.0 || dy != 0.0 { + b.set("x_offset", Raw(vec![dx; n])); + b.set("y_offset", Raw(vec![dy; n])); + } + + plot.add_geom(b.build()); + Ok(()) +} + +/// The layer aesthetics wired through the shared material path, with ggsql's +/// text defaults. This table is also what dresses the legend key, so every +/// aesthetic hephaestus's `Text` key consumes belongs here — the face a layer +/// sets is as much part of what a `fontsize` swatch describes as its colour is. +/// Only justification is left out: the key centres its glyph in the cell. +fn material() -> [MaterialSpec; 8] { + [ + MaterialSpec::new( + "fill", + "fill", + RangeKind::Color, + MatDefault::Color(rgb8(0, 0, 0)), + ), + MaterialSpec::new("stroke", "text_stroke", RangeKind::Color, MatDefault::None), + MaterialSpec::new( + "fontsize", + "size", + RangeKind::Number, + MatDefault::Number(11.0), + ), + MaterialSpec::new( + "opacity", + "fill_opacity", + RangeKind::Number, + MatDefault::Number(1.0), + ), + MaterialSpec::new("typeface", "family", RangeKind::Text, MatDefault::None), + MaterialSpec::new( + "fontweight", + "weight", + RangeKind::FontWeight, + MatDefault::Number(400.0), + ), + MaterialSpec::new("italic", "italic", RangeKind::Bool, MatDefault::None), + // ggsql resolves `rotation` in degrees; hephaestus angles are radians + // (math CCW), which `RangeKind::Angle` converts. A rotated layer gets a + // rotated key, as ggplot2's `draw_key_text` does — hephaestus sizes the + // swatch cell from the rotated glyph, so nothing is clipped. + MaterialSpec::new("rotation", "angle", RangeKind::Angle, MatDefault::None), + ] +} + +/// The layer's `offset` parameter as `(dx, dy)` in points. A bare number offsets +/// both axes; a two-element array gives them separately. +fn offset(layer: &crate::Layer) -> (f64, f64) { + match layer.parameters.get("offset") { + Some(ParameterValue::Number(n)) => (*n, *n), + Some(ParameterValue::Array(a)) if a.len() == 2 => { + let at = |i: usize| match a[i] { + ArrayElement::Number(n) => n, + _ => 0.0, + }; + (at(0), at(1)) + } + _ => (0.0, 0.0), + } +} + +/// The layer's `parse` parameter: whether a label is markdown. Defaults to +/// `true`, matching the geom's own default — a `PLACE` layer or a query built +/// without going through parameter resolution leaves it unset. +fn parse(layer: &crate::Layer) -> bool { + match layer.parameters.get("parse") { + Some(ParameterValue::Boolean(b)) => *b, + _ => true, + } +} + +/// A justification aesthetic (`hjust` / `vjust`) as a 0–1 fraction. ggsql accepts +/// either a number or a keyword, so the keywords are mapped the way the +/// Vega-Lite writer's `convert_hjust` / `convert_vjust` map them to `align` / +/// `baseline`, and anything unrecognised centres. +fn justification(ctx: &Ctx, aesthetic: &str) -> Vec { + let n = ctx.df.height(); + if let Some(col) = aesthetic_column_name(ctx.layer, aesthetic) { + // Dispatch on the column's own type — a keyword column casts to numbers + // without erroring, so a numeric read cannot be tried first (see + // `is_text_column`). + if is_text_column(ctx.df, col) { + if let Ok(names) = column_to_strings(ctx.df, col) { + return names.iter().map(|s| parse_justification(s)).collect(); + } + } else if let Ok(values) = column_to_f64(ctx.df, col) { + return values; + } + return vec![0.5; n]; + } + // `SETTING vjust => 'top'` is a string literal, which `constant_number` + // cannot read; try it as a number first, then as a keyword. + let value = match ctx.layer.mappings.aesthetics.get(aesthetic) { + Some(AestheticValue::Literal(ParameterValue::String(s))) => parse_justification(s), + _ => constant_number(ctx, aesthetic, 0.5), + }; + vec![value; n] +} + +/// A justification keyword (or numeric string) as a 0–1 fraction; 0 is +/// left/bottom, 1 is right/top. +fn parse_justification(value: &str) -> f64 { + if let Ok(n) = value.parse::() { + return n; + } + match value.to_lowercase().as_str() { + "left" | "bottom" => 0.0, + "right" | "top" => 1.0, + _ => 0.5, // centre / center / middle / unknown + } +} diff --git a/src/writer/hephaestus/geom/violin.rs b/src/writer/hephaestus/geom/violin.rs new file mode 100644 index 000000000..fec019226 --- /dev/null +++ b/src/writer/hephaestus/geom/violin.rs @@ -0,0 +1,258 @@ +//! `violin` composite geom. ggsql's stat emits a KDE grid per group +//! (`pos1` = category, `pos2` = value, `offset` = pre-scaled half-width). We +//! render one `RibbonGeom` band per (category, partition group): one edge sits at +//! `+offset` and the other at `-offset` of the category band (via the ribbon's +//! per-row band-offset channels), sharing the value channel. One ribbon row per +//! KDE grid sample, so the contour needs no hand-built outline. +//! +//! Which axis carries the categories follows the layer's orientation +//! (`BandAxes`); `side` collapses the band to one half, leaving the other edge on +//! the centreline (so a half-violin can pair with a half-boxplot). + +use std::cmp::Ordering; +use std::collections::HashMap; + +use hephaestus::color::rgb8; +use hephaestus::plot::{Plot as HPlot, RibbonGeom}; + +use super::super::channels::{ + build_group_keys, column_to_channel, column_to_f64, column_to_strings, +}; +use super::super::scales::RangeKind; +use super::super::wiring::{ + apply_material, band_edges, dodge_offsets, require_column, resolve_color, resolve_material, + side_sign, BandAxes, Ctx, LegendKind, MatDefault, MaterialSpec, +}; +use crate::Result; + +/// The layer aesthetics this composite styles, with ggsql's violin defaults. +/// Used both to resolve them and to dress the legend keys in the layer's look. +fn material() -> [MaterialSpec; 5] { + [ + MaterialSpec::new( + "fill", + "fill", + RangeKind::Color, + MatDefault::Color(rgb8(255, 255, 255)), + ), + MaterialSpec::new( + "stroke", + "stroke", + RangeKind::Color, + MatDefault::Color(rgb8(60, 60, 60)), + ), + MaterialSpec::new( + "linewidth", + "linewidth", + RangeKind::Number, + MatDefault::None, + ), + MaterialSpec::new( + "linetype", + "linetype", + RangeKind::Linetype, + MatDefault::None, + ), + MaterialSpec::new( + "opacity", + "fill_opacity", + RangeKind::Number, + MatDefault::None, + ), + ] +} + +pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { + let (layer, df) = (ctx.layer, ctx.df); + + let axes = BandAxes::new(ctx); + let band_aes = axes.band(); + let value_aes = axes.value(); + + let band_col = require_column(ctx, band_aes)?; + let value_col = require_column(ctx, value_aes)?; + let offset = require_column(ctx, "offset")?; + + let p1 = column_to_channel(df, band_col)?; + let cat = column_to_strings(df, band_col)?; + let p2 = column_to_f64(df, value_col)?; + let off = column_to_f64(df, offset)?; + + // One contour per (category, partition group): the category alone would merge + // a dodged violin's groups into a single blob, since ggsql keeps position + // aesthetics out of `partition_by`. The Vega-Lite writer composes its `detail` + // encoding the same way. + let partitions = build_group_keys(df, &layer.partition_by)?; + let keys: Vec = match &partitions { + Some(parts) => cat + .iter() + .zip(parts) + .map(|(c, p)| format!("{c}\u{1f}{p}")) + .collect(), + None => cat.clone(), + }; + + // Order rows so each violin's band is contiguous and ascending in the value + // axis (RibbonGeom connects a mark's rows in source order). + let mut groups: Vec> = Vec::new(); + let mut index: HashMap<&str, usize> = HashMap::new(); + for (i, k) in keys.iter().enumerate() { + let g = *index.entry(k.as_str()).or_insert_with(|| { + groups.push(Vec::new()); + groups.len() - 1 + }); + groups[g].push(i); + } + let mut order: Vec = Vec::with_capacity(cat.len()); + for rows in &mut groups { + rows.sort_by(|&a, &b| p2[a].partial_cmp(&p2[b]).unwrap_or(Ordering::Equal)); + order.extend_from_slice(rows); + } + + // A channel always drives the same panel axis, whatever the orientation. + for (channel, scale) in [ + ("x", ctx.pos1_scale), + ("x2", ctx.pos1_scale), + ("y", ctx.pos2_scale), + ("y2", ctx.pos2_scale), + ] { + plot.set_binding(channel, scale); + } + + // One ribbon per category, its two edges at ±offset of the category band (or + // centreline → offset for a one-sided `side`), both shifted by the dodge + // offset (zero when not dodged). + let dodge = dodge_offsets(df, axes.dodge()); + let side = side_sign(layer); + let ordered_keys: Vec = order.iter().map(|&i| keys[i].clone()).collect(); + let edges: Vec<(f64, f64)> = order + .iter() + .map(|&i| { + let (near, far) = band_edges(off[i], side); + (dodge[i] + near, dodge[i] + far) + }) + .collect(); + let band: Vec = edges.iter().map(|&(near, _)| near).collect(); + let band2: Vec = edges.iter().map(|&(_, far)| far).collect(); + let values: Vec = order.iter().map(|&i| p2[i]).collect(); + + // What this composite styles, in one table: the ggsql defaults a legend key + // should wear when nothing is mapped, and the aliasing each resolve below + // uses. A composite has no `GeomSpec`, so it declares the same table itself. + let material = material(); + + // Resolve fill + stroke once (data-mapped → shared scale/legend, else + // constant), mirroring the VL writer's shared-encoding model. + let fill = resolve_color( + ctx, + plot, + "fill", + "fill", + rgb8(255, 255, 255), + LegendKind::Rect, + &material, + )?; + let stroke = resolve_color( + ctx, + plot, + "stroke", + "stroke", + rgb8(60, 60, 60), + LegendKind::Rect, + &material, + )?; + // Outline width + dash pattern, applied to both ribbon edges. + let linewidth = resolve_material( + ctx, + plot, + "linewidth", + "linewidth", + RangeKind::Number, + LegendKind::Line, + &material, + )?; + let linetype = resolve_material( + ctx, + plot, + "linetype", + "linetype", + RangeKind::Linetype, + LegendKind::Line, + &material, + )?; + // Resolved like the rest, so a mapped column fades each violin by its own + // datum rather than by the first row's. + let alpha = resolve_material( + ctx, + plot, + "opacity", + "fill_opacity", + RangeKind::Number, + LegendKind::Rect, + &material, + )?; + // Which of the ribbon's two edges take an outline. Both, normally; only the + // far edge under a one-sided `side`, where the near edge is the centreline. + let outline_edges: &[&str] = if side.is_some() { + &["stroke2", "linewidth2", "linetype2"] + } else { + &[ + "stroke", + "stroke2", + "linewidth", + "linewidth2", + "linetype", + "linetype2", + ] + }; + // The ribbon's two edges share each outline scale (the `2` suffix is the far + // edge), so a data-mapped stroke/width/dash styles both sides alike. + for (source, channel) in [ + (Some(&stroke), "stroke2"), + (linewidth.as_ref(), "linewidth2"), + (linetype.as_ref(), "linetype2"), + ] { + if let Some(name) = source.and_then(|s| s.scale_name()) { + plot.set_binding(channel, name); + } + } + + // Both band edges carry the category; only one value channel is set, which is + // what selects the ribbon's orientation (a vertical band when the far edge is + // on x, a horizontal one when it is on y). + let (band_ch, band_ch2) = axes.band_channels(); + let (frac_ch, frac_ch2) = axes.band_fraction_channels(); + let (value_ch, _) = axes.value_channels(); + + let mut b = RibbonGeom::builder(); + b.keys(ordered_keys); + p1.select(&order).apply(&mut b, band_ch); + p1.select(&order).apply(&mut b, band_ch2); + b.set(frac_ch, band); + b.set(frac_ch2, band2); + b.set(value_ch, values); + fill.apply(&mut b, "fill", &order); + // Under a one-sided `side`, `band_edges` collapses curve A onto the band's + // centreline, so stroking it would draw a rule down the flat side of every + // half-violin. Only the curve that traces the density gets an outline. + if outline_edges.contains(&"stroke") { + stroke.apply(&mut b, "stroke", &order); + } + stroke.apply(&mut b, "stroke2", &order); + // `RibbonGeom` resolves its outline channels once per mark (from the mark's + // first row), so a data-mapped width/dash varies per violin, not per vertex. + for (source, channels) in [ + (linewidth.as_ref(), ["linewidth", "linewidth2"]), + (linetype.as_ref(), ["linetype", "linetype2"]), + ] { + if let Some(source) = source { + for channel in channels.iter().filter(|c| outline_edges.contains(c)) { + source.apply(&mut b, channel, &order); + } + } + } + apply_material(&mut b, alpha.as_ref(), "fill_opacity", &order); + plot.add_geom(b.build()); + + Ok(()) +} diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs new file mode 100644 index 000000000..4d8edf7a1 --- /dev/null +++ b/src/writer/hephaestus/mod.rs @@ -0,0 +1,1772 @@ +//! PNG raster writer. +//! +//! Renders a resolved ggsql `Spec` to PNG bytes via the [`hephaestus`] 2D scene +//! renderer. Only [`PngWriter`] is public; the renderer behind it is an +//! implementation detail. +//! +//! **Scope**: multi-layer plots under Cartesian, Polar, and Map projections, +//! with `FACET` faceting (Wrap/Grid, fixed + free scales); every geom except +//! `arrow`; all scale types and transforms, material aesthetics, plot and axis +//! titles, and legends. A geom outside [`geom::is_supported`] is rejected by +//! [`PngWriter::validate`]. +//! +//! Architecture — the abstractions and the invariants they keep — and the +//! inventory of deferred work are documented in +//! `src/writer/hephaestus/CLAUDE.md`. +//! +//! Rendering uses hephaestus's Vello (GPU) backend, so a working wgpu adapter +//! (hardware or software, e.g. lavapipe) is required at render time. + +mod channels; +mod facet; +mod geom; +mod projection; +mod scales; +mod wiring; + +use std::collections::HashMap; + +use hephaestus::backend::vello::VelloRenderer; +pub use hephaestus::color::{rgba, Color}; +use hephaestus::geometry::Size; +use hephaestus::plot::{scale, AspectMode, Plot as HPlot, PlotComposition}; +use hephaestus::png::encode_png; +use hephaestus::scales::chrome::AxisSide; +use hephaestus::shape::ShapeRegistry; +use hephaestus::{Renderer, SceneBuilder}; + +use crate::naming; +use crate::plot::layer::geom::GeomType; +use crate::plot::layer::is_transposed; +use crate::plot::ParameterValue; +use crate::writer::hephaestus::projection::apply_projection; +use crate::writer::hephaestus::scales::build_scale; +use crate::writer::{Writer, WriterOptions}; +use crate::{DataFrame, GgsqlError, Layer, Plot, Result}; + +use wiring::Ctx; + +/// Default canvas width in pixels. +const DEFAULT_WIDTH: u32 = 1500; +/// Default canvas height in pixels. +const DEFAULT_HEIGHT: u32 = 1000; +/// Default resolution. DPI converts the theme's physical sizes (text, stroke +/// widths, spacing — all in points) to pixels, so it sets how large the chrome +/// is relative to the canvas as well as the print size of a physical figure. +const DEFAULT_DPI: f64 = 300.0; + +/// Largest canvas dimension accepted, in pixels. Far beyond any real figure, but +/// small enough that a slipped unit conversion fails with a message instead of +/// exhausting GPU memory. +const MAX_DIMENSION: f64 = 32_768.0; + +/// Fraction of a map's bounding-box span added as breathing room around it, so +/// marks on the boundary are not drawn against the panel edge. Matches the +/// Vega-Lite writer's projection fit (`span * 1.1`). +const MAP_PADDING: f64 = 0.1; + +/// Option keys [`PngWriter::from_options`] understands. +const OPTIONS: &[&str] = &["width", "height", "units", "dpi", "background"]; + +/// Units a `width` / `height` option may be given in. +const UNITS: &[&str] = &["px", "in", "cm", "mm", "pt"]; + +/// Writer that renders a ggsql plot to a PNG image. +/// +/// Configured with a target pixel size and DPI because raster rendering needs +/// concrete dimensions, unlike the resolution-independent Vega-Lite writer. +/// [`PngWriter::from_options`] builds the same configuration from +/// key–value [`WriterOptions`]: +/// +/// | Option | Value | Default | +/// | --- | --- | --- | +/// | `width` | Canvas width, in `units` | 1500 px | +/// | `height` | Canvas height, in `units` | 1000 px | +/// | `units` | `px`, `in`, `cm`, `mm`, or `pt` — how `width`/`height` are read | `px` | +/// | `dpi` | Pixels per inch; converts physical sizes, including `units` | 300 | +/// | `background` | Any CSS color, e.g. `white`, `#ff0000`, `transparent` | `white` | +#[derive(Debug, Clone, PartialEq)] +pub struct PngWriter { + width: u32, + height: u32, + dpi: f64, + background: Color, +} + +impl PngWriter { + /// Create a writer for the given pixel dimensions and DPI, white background. + pub fn new(width: u32, height: u32, dpi: f64) -> Self { + Self { + width, + height, + dpi, + background: rgba(1.0, 1.0, 1.0, 1.0), + } + } + + /// Set the background color used to clear the canvas before rendering. + pub fn background(mut self, color: Color) -> Self { + self.background = color; + self + } +} + +impl Default for PngWriter { + fn default() -> Self { + Self::new(DEFAULT_WIDTH, DEFAULT_HEIGHT, DEFAULT_DPI) + } +} + +impl Writer for PngWriter { + type Output = Vec; + + fn from_options(options: &WriterOptions) -> Result { + options.reject_unknown(OPTIONS)?; + + let dpi = match options.number("dpi")? { + Some(dpi) if dpi > 0.0 => dpi, + Some(dpi) => { + return Err(GgsqlError::WriterError(format!( + "writer option 'dpi' expects a positive number, got '{dpi}'" + ))) + } + None => DEFAULT_DPI, + }; + // `units` interprets the dimensions the caller supplies; the defaults are + // pixel counts, so they stand whatever the unit is. + let units = options.one_of("units", UNITS)?.unwrap_or("px"); + let width = match options.number("width")? { + Some(width) => to_pixels(width, units, dpi, "width")?, + None => DEFAULT_WIDTH, + }; + let height = match options.number("height")? { + Some(height) => to_pixels(height, units, dpi, "height")?, + None => DEFAULT_HEIGHT, + }; + + let mut writer = Self::new(width, height, dpi); + if let Some(raw) = options.get("background") { + // `none` is a familiar spelling of a transparent canvas that CSS + // itself doesn't accept as a color. + let color = match raw.trim().to_lowercase().as_str() { + "none" => rgba(0.0, 0.0, 0.0, 0.0), + _ => scales::parse_color(raw).ok_or_else(|| { + GgsqlError::WriterError(format!( + "writer option 'background' expects a CSS color, got '{raw}'" + )) + })?, + }; + writer = writer.background(color); + } + Ok(writer) + } + + fn validate(&self, spec: &Plot) -> Result<()> { + if spec.layers.is_empty() { + return Err(GgsqlError::WriterError( + "png writer requires at least one layer".into(), + )); + } + for layer in &spec.layers { + let geom_type = layer.geom.geom_type(); + if !geom::is_supported(geom_type) { + return Err(GgsqlError::WriterError(format!( + "png writer does not support the '{geom_type}' geom yet" + ))); + } + } + Ok(()) + } + + fn write(&self, spec: &Plot, data: &HashMap) -> Result { + self.validate(spec)?; + + // FACET → a grid of named panels (a single panel when unfaceted). Each + // panel becomes one hephaestus `Plot` sharing the composition's scales. + let (composition, panels) = facet::build_panels(spec, data)?; + // The composition owns the shape registry backing composition-level legend + // glyphs (point markers, line dashes). + let mut view = PlotComposition::new(&composition) + .shape_registry(ShapeRegistry::with_builtins()) + .theme(wiring::ggsql_theme()); + + // Plot title/subtitle/caption from the LABEL clause. These live on the + // composition, not the per-panel plots, so one label spans the whole + // figure — which is also correct for the unfaceted 1x1 case (a plot-level + // title would resolve to the same layout row and be painted over). + if let Some(text) = wiring::plot_label(spec, "title") { + view = view.title(text); + } + if let Some(text) = wiring::plot_label(spec, "subtitle") { + view = view.subtitle(text); + } + if let Some(text) = wiring::plot_label(spec, "caption") { + view = view.caption(text); + } + + // Axis titles are composition chrome too: one centred title per + // dimension for the whole figure, rather than one per panel rail. + for (side, text) in projection::composition_axis_titles(spec) { + view = view.axis_title(side, text); + } + + // Register the fixed (shared) scales once, globally. Every panel binds + // its position channels to these names, giving fixed-scale faceting. + for scale in &spec.scales { + let kind = match scale.aesthetic.as_str() { + "fill" | "stroke" => scales::RangeKind::Color, + "shape" => scales::RangeKind::Shape, + "linetype" => scales::RangeKind::Linetype, + // The text geom's font aesthetics: a scale over them resolves a + // range of family names / weights, not numbers. + "typeface" => scales::RangeKind::Text, + "fontweight" => scales::RangeKind::FontWeight, + "italic" => scales::RangeKind::Bool, + _ => { + if scale.aesthetic.starts_with("pos") { + scales::RangeKind::Position + } else { + scales::RangeKind::Number + } + } + }; + if let Some(hs) = build_scale(scale, kind) { + view.insert_scale(scale.aesthetic.clone(), hs); + } + } + + // Frame a map to its bounding box. Under a `PROJECT map` every mark, the + // clip boundary and the graticules share one pre-projected data space, so + // the position scales must span the map's extent rather than the marks' + // — otherwise the data is zoomed in and drifts off the boundary. A + // spatial layer additionally has no `pos1`/`pos2` columns at all (it + // positions by geometry), so ggsql resolves no position scales for it and + // these are the only ones. The bbox comes from ggsql + // (`computed["bbox"]` when projected, else the geometry extent), keeping + // the "writer never invents extents" principle. + let map_bbox = map_bbox(spec, data)?; + if let Some((xmin, ymin, xmax, ymax)) = map_bbox { + view.insert_scale("pos1".to_string(), scale::continuous(map_range(xmin, xmax))); + view.insert_scale("pos2".to_string(), scale::continuous(map_range(ymin, ymax))); + } + + // Legends are collected from the first panel only and registered once on + // the composition's own legend ring, so a faceted plot gets a single shared + // legend rather than one per panel. Every panel produces the same legends + // (all built from the globally resolved scales), so one capture suffices. + let legend_sink = std::cell::RefCell::new(Vec::new()); + let mut legends_captured = false; + + for panel in &panels { + // Slice each layer's data to this panel. A Grid cell whose facet + // combination doesn't occur in the data still becomes a panel — framed, + // axed and strip-labelled like any other, just with no marks — so the + // grid stays rectangular and its strips keep describing every row and + // column (the ggplot2 look). + let slices: Vec<(&Layer, DataFrame)> = spec + .layers + .iter() + .enumerate() + .map(|(idx, layer)| { + Ok(( + layer, + facet::panel_dataframe(layer_dataframe(layer, idx, data)?, panel)?, + )) + }) + .collect::>()?; + let empty = slices.iter().all(|(_, df)| df.height() == 0); + + // Fixed dimensions bind the shared `pos1`/`pos2`; free dimensions get + // a per-panel scale whose domain is computed from this panel's slices + // (the one place the writer computes extents — free facets only). + let mut ps = facet::PanelScales::new(spec, panel); + let layer_dfs: Vec<&DataFrame> = slices.iter().map(|(_, df)| df).collect(); + if ps.free_x { + match scales::free_position_scale(spec.find_scale("pos1"), &layer_dfs, "pos1") { + Some(hs) => view.insert_scale(ps.pos1.clone(), hs), + // No panel extent to free the dimension over (an empty cell), + // so read the shared scale rather than leave the axis and the + // channel bindings pointing at a scale that was never inserted. + None => ps.use_shared("pos1"), + } + } + if ps.free_y { + match scales::free_position_scale(spec.find_scale("pos2"), &layer_dfs, "pos2") { + Some(hs) => view.insert_scale(ps.pos2.clone(), hs), + None => ps.use_shared("pos2"), + } + } + + // Build every layer's geom into this panel; geoms bind channels and + // record legends (first panel only) into `legend_sink`, drawing in + // layer (DRAW) = z-order. An empty panel builds no geoms — a hephaestus + // geom over zero rows has nothing to draw — and so must not count as + // the legend-capturing panel either. + let panel_legends = (!legends_captured).then_some(&legend_sink); + let mut plot = HPlot::new(&composition, panel.id.as_str()) + .shape_registry(ShapeRegistry::with_builtins()); + if !empty { + for (layer, df) in &slices { + let ctx = Ctx { + spec, + layer, + df, + transposed: is_transposed(layer), + pos1_scale: &ps.pos1, + pos2_scale: &ps.pos2, + legends: panel_legends, + }; + geom::build_into_plot(&mut plot, &ctx)?; + } + legends_captured = true; + } else { + // hephaestus draws a panel's grid lines from the scales bound to + // the projection's channels — which a geom would have bound. With + // no geoms to do it, bind the position channels here so an empty + // cell carries the same grid as its populated neighbours. A + // position ggsql resolved no scale for stays unbound, since a + // binding to an unregistered scale fails validation. + for (channel, name) in [("x", &ps.pos1), ("y", &ps.pos2)] { + if view.scale(name).is_some() { + plot.set_binding(channel, name.clone()); + } + } + } + + // Axes are created per coordinate system, edge-only for fixed scales. + plot = apply_projection(plot, spec, panel, &ps); + + // Lock a map panel to square units so the projection keeps its + // proportions (a globe stays round), the raster analog of the + // Vega-Lite writer's single uniform projection scale. + // + // `aspect_ratio` is the *data-space* x-unit : y-unit ratio, not a + // panel width:height ratio. Map coordinates arrive pre-projected, so + // one unit means the same length on both axes and the ratio is 1 — + // passing the bbox's own height/width instead stretches every map by + // exactly that factor. + if map_bbox.is_some() { + plot = plot.aspect_ratio(1.0).aspect_mode(AspectMode::Range); + } + + // Facet strip labels (Wrap/Grid-column header on top, Grid-row on right). + if let Some(text) = &panel.strip_top { + plot = plot.strip(AxisSide::Top, text.clone()); + } + if let Some(text) = &panel.strip_right { + plot = plot.strip(AxisSide::Right, text.clone()); + } + + view.attach_plot(plot); + } + + // One shared legend for the whole composition (see `legend_sink` above). + for legend in legend_sink.into_inner() { + view.add_legend(legend); + } + + let issues = view.validate(); + if !issues.is_empty() { + return Err(GgsqlError::WriterError(format!( + "png writer composition validation failed: {issues:?}" + ))); + } + + render_png( + &mut view, + self.width, + self.height, + self.dpi, + self.background, + ) + } +} + +/// Convert a canvas dimension given in `units` to whole pixels at `dpi`. +/// +/// A physical unit goes through inches, so the same figure grows with DPI; `px` +/// is already the canvas unit, where DPI only scales the chrome. +fn to_pixels(value: f64, units: &str, dpi: f64, key: &str) -> Result { + let per_inch = match units { + "in" => 1.0, + "cm" => 2.54, + "mm" => 25.4, + "pt" => 72.0, + _ => return whole_pixels(value, key), + }; + whole_pixels(value / per_inch * dpi, key) +} + +/// Round a pixel count and reject one outside the renderable range. +fn whole_pixels(pixels: f64, key: &str) -> Result { + let rounded = pixels.round(); + if !(1.0..=MAX_DIMENSION).contains(&rounded) { + return Err(GgsqlError::WriterError(format!( + "writer option '{key}' resolves to {rounded} px, outside the supported range 1–{MAX_DIMENSION} px" + ))); + } + Ok(rounded as u32) +} + +/// The map bounding box `(xmin, ymin, xmax, ymax)`, or `None` when the plot is +/// not a map. ggsql's resolved `computed["bbox"]` (set under a `PROJECT map`) +/// wins; a bare `spatial` geom with no projection falls back to the union extent +/// of its geometry data. +fn map_bbox( + spec: &Plot, + data: &HashMap, +) -> Result> { + if let Some(proj) = &spec.project { + if let Some(ParameterValue::Array(arr)) = proj.computed.get("bbox") { + let nums: Vec = arr.iter().filter_map(|e| e.to_f64()).collect(); + if let [xmin, ymin, xmax, ymax] = nums[..] { + if [xmin, ymin, xmax, ymax].iter().all(|v| v.is_finite()) { + return Ok(Some((xmin, ymin, xmax, ymax))); + } + } + } + } + + let is_spatial = |layer: &Layer| layer.geom.geom_type() == GeomType::Spatial; + if !spec.layers.iter().any(is_spatial) { + return Ok(None); + } + + let geom_col = naming::aesthetic_column("geometry"); + let (mut xmin, mut ymin, mut xmax, mut ymax) = ( + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ); + for (idx, layer) in spec + .layers + .iter() + .enumerate() + .filter(|(_, l)| is_spatial(l)) + { + let df = layer_dataframe(layer, idx, data)?; + if df.column(&geom_col).is_err() { + continue; + } + for g in channels::column_to_geometry(df, &geom_col)? { + if let Some((x0, y0, x1, y1)) = g.bounds() { + xmin = xmin.min(x0); + ymin = ymin.min(y0); + xmax = xmax.max(x1); + ymax = ymax.max(y1); + } + } + } + Ok( + (xmin.is_finite() && ymin.is_finite() && xmax.is_finite() && ymax.is_finite()) + .then_some((xmin, ymin, xmax, ymax)), + ) +} + +/// A non-degenerate inclusive range for a map's continuous position scale. +/// +/// The extent is padded by [`MAP_PADDING`] around its centre, matching the +/// Vega-Lite writer, which fits the projection to `span * 1.1` centred on the +/// bbox (`vegalite/projection/map.rs`). A zero-width or inverted extent is +/// widened instead, so the scale can still map it. +fn map_range(min: f64, max: f64) -> std::ops::RangeInclusive { + let span = max - min; + if span > f64::EPSILON { + let pad = span * MAP_PADDING / 2.0; + (min - pad)..=(max + pad) + } else { + (min - 0.5)..=(max + 0.5) + } +} + +/// Look up the DataFrame backing a layer by its execution-assigned data key, +/// falling back to the conventional key for its index as the Vega-Lite writer +/// does. Execution always assigns the key; the fallback is for a hand-built +/// `Plot`. +fn layer_dataframe<'a>( + layer: &Layer, + idx: usize, + data: &'a HashMap, +) -> Result<&'a DataFrame> { + let key = layer + .data_key + .clone() + .unwrap_or_else(|| naming::layer_key(idx)); + data.get(&key) + .ok_or_else(|| GgsqlError::WriterError(format!("no data found for layer key '{key}'"))) +} + +/// Render the composition to an RGBA8 buffer and encode it as PNG bytes. +fn render_png( + view: &mut PlotComposition, + width: u32, + height: u32, + dpi: f64, + background: Color, +) -> Result> { + let mut renderer = VelloRenderer::new().map_err(|e| { + GgsqlError::WriterError(format!("could not initialise the GPU renderer: {e}")) + })?; + { + let scene = renderer.scene(); + scene.clear(); + view.render(scene, Size::new(width as f64, height as f64), dpi); + } + let mut pixels = vec![0u8; (width as usize) * (height as usize) * 4]; + renderer + .render_to_buffer(width, height, background, &mut pixels) + .map_err(|e| GgsqlError::WriterError(format!("png render failed: {e}")))?; + + // `render_to_buffer` hands out straight (un-premultiplied) alpha, which is + // exactly what PNG stores, so the buffer encodes as-is. + encode_png(width, height, &pixels) + .map_err(|e| GgsqlError::WriterError(format!("PNG encode failed: {e}"))) +} + +/// `from_options` tests. Separate from the render suite below because they need +/// neither a reader nor a GPU. +#[cfg(test)] +mod option_tests { + use super::*; + + fn writer(pairs: &[&str]) -> Result { + PngWriter::from_options(&WriterOptions::parse(pairs)?) + } + + /// The writer's canvas as `(width, height, dpi)`. + fn canvas(pairs: &[&str]) -> (u32, u32, f64) { + let writer = writer(pairs).unwrap(); + (writer.width, writer.height, writer.dpi) + } + + #[test] + fn no_options_gives_the_defaults() { + assert_eq!(canvas(&[]), (DEFAULT_WIDTH, DEFAULT_HEIGHT, DEFAULT_DPI)); + let default = PngWriter::default(); + assert_eq!(canvas(&[]), (default.width, default.height, default.dpi)); + // White, as `new()` sets it. + let background = writer(&[]).unwrap().background; + assert_eq!(background.components, [1.0, 1.0, 1.0, 1.0]); + } + + #[test] + fn pixel_dimensions_are_taken_verbatim() { + assert_eq!(canvas(&["width=1600", "height=1200"]).0, 1600); + assert_eq!(canvas(&["width=1600", "height=1200"]).1, 1200); + // `units=px` is the default, and DPI does not rescale a pixel canvas. + assert_eq!( + canvas(&["width=800", "units=px", "dpi=72"]), + (800, 1000, 72.0) + ); + } + + #[test] + fn physical_dimensions_scale_with_dpi() { + assert_eq!( + canvas(&["width=8", "height=6", "units=in", "dpi=100"]).0, + 800 + ); + assert_eq!( + canvas(&["width=8", "height=6", "units=in", "dpi=100"]).1, + 600 + ); + // 2.54 cm = 1 in; 25.4 mm = 1 in; 72 pt = 1 in. + assert_eq!(canvas(&["width=2.54", "units=cm", "dpi=96"]).0, 96); + assert_eq!(canvas(&["width=25.4", "units=mm", "dpi=96"]).0, 96); + assert_eq!(canvas(&["width=72", "units=pt", "dpi=96"]).0, 96); + // Defaults stay pixel counts even when the caller works in inches. + assert_eq!( + canvas(&["width=5", "units=in", "dpi=200"]).1, + DEFAULT_HEIGHT + ); + } + + #[test] + fn background_accepts_css_colors() { + let red = writer(&["background=#ff0000"]).unwrap().background; + assert_eq!(red.components, [1.0, 0.0, 0.0, 1.0]); + for spelling in ["background=transparent", "background=none"] { + let clear = writer(&[spelling]).unwrap().background; + assert_eq!( + clear.components[3], 0.0, + "{spelling} should be fully transparent" + ); + } + assert!(writer(&["background=rgb(0, 0, 255)"]).is_ok()); + } + + #[test] + fn bad_values_are_reported_per_option() { + let cases = [ + ("units=furlongs", "'units' expects"), + ("dpi=0", "'dpi' expects a positive number"), + ("dpi=high", "'dpi' expects a number"), + ("width=0", "'width' resolves to 0 px"), + ("width=-4", "'width' resolves to -4 px"), + ("height=1e9", "'height' resolves to"), + ("background=nope", "'background' expects a CSS color"), + ]; + for (option, expected) in cases { + let err = writer(&[option]).unwrap_err().to_string(); + assert!(err.contains(expected), "{option}: {err}"); + } + } + + #[test] + fn unknown_options_are_rejected() { + let err = writer(&["with=1600"]).unwrap_err().to_string(); + assert!(err.contains("unknown writer option 'with'"), "{err}"); + assert!(err.contains("supported options: width, height"), "{err}"); + } +} + +#[cfg(all(test, feature = "duckdb"))] +mod tests { + use super::*; + use crate::reader::{DuckDBReader, Reader}; + + fn render(query: &str) -> Result> { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let spec = reader.execute(query).unwrap(); + PngWriter::new(640, 480, 96.0).render(&spec) + } + + /// The panels' `(top, right)` strip labels, in panel order. Exercises the + /// facet layout and labelling without rendering, so it needs no GPU. + fn strips(query: &str) -> Vec<(Option, Option)> { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let spec = reader.execute(query).unwrap(); + let (_, panels) = facet::build_panels(spec.plot(), spec.data()).unwrap(); + panels + .iter() + .map(|p| (p.strip_top.clone(), p.strip_right.clone())) + .collect() + } + + /// The figure's composition-level axis titles. Needs no GPU. + fn axis_titles(query: &str) -> Vec<(AxisSide, String)> { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let spec = reader.execute(query).unwrap(); + projection::composition_axis_titles(spec.plot()) + } + + /// Just the top strip labels, in panel order. + fn top_strips(query: &str) -> Vec { + strips(query) + .into_iter() + .map(|(top, _)| top.unwrap_or_default()) + .collect() + } + + /// Assert a PNG was produced, tolerating headless CI with no GPU adapter. + fn assert_png_or_skip(result: Result>) { + match result { + Ok(png) => assert!( + png.starts_with(&[0x89, b'P', b'N', b'G']), + "output should carry the PNG signature" + ), + Err(GgsqlError::WriterError(msg)) if msg.contains("GPU renderer") => { + eprintln!("skipping render assertion: {msg}"); + } + Err(e) => panic!("unexpected error: {e}"), + } + } + + #[test] + fn renders_basic_point_plot() { + assert_png_or_skip(render( + "SELECT 1 AS x, 2 AS y UNION ALL SELECT 2, 3 UNION ALL SELECT 3, 1 \ + VISUALISE x AS x, y AS y DRAW point", + )); + } + + #[test] + fn renders_categorical_color_with_legend() { + assert_png_or_skip(render( + "SELECT 1 AS x, 2 AS y, 'a' AS grp UNION ALL SELECT 2, 3, 'b' \ + UNION ALL SELECT 3, 1, 'a' \ + VISUALISE x AS x, y AS y, grp AS color DRAW point", + )); + } + + #[test] + fn renders_continuous_size() { + assert_png_or_skip(render( + "SELECT 1 AS x, 2 AS y, 10 AS w UNION ALL SELECT 2, 3, 40 \ + UNION ALL SELECT 3, 1, 90 \ + VISUALISE x AS x, y AS y, w AS size DRAW point", + )); + } + + #[test] + fn renders_shape_legend() { + // A non-color legend key must be given a color to paint, else the + // swatches come out empty next to their labels. + assert_png_or_skip(render( + "SELECT x, y, g FROM (VALUES (1,2,'a'),(2,3,'b'),(3,1,'c')) t(x,y,g) \ + VISUALISE x AS x, y AS y, g AS shape DRAW point", + )); + } + + #[test] + fn renders_linetype_legend() { + assert_png_or_skip(render( + "SELECT x, y, g FROM (VALUES (1,2,'a'),(2,3,'a'),(1,1,'b'),(2,2,'b')) t(x,y,g) \ + VISUALISE x AS x, y AS y, g AS linetype DRAW line", + )); + } + + /// An identity column is a per-row literal, so a `linetype` column holds ggsql + /// names or hex patterns and must go through `map_linetype` exactly as the + /// literal does — the channel takes dash patterns, not strings, so passing the + /// names through drew a solid line. + #[test] + fn renders_identity_linetype() { + assert_png_or_skip(render( + "SELECT x, y, lt FROM (VALUES (1,2,'dashed'),(2,3,'dashed'),(1,1,'dotted'),(2,2,'dotted')) t(x,y,lt) \ + VISUALISE x AS x, y AS y, lt AS linetype DRAW line SCALE IDENTITY linetype", + )); + } + + #[test] + fn renders_colorbar_beside_size_legend() { + // Two distinct scales: a merged colorbar for `color` plus a keyed size + // legend whose glyphs fall back to a neutral color (the mapped `fill` + // column holds domain values, not a constant to borrow). + assert_png_or_skip(render( + "SELECT x, y, c, w FROM (VALUES (1,2,10,100),(2,3,50,200),(3,1,90,300)) t(x,y,c,w) \ + VISUALISE x AS x, y AS y, c AS color, w AS size DRAW point", + )); + } + + #[test] + fn renders_log_scale() { + assert_png_or_skip(render( + "SELECT 1 AS x, 2 AS y UNION ALL SELECT 10, 3 UNION ALL SELECT 100, 1 \ + VISUALISE x AS x, y AS y DRAW point SCALE x VIA log", + )); + } + + #[test] + fn renders_grouped_line() { + assert_png_or_skip(render( + "SELECT 1 AS x, 2 AS y, 'a' AS g UNION ALL SELECT 2, 3, 'a' \ + UNION ALL SELECT 1, 1, 'b' UNION ALL SELECT 2, 2, 'b' \ + VISUALISE x AS x, y AS y, g AS color DRAW line", + )); + } + + #[test] + fn renders_bar() { + assert_png_or_skip(render( + "SELECT 'a' AS cat, 3 AS v UNION ALL SELECT 'b', 5 UNION ALL SELECT 'c', 2 \ + VISUALISE cat AS x, v AS y DRAW bar", + )); + } + + #[test] + fn renders_dodged_bar() { + assert_png_or_skip(render( + "SELECT x, grp, v FROM (VALUES ('a','p',3),('a','q',5),('b','p',2),('b','q',4)) \ + t(x, grp, v) \ + VISUALISE x AS x, v AS y, grp AS fill DRAW bar SETTING position => 'dodge'", + )); + } + + #[test] + fn renders_histogram() { + assert_png_or_skip(render( + "SELECT x FROM (VALUES (1),(2),(2),(3),(3),(3),(4),(4),(5)) t(x) \ + VISUALISE x AS x DRAW histogram", + )); + } + + #[test] + fn renders_area() { + assert_png_or_skip(render( + "SELECT 1 AS x, 2 AS y UNION ALL SELECT 2, 4 UNION ALL SELECT 3, 3 \ + VISUALISE x AS x, y AS y DRAW area", + )); + } + + #[test] + fn renders_ribbon() { + assert_png_or_skip(render( + "SELECT 1 AS x, 1 AS lo, 3 AS hi UNION ALL SELECT 2, 2, 5 \ + UNION ALL SELECT 3, 1, 4 \ + VISUALISE x AS x, lo AS ymin, hi AS ymax DRAW ribbon", + )); + } + + #[test] + fn renders_segment() { + assert_png_or_skip(render( + "SELECT 0 AS x, 0 AS y, 1 AS xend, 2 AS yend UNION ALL SELECT 1, 1, 2, 0 \ + VISUALISE x AS x, y AS y, xend AS xend, yend AS yend DRAW segment", + )); + } + + #[test] + fn renders_text() { + assert_png_or_skip(render( + "SELECT 1 AS x, 2 AS y, 'hi' AS lab UNION ALL SELECT 2, 3, 'there' \ + VISUALISE x AS x, y AS y, lab AS label DRAW text", + )); + } + + #[test] + fn renders_text_styled() { + assert_png_or_skip(render( + "SELECT 1 AS x, 1 AS y, 'a' AS lab UNION ALL SELECT 2, 2, 'Hello' \ + UNION ALL SELECT 3, 3, 'z' \ + VISUALISE x AS x, y AS y, lab AS label, 30 AS rotation, \ + 'bold' AS fontweight, 22 AS fontsize DRAW text", + )); + } + + /// A scaled `fontsize` on a layer whose face is set: the legend key is + /// dressed from the same material table the glyphs are, so `family` / + /// `weight` / `italic` / `angle` all have to reach it. + #[test] + fn renders_text_font_legend() { + assert_png_or_skip(render( + "SELECT 1 AS x, 1 AS y, 'a' AS lab, 10 AS sz UNION ALL SELECT 2, 2, 'b', 20 \ + UNION ALL SELECT 3, 3, 'c', 30 \ + VISUALISE x AS x, y AS y, lab AS label, sz AS fontsize \ + DRAW text SETTING typeface => 'Times New Roman', fontweight => 'bold', \ + italic => true, rotation => 20 SCALE fontsize TO (10, 30)", + )); + } + + /// A label carrying markdown: `parse` defaults on, so the row goes through + /// hephaestus's rich-text shaper rather than being drawn with its markers. + #[test] + fn renders_text_markdown() { + assert_png_or_skip(render( + "SELECT 1 AS x, 1 AS y, '**bold** and {.red red}' AS lab \ + UNION ALL SELECT 2, 2, '`code` and ~~strike~~' \ + VISUALISE x AS x, y AS y, lab AS label DRAW text", + )); + } + + /// `SETTING parse => false` opts the layer out, drawing the markers literally. + #[test] + fn renders_text_markdown_off() { + assert_png_or_skip(render( + "SELECT 1 AS x, 1 AS y, '**bold** and {.red red}' AS lab \ + VISUALISE x AS x, y AS y, lab AS label DRAW text SETTING parse => false", + )); + } + + /// The glyph outline survives the markdown path: hephaestus folds the row's + /// `text_stroke` onto the rich sheet's root selector rather than dropping it. + #[test] + fn renders_text_markdown_with_stroke() { + assert_png_or_skip(render( + "SELECT 1 AS x, 1 AS y, '**bold**' AS lab \ + VISUALISE x AS x, y AS y, lab AS label \ + DRAW text SETTING fontsize => 30, stroke => 'red', rotation => 20", + )); + } + + /// Markdown chrome: a `LABEL` string is rich text too, so the title, subtitle, + /// caption and axis titles all shape through the rich pipeline. + #[test] + fn renders_markdown_chrome() { + assert_png_or_skip(render( + "SELECT 1 AS x, 2 AS y UNION ALL SELECT 2, 3 \ + VISUALISE x AS x, y AS y DRAW point \ + LABEL title => 'A **bold** title', subtitle => '{.red red} subtitle', \ + caption => '*italic* caption', x => 'axis *italic*'", + )); + } + + /// The same aesthetics as *columns*, which take the identity path rather than + /// the literal one: strings, booleans and degrees, each converted per row. + #[test] + fn renders_text_mapped_font() { + assert_png_or_skip(render( + "SELECT 1 AS x, 1 AS y, 'a' AS lab, 'Times New Roman' AS face, 'bold' AS wt, \ + true AS it, 0 AS rot \ + UNION ALL SELECT 2, 2, 'b', 'Helvetica', 'light', false, 45 \ + VISUALISE x AS x, y AS y, lab AS label, face AS typeface, wt AS fontweight, \ + it AS italic, rot AS rotation DRAW text", + )); + } + + #[test] + fn renders_polygon() { + assert_png_or_skip(render( + "SELECT x, y FROM (VALUES (0,0),(2,0),(1,2)) t(x, y) \ + VISUALISE x AS x, y AS y DRAW polygon", + )); + } + + #[test] + fn renders_boxplot() { + assert_png_or_skip(render( + "SELECT g, y FROM (VALUES ('a',1),('a',5),('a',3),('a',9),('a',2),('a',20), \ + ('b',4),('b',6),('b',5),('b',7),('b',3)) t(g, y) \ + VISUALISE g AS x, y AS y DRAW boxplot", + )); + } + + #[test] + fn renders_boxplot_fill_by_group() { + assert_png_or_skip(render( + "SELECT g, y FROM (VALUES ('a',1),('a',5),('a',3),('a',9),('a',2), \ + ('b',4),('b',6),('b',5),('b',7),('b',3)) t(g, y) \ + VISUALISE g AS x, y AS y, g AS fill DRAW boxplot", + )); + } + + #[test] + fn renders_diagonal_rule() { + assert_png_or_skip(render( + "SELECT 0 AS i VISUALISE i AS y DRAW rule \ + SETTING slope => 1 SCALE x FROM (0, 10) SCALE y FROM (0, 10)", + )); + // The dash pattern is honored on the computed segment. + assert_png_or_skip(render( + "SELECT 0 AS i VISUALISE i AS y DRAW rule \ + SETTING slope => 1, linetype => 'dashed', linewidth => 2 \ + SCALE x FROM (0, 10) SCALE y FROM (0, 10)", + )); + // One line per row: three intercepts → three parallel lines. + assert_png_or_skip(render( + "SELECT * FROM (VALUES (0),(2),(4)) t(i) VISUALISE i AS y DRAW rule \ + SETTING slope => 1 SCALE x FROM (0, 10) SCALE y FROM (0, 15)", + )); + } + + #[test] + fn renders_multiple_diagonal_rules() { + // Per-row slope + intercept + a data-mapped material aesthetic: three + // differently-sloped, differently-colored ablines over a scatter (the + // Vega-Lite writer's `test_rule_renderer_multiple_diagonal_lines` query). + assert_png_or_skip(render( + "WITH points AS (SELECT * FROM (VALUES (0, 5), (5, 15), (10, 25)) t(x, y)), \ + lines AS (SELECT * FROM (VALUES (2, 5, 'A'), (1, 10, 'B'), (3, 0, 'C')) \ + t(slope, y, line_id)) \ + SELECT * FROM points VISUALISE \ + DRAW point MAPPING x AS x, y AS y \ + DRAW rule MAPPING slope AS slope, y AS y, line_id AS color FROM lines", + )); + } + + #[test] + fn renders_constant_aesthetics() { + // Constant material values from `SETTING` arrive as `AestheticValue::Literal` + // and must be honored (color/size on points, linetype/linewidth on a line). + assert_png_or_skip(render( + "SELECT * FROM (VALUES (1,1),(2,3),(3,2)) t(a,b) \ + VISUALISE a AS x, b AS y DRAW point SETTING color => 'red', size => 8", + )); + assert_png_or_skip(render( + "SELECT * FROM (VALUES (1,1),(2,3),(3,2)) t(a,b) \ + VISUALISE a AS x, b AS y DRAW line \ + SETTING color => 'steelblue', linetype => 'dashed', linewidth => 2", + )); + } + + #[test] + fn renders_multilayer_point_line() { + // Two layers share one pair of axes / position scales. + assert_png_or_skip(render( + "SELECT * FROM (VALUES (1,2),(2,4),(3,5),(4,4),(5,7)) t(a,b) \ + VISUALISE a AS x, b AS y DRAW point DRAW line", + )); + } + + #[test] + fn renders_multilayer_overlay() { + // Bar + point overlay (point drawn over bar) over a shared discrete x. + assert_png_or_skip(render( + "SELECT g, b FROM (VALUES ('a',2),('b',4),('c',5),('d',3)) t(g,b) \ + VISUALISE g AS x, b AS y DRAW bar DRAW point SETTING color => 'red'", + )); + } + + #[test] + fn renders_multilayer_abline() { + // A diagonal reference line overlaid on a scatter spans the shared + // resolved x/y domain. + assert_png_or_skip(render( + "SELECT * FROM (VALUES (1,2),(2,4),(3,5),(4,4),(5,7)) t(a,b) \ + VISUALISE a AS x, b AS y DRAW point PLACE rule SETTING slope => 1, y => 0", + )); + } + + #[test] + fn renders_multilayer_shared_legend() { + // Two layers both colored by the same variable → one collapsed legend. + assert_png_or_skip(render( + "SELECT g, a, b FROM (VALUES ('p',1,2),('p',2,4),('q',3,5),('q',4,4)) t(g,a,b) \ + VISUALISE a AS x, b AS y, g AS color DRAW point DRAW line", + )); + } + + #[test] + fn renders_boxplot_styled() { + assert_png_or_skip(render( + "SELECT g, y FROM (VALUES ('a',1),('a',5),('a',3),('a',9),('a',2), \ + ('b',4),('b',6),('b',5),('b',7),('b',3)) t(g, y) \ + VISUALISE g AS x, y AS y, 'navy' AS stroke DRAW boxplot", + )); + } + + #[test] + fn renders_boxplot_stroke_by_group() { + // Data-mapped stroke colors every component (box/whisker/median/outlier) + // per group and registers one collapsed legend. + assert_png_or_skip(render( + "SELECT g, y FROM (VALUES ('a',1),('a',5),('a',3),('a',9),('a',2),('a',40), \ + ('b',4),('b',6),('b',5),('b',7),('b',3)) t(g, y) \ + VISUALISE g AS x, y AS y, g AS stroke DRAW boxplot", + )); + } + + #[test] + fn renders_tile_sized() { + // `width`/`height` settings shrink discrete tiles within their band. + assert_png_or_skip(render( + "SELECT a, b, v FROM (VALUES ('x','p',1),('y','q',2),('x','q',3),('y','p',4)) t(a,b,v) \ + VISUALISE a AS x, b AS y, v AS fill DRAW tile SETTING width => 0.5, height => 0.5", + )); + } + + #[test] + fn renders_tile_mixed_discrete_and_continuous_axes() { + // The tile stat parameterises each direction on its own, so a tile can be + // banded on one axis and spanned by extents on the other. + let data = + "SELECT c, n, v FROM (VALUES ('x',1.0,1),('y',2.0,2),('x',2.0,3),('y',1.0,4)) t(c,n,v)"; + assert_png_or_skip(render(&format!( + "{data} VISUALISE c AS x, n AS y, v AS fill DRAW tile" + ))); + assert_png_or_skip(render(&format!( + "{data} VISUALISE n AS x, c AS y, v AS fill DRAW tile" + ))); + } + + #[test] + fn renders_text_keyword_justification_column() { + // A `vjust` column of keywords is read as keywords: casting it to numbers + // first would silently make every anchor NaN. + assert_png_or_skip(render( + "SELECT x, y, l, j FROM (VALUES (1,1,'one','top'),(2,2,'two','bottom')) t(x,y,l,j) \ + VISUALISE x AS x, y AS y, l AS label, j AS vjust DRAW text", + )); + } + + #[test] + fn renders_violin() { + assert_png_or_skip(render( + "SELECT g, y FROM (VALUES ('a',1),('a',5),('a',3),('a',9),('a',2), \ + ('b',4),('b',6),('b',5),('b',7),('b',3)) t(g, y) \ + VISUALISE g AS x, y AS y DRAW violin", + )); + } + + #[test] + fn renders_polar_pie() { + // A stacked bar under polar becomes a pie: pos2 (count) → theta, + // pos1 (dummy) → radius. Includes a 180° slice, which exercises the + // wide-wedge path. + assert_png_or_skip(render( + "SELECT c FROM (VALUES ('a'),('a'),('a'),('b'),('b'),('c')) t(c) \ + VISUALISE c AS fill DRAW bar PROJECT TO polar", + )); + } + + #[test] + fn renders_polar_donut() { + // `inner` opens a centre hole (donut). + assert_png_or_skip(render( + "SELECT c FROM (VALUES ('a'),('a'),('a'),('b'),('b'),('c')) t(c) \ + VISUALISE c AS fill DRAW bar PROJECT TO polar SETTING inner => 0.5", + )); + } + + #[test] + fn renders_wrap_facet() { + assert_png_or_skip(render( + "SELECT 1 AS x, 2 AS y, 'a' AS g UNION ALL SELECT 2, 3, 'b' \ + UNION ALL SELECT 3, 1, 'a' UNION ALL SELECT 4, 5, 'c' \ + VISUALISE x AS x, y AS y DRAW point FACET g", + )); + } + + #[test] + fn renders_grid_facet() { + assert_png_or_skip(render( + "SELECT 1 AS x, 2 AS y, 'a' AS r, 'p' AS c UNION ALL SELECT 2, 3, 'b', 'p' \ + UNION ALL SELECT 3, 1, 'a', 'q' UNION ALL SELECT 4, 5, 'b', 'q' \ + VISUALISE x AS x, y AS y DRAW point FACET r BY c", + )); + } + + #[test] + fn renders_sparse_grid_facet() { + // A grid whose row × column combinations are not all present: the absent + // cells are still drawn — framed, gridded, axed and strip-labelled — so the + // grid stays rectangular. `('b','q')` has no rows here. + assert_png_or_skip(render( + "SELECT 1 AS x, 2 AS y, 'a' AS r, 'p' AS c UNION ALL SELECT 2, 3, 'b', 'p' \ + UNION ALL SELECT 3, 1, 'a', 'q' \ + VISUALISE x AS x, y AS y DRAW point FACET r BY c", + )); + } + + #[test] + fn renders_sparse_grid_facet_free() { + // An empty cell has no extent of its own, so a free dimension falls back to + // the shared scale there — the axis and channel bindings must still resolve. + assert_png_or_skip(render( + "SELECT 1 AS x, 2 AS y, 'a' AS r, 'p' AS c UNION ALL SELECT 2, 3, 'b', 'p' \ + UNION ALL SELECT 3, 1, 'a', 'q' \ + VISUALISE x AS x, y AS y DRAW point FACET r BY c SETTING free => ['x','y']", + )); + } + + #[test] + fn renders_faceted_bar_with_color() { + assert_png_or_skip(render( + "SELECT g, k FROM (VALUES ('a','x'),('a','y'),('b','x'),('b','y'),('a','x')) t(g, k) \ + VISUALISE k AS x, k AS fill DRAW bar FACET g", + )); + } + + #[test] + fn renders_free_scale_facet() { + // Panels with very different data ranges: free scales give each panel its + // own per-panel domain and axes. + assert_png_or_skip(render( + "SELECT x, y, g FROM (VALUES (1,1,'a'),(2,2,'a'),(3,3,'a'),\ + (100,100,'b'),(200,200,'b'),(300,300,'b')) t(x,y,g) \ + VISUALISE x AS x, y AS y DRAW point FACET g SETTING free => ['x','y']", + )); + } + + #[test] + fn renders_polar_facet() { + // A pie per panel, sharing the fill scale; proportions differ per panel. + assert_png_or_skip(render( + "SELECT c, panel FROM (VALUES \ + ('a','one'),('a','one'),('b','one'),('c','one'),\ + ('a','two'),('b','two'),('b','two'),('b','two'),('c','two')) t(c, panel) \ + VISUALISE c AS fill DRAW bar PROJECT TO polar FACET panel", + )); + } + + #[cfg(feature = "spatial")] + #[test] + fn renders_spatial() { + // A bare `spatial` geom (no PROJECT): two polygons filled by a value, + // framed to the geometry bbox under Cartesian with equal aspect. + assert_png_or_skip(render( + "INSTALL spatial; LOAD spatial; \ + SELECT ST_GeomFromText('POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))') AS geom, \ + 200 AS population \ + UNION ALL SELECT ST_GeomFromText('POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))'), 150 \ + VISUALISE DRAW spatial MAPPING population AS fill", + )); + } + + #[cfg(feature = "spatial")] + #[test] + fn renders_spatial_mapped_opacity() { + // A data-mapped scalar aesthetic (opacity) must vary per feature and + // register a legend, not collapse to a constant. + assert_png_or_skip(render( + "INSTALL spatial; LOAD spatial; \ + SELECT ST_GeomFromText('POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))') AS geom, \ + 10 AS v \ + UNION ALL SELECT ST_GeomFromText('POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))'), 90 \ + VISUALISE DRAW spatial MAPPING v AS opacity", + )); + } + + #[cfg(feature = "spatial")] + #[test] + fn renders_map() { + // A projected world map: pre-projected geometry + Custom projection + // boundary + graticules from `computed`. + assert_png_or_skip(render( + "VISUALISE FROM ggsql:world DRAW spatial PROJECT TO orthographic", + )); + } + + /// Under a map `PROJECT`, ggsql expands these layers into per-vertex rows and + /// remaps the extent aesthetics onto `pos1`/`pos2`, so each must draw as a + /// polyline or a polygon rather than as its usual mark — otherwise a segment + /// is zero-length, a ribbon zero-height, a rule a fan of straight lines, and + /// a tile a box per vertex. + #[cfg(feature = "spatial")] + #[test] + fn renders_densified_segment() { + assert_png_or_skip(render( + "INSTALL spatial; LOAD spatial; \ + SELECT * FROM (VALUES (-100,30,20,60),(-50,-20,100,10)) t(x1,y1,x2,y2) \ + VISUALISE x1 AS x, y1 AS y, x2 AS xend, y2 AS yend DRAW segment \ + SETTING stroke => 'firebrick', linewidth => 2 PROJECT x, y TO robinson", + )); + } + + #[cfg(feature = "spatial")] + #[test] + fn renders_densified_ribbon() { + assert_png_or_skip(render( + "INSTALL spatial; LOAD spatial; \ + SELECT * FROM (VALUES (-160,-20,20),(-80,0,40),(0,10,50),(80,-10,30)) t(x,lo,hi) \ + VISUALISE x AS x, lo AS ymin, hi AS ymax DRAW ribbon \ + SETTING fill => 'steelblue' PROJECT x, y TO robinson", + )); + } + + #[cfg(feature = "spatial")] + #[test] + fn renders_densified_rule() { + // A rule spans the clip bbox, so its meridians curve with the projection. + assert_png_or_skip(render( + "INSTALL spatial; LOAD spatial; \ + SELECT * FROM (VALUES (-100),(0),(100)) t(x) VISUALISE x AS x DRAW rule \ + SETTING stroke => 'darkgreen', linetype => 'dashed' PROJECT x, y TO robinson", + )); + } + + #[cfg(feature = "spatial")] + #[test] + fn renders_densified_tile() { + assert_png_or_skip(render( + "INSTALL spatial; LOAD spatial; \ + SELECT * FROM (VALUES (-120,-30,5),(-40,20,9),(40,-10,3)) t(x,y,v) \ + VISUALISE x AS x, y AS y, v AS fill DRAW tile \ + SETTING width => 40, height => 30 PROJECT x, y TO robinson", + )); + } + + #[cfg(feature = "spatial")] + #[test] + fn renders_map_over_spatial_base() { + // A non-spatial layer over a spatial base map: both must frame to ggsql's + // bbox so the segments land on the boundary, not on their own extent. + assert_png_or_skip(render( + "WITH routes AS (SELECT * FROM (VALUES (-74,40,2,48,'a'),(151,-34,18,-34,'b')) \ + t(x1,y1,x2,y2,route)) \ + VISUALISE \ + DRAW spatial MAPPING * FROM ggsql:world \ + DRAW segment MAPPING x1 AS x, y1 AS y, x2 AS xend, y2 AS yend, route AS stroke \ + FROM routes \ + PROJECT x, y TO robinson", + )); + } + + /// A 6-row fixture whose `g` is categorical and `v` numeric. + const FACET_DATA: &str = "SELECT g, v, y FROM (VALUES \ + ('a',5,1),('a',7,2),('b',15,3),('b',18,1),('c',25,2),('c',28,3)) t(g,v,y)"; + + #[test] + fn axis_titles_are_one_per_dimension() { + // Axis titles are outer chrome: exactly one per dimension for the whole + // figure, however many panels there are and whether or not a dimension + // is free (a free dimension draws its rail on every panel, but still + // gets a single centred title). + let expected = vec![ + (AxisSide::Bottom, "v".to_string()), + (AxisSide::Left, "y".to_string()), + ]; + for facet in [ + "", + "FACET g", + "FACET g SETTING free => ('x', 'y')", + "FACET g BY y", + ] { + let query = format!("{FACET_DATA} VISUALISE v AS x, y AS y DRAW point {facet}"); + assert_eq!(axis_titles(&query), expected, "{facet}"); + } + } + + #[test] + fn axis_titles_follow_labels() { + assert_eq!( + axis_titles(&format!( + "{FACET_DATA} VISUALISE v AS x, y AS y DRAW point FACET g \ + LABEL x => 'Value', y => 'Count'" + )), + vec![ + (AxisSide::Bottom, "Value".to_string()), + (AxisSide::Left, "Count".to_string()), + ] + ); + } + + #[test] + fn axis_titles_skip_untitled_axes() { + // A polar coord has no Cartesian rails to title, and a synthetic dummy + // position scale has no axis at all. + assert!(axis_titles(&format!( + "{FACET_DATA} VISUALISE v AS y, g AS fill DRAW bar PROJECT x, y TO polar" + )) + .is_empty()); + assert_eq!( + axis_titles(&format!("{FACET_DATA} VISUALISE v AS y DRAW bar")), + vec![(AxisSide::Left, "v".to_string())] + ); + } + + #[test] + fn facet_strips_rename_discrete() { + assert_eq!( + top_strips(&format!( + "{FACET_DATA} VISUALISE v AS x, y AS y DRAW point FACET g \ + SCALE panel RENAMING 'a' => 'Alpha'" + )), + vec!["Alpha", "b", "c"] + ); + } + + #[test] + fn facet_strips_suppress_discrete() { + // A suppressed label leaves an empty strip, keeping panel heights aligned. + assert_eq!( + top_strips(&format!( + "{FACET_DATA} VISUALISE v AS x, y AS y DRAW point FACET g \ + SCALE panel RENAMING 'b' => NULL" + )), + vec!["a", "", "c"] + ); + } + + #[test] + fn facet_strips_null_level() { + // A NULL facet level keys as "null" and is renamable under that key. + let data = "SELECT g, v FROM (VALUES ('a',1),(NULL,2)) t(g,v)"; + assert_eq!( + top_strips(&format!( + "{data} VISUALISE v AS x, v AS y DRAW point FACET g \ + SCALE panel FROM ('a', null)" + )), + vec!["a", "null"] + ); + assert_eq!( + top_strips(&format!( + "{data} VISUALISE v AS x, v AS y DRAW point FACET g \ + SCALE panel FROM ('a', null) RENAMING null => 'The rest'" + )), + vec!["a", "The rest"] + ); + } + + #[test] + fn facet_strips_binned_ranges() { + // A numeric facet is binned; strips show the bin range, not the midpoint. + assert_eq!( + top_strips(&format!( + "{FACET_DATA} VISUALISE v AS x, y AS y DRAW point FACET v \ + SCALE panel SETTING breaks => (0, 10, 20, 30)" + )), + vec!["0 – 10", "10 – 20", "20 – 30"] + ); + } + + #[test] + fn facet_strips_binned_squish() { + // `oob => 'squish'` opens the terminal bins: "< upper" / "≥ lower". + // Two breaks-interior bins here, both terminal — matches the Vega-Lite + // writer's labelExpr for the same query. + assert_eq!( + top_strips(&format!( + "{FACET_DATA} VISUALISE v AS x, y AS y DRAW point FACET v \ + SCALE panel SETTING breaks => (10, 20, 30), oob => 'squish'" + )), + vec!["< 20", "≥ 20"] + ); + } + + #[test] + fn facet_strips_binned_closed_right() { + // `closed => 'right'` flips the open-ended terminal symbols. + assert_eq!( + top_strips(&format!( + "{FACET_DATA} VISUALISE v AS x, y AS y DRAW point FACET v \ + SCALE panel SETTING breaks => (10, 20, 30), oob => 'squish', \ + closed => 'right'" + )), + vec!["≤ 20", "> 20"] + ); + } + + #[test] + fn facet_strips_binned_edge_renaming() { + // RENAMING applies per break edge, before the range label is built. + assert_eq!( + top_strips(&format!( + "{FACET_DATA} VISUALISE v AS x, y AS y DRAW point FACET v \ + SCALE panel SETTING breaks => (0, 10, 20, 30) RENAMING 20 => 'twenty'" + )), + vec!["0 – 10", "10 – twenty", "twenty – 30"] + ); + } + + #[test] + fn facet_strips_binned_reverse() { + assert_eq!( + top_strips(&format!( + "{FACET_DATA} VISUALISE v AS x, y AS y DRAW point FACET v \ + SCALE panel SETTING breaks => (0, 10, 20, 30), reverse => true" + )), + vec!["20 – 30", "10 – 20", "0 – 10"] + ); + } + + #[test] + fn facet_strips_binned_temporal() { + // Temporal binned facets label as date ranges. Vega-Lite silently fails + // this case (its midpoint-string comparison never matches); computing the + // label from typed values here avoids that whole class of bug. + let data = "SELECT CAST(d AS DATE) AS d, v FROM (VALUES \ + ('1973-05-04', 1), ('1973-05-20', 2), ('1973-06-08', 3)) t(d, v)"; + assert_eq!( + top_strips(&format!( + "{data} VISUALISE v AS x, v AS y DRAW point FACET d \ + SCALE panel SETTING breaks => 'month'" + )), + vec!["1973-05-01 – 1973-06-01", "1973-06-01 – 1973-07-01"] + ); + } + + #[test] + fn facet_strips_null_and_empty_are_separate_panels() { + // `column_to_strings` renders both a NULL and an empty category as "", + // so they need the null flag to stay apart — the Vega-Lite writer gives + // them a panel each. + let data = "SELECT g, v FROM (VALUES ('', 1), (NULL, 2), ('a', 3)) t(g, v)"; + assert_eq!( + top_strips(&format!( + "{data} VISUALISE v AS x, v AS y DRAW point FACET g" + )), + vec!["", "a", "null"] + ); + } + + #[test] + fn facet_over_empty_data_is_one_panel() { + // No levels to lay out: both layouts collapse to the unfaceted single + // panel rather than building a grid of zero cells. + let empty = "SELECT g, h, v FROM (VALUES ('a','b',1)) t(g,h,v) WHERE false"; + for query in [ + format!("{empty} VISUALISE v AS x, v AS y DRAW point FACET g"), + format!("{empty} VISUALISE v AS x, v AS y DRAW point FACET g BY h"), + ] { + assert_eq!(strips(&query), vec![(None, None)], "for: {query}"); + } + } + + #[test] + fn facet_strips_grid_row_column() { + // Grid: renamed column labels on the top row only, renamed row labels on + // the right column only. + let data = "SELECT r, c, v FROM (VALUES \ + ('r1','c1',1),('r1','c2',2),('r2','c1',3),('r2','c2',4)) t(r,c,v)"; + assert_eq!( + strips(&format!( + "{data} VISUALISE v AS x, v AS y DRAW point FACET r BY c \ + SCALE row RENAMING 'r1' => 'Row one' \ + SCALE column RENAMING 'c2' => 'Col two'" + )), + vec![ + (Some("c1".into()), None), + (Some("Col two".into()), Some("Row one".into())), + (None, None), + (None, Some("r2".into())), + ] + ); + } + + #[test] + fn renders_binned_facet() { + assert_png_or_skip(render(&format!( + "{FACET_DATA} VISUALISE v AS x, y AS y DRAW point FACET v \ + SCALE panel SETTING breaks => (0, 10, 20, 30)" + ))); + } + + #[test] + fn renders_free_binned_facet() { + // A free binned position dimension: each panel keeps ggsql's global bin + // edges but shows only the bins its own data occupies. + assert_png_or_skip(render( + "VISUALISE body_mass AS x FROM ggsql:penguins DRAW bar \ + SCALE BINNED x SETTING breaks => (2500, 3500, 4500, 5500, 6500) \ + FACET species SETTING free => 'x'", + )); + } + + #[test] + fn renders_binned_size_legend() { + // A binned *keyed* legend: one key per bin, sized at the bin's midpoint, + // with ggsql's edge labels on the rail between keys. + assert_png_or_skip(render( + "VISUALISE bill_len AS x, bill_dep AS y, body_mass AS size \ + FROM ggsql:penguins DRAW point \ + SCALE BINNED size SETTING breaks => (2500, 3500, 4500, 5500, 6500)", + )); + } + + #[test] + fn renders_binned_color_legend() { + // The same ladder driving color: a stepped colorbar, one block per bin. + assert_png_or_skip(render( + "VISUALISE bill_len AS x, bill_dep AS y, body_mass AS color \ + FROM ggsql:penguins DRAW point \ + SCALE BINNED color SETTING breaks => (2500, 3500, 4500, 5500, 6500)", + )); + } + + #[test] + fn renders_boxplot_linewidth() { + // `linewidth` thickens box, whiskers and median alike (VL puts + // strokeWidth in the boxplot's shared encoding). + assert_png_or_skip(render( + "SELECT g, v FROM (VALUES ('a',1),('a',2),('a',3),('a',9),\ + ('b',2),('b',3),('b',4),('b',5)) t(g,v) \ + VISUALISE g AS x, v AS y DRAW boxplot SETTING linewidth => 3", + )); + } + + #[test] + fn renders_boxplot_dashed() { + assert_png_or_skip(render( + "SELECT g, v FROM (VALUES ('a',1),('a',2),('a',3),('b',2),('b',3),('b',5)) t(g,v) \ + VISUALISE g AS x, v AS y DRAW boxplot \ + SETTING linetype => 'dashed', linewidth => 2", + )); + } + + #[test] + fn renders_boxplot_hinge() { + // `hinge` caps the whiskers with a fixed-size (pt) tick at each fence. + assert_png_or_skip(render( + "SELECT g, v FROM (VALUES ('a',1),('a',2),('a',3),('a',9),\ + ('b',2),('b',3),('b',4),('b',5)) t(g,v) \ + VISUALISE g AS x, v AS y DRAW boxplot SETTING hinge => 20", + )); + } + + #[test] + fn renders_boxplot_side() { + // `side` halves the box, median and caps onto one side of the band, + // leaving whiskers and outliers on the centreline. + assert_png_or_skip(render( + "SELECT g, v FROM (VALUES ('a',1),('a',2),('a',3),('a',9),\ + ('b',2),('b',3),('b',4),('b',5)) t(g,v) \ + VISUALISE g AS x, v AS y DRAW boxplot \ + SETTING side => 'right', hinge => 20", + )); + } + + #[test] + fn renders_transposed_boxplot() { + // A horizontal boxplot: ggsql flips the position columns, so the + // categories are on `pos2` and the summary values in the `pos1` family. + assert_png_or_skip(render( + "SELECT g, v FROM (VALUES ('a',1),('a',2),('a',3),('a',9),\ + ('b',2),('b',3),('b',4),('b',5)) t(g,v) \ + VISUALISE v AS x, g AS y DRAW boxplot SETTING hinge => 15", + )); + } + + #[test] + fn renders_half_violin_with_half_boxplot() { + // Opposite `side` values pair the two composites on one band, the + // documented raincloud-style layout (transposed, so top/bottom). + assert_png_or_skip(render( + "SELECT g, v FROM (VALUES ('a',1),('a',2),('a',2),('a',3),('a',4),\ + ('b',2),('b',3),('b',3),('b',4),('b',6)) t(g,v) \ + VISUALISE v AS x, g AS y \ + DRAW violin SETTING side => 'top' \ + DRAW boxplot SETTING side => 'bottom', width => 0.3", + )); + } + + #[test] + fn renders_jittered_points() { + // `position => 'jitter'` spreads the points across their category band; + // `side` (folded into the offsets by ggsql) keeps them on one half. + assert_png_or_skip(render( + "VISUALISE species AS x, bill_len AS y FROM ggsql:penguins DRAW point \ + SETTING position => 'jitter'", + )); + assert_png_or_skip(render( + "VISUALISE species AS x, bill_len AS y FROM ggsql:penguins DRAW point \ + SETTING position => 'jitter', side => 'right'", + )); + } + + #[test] + fn renders_dodged_points() { + // Dodge on a geom that doesn't derive its own band edges: the offsets + // reach the point's band channel. + assert_png_or_skip(render( + "SELECT x, g, v FROM (VALUES ('a','p',3),('a','q',5),('b','p',2),('b','q',4)) \ + t(x,g,v) \ + VISUALISE x AS x, v AS y, g AS color DRAW point SETTING position => 'dodge'", + )); + } + + #[test] + fn renders_dodged_range_with_hinges() { + // A dodged interval and its end caps share one offset, so they stay + // aligned in the dodge slot. + assert_png_or_skip(render( + "SELECT g, s, lo, hi FROM (VALUES ('a','p',1,5),('a','q',2,6),('b','p',2,7)) \ + t(g,s,lo,hi) \ + VISUALISE g AS x, lo AS ymin, hi AS ymax, s AS stroke DRAW range \ + SETTING position => 'dodge'", + )); + } + + #[test] + fn renders_jitter_with_half_boxplot() { + // The documented raincloud layout: a one-sided jitter above the + // centreline, a half-boxplot below it. + assert_png_or_skip(render( + "VISUALISE bill_len AS x, species AS y FROM ggsql:penguins \ + DRAW point SETTING position => 'jitter', side => 'top', width => 0.4 \ + DRAW boxplot SETTING side => 'bottom', width => 0.4", + )); + } + + #[test] + fn renders_range_hinges() { + // A range carries 10pt end caps by default; `hinge => null` drops them. + assert_png_or_skip(render( + "SELECT g, lo, hi FROM (VALUES ('a',1,5),('b',2,7)) t(g,lo,hi) \ + VISUALISE g AS x, lo AS ymin, hi AS ymax DRAW range", + )); + assert_png_or_skip(render( + "SELECT g, lo, hi FROM (VALUES ('a',1,5),('b',2,7)) t(g,lo,hi) \ + VISUALISE g AS y, lo AS xmin, hi AS xmax DRAW range \ + SETTING hinge => 40", + )); + assert_png_or_skip(render( + "SELECT g, lo, hi FROM (VALUES ('a',1,5),('b',2,7)) t(g,lo,hi) \ + VISUALISE g AS x, lo AS ymin, hi AS ymax DRAW range \ + SETTING hinge => null", + )); + } + + #[test] + fn renders_violin_linewidth() { + assert_png_or_skip(render( + "SELECT g, v FROM (VALUES ('a',1),('a',2),('a',2),('a',3),('a',4),\ + ('b',2),('b',3),('b',3),('b',4),('b',6)) t(g,v) \ + VISUALISE g AS x, v AS y DRAW violin \ + SETTING linewidth => 3, linetype => 'dashed'", + )); + } + + #[test] + fn renders_dodged_violin() { + // Two fill groups per category: each must be its own contour (keyed on the + // category *and* the partition columns), not one merged blob. + assert_png_or_skip(render( + "SELECT g, f, v FROM (VALUES ('a','x',1),('a','x',2),('a','x',3),\ + ('a','y',5),('a','y',6),('a','y',7),\ + ('b','x',2),('b','x',3),('b','x',4),('b','y',6),('b','y',7),('b','y',8)) t(g,f,v) \ + VISUALISE g AS x, v AS y, f AS fill DRAW violin", + )); + } + + #[test] + fn renders_text_stroke() { + // A constant `stroke` outlines the glyphs; white-on-dark legibility. + assert_png_or_skip(render( + "SELECT 1 AS x, 2 AS y, 'peak' AS lbl UNION ALL SELECT 2, 3, 'trough' \ + VISUALISE x AS x, y AS y, lbl AS label DRAW text \ + SETTING fontsize => 28, fontweight => 'bold', color => 'black', \ + stroke => 'white'", + )); + } + + #[test] + fn renders_text_stroke_by_group() { + // A data-mapped outline color: one scale + legend, per-row outline. + assert_png_or_skip(render( + "SELECT 1 AS x, 2 AS y, 'a' AS lbl, 'one' AS g \ + UNION ALL SELECT 2, 3, 'b', 'two' \ + VISUALISE x AS x, y AS y, lbl AS label, g AS stroke DRAW text \ + SETTING fontsize => 30, fontweight => 'bold'", + )); + } + + #[test] + fn renders_titled_plot() { + // Title, subtitle and caption all sit on the composition, above/below the + // single panel. + assert_png_or_skip(render( + "SELECT 1 AS x, 2 AS y UNION ALL SELECT 2, 3 UNION ALL SELECT 3, 1 \ + VISUALISE x AS x, y AS y DRAW point \ + LABEL title => 'Sales by Region', subtitle => 'FY 2024', \ + caption => 'Source: internal'", + )); + } + + #[test] + fn renders_suppressed_title() { + // `LABEL title => NULL` suppresses; the subtitle still renders. + assert_png_or_skip(render( + "SELECT 1 AS x, 2 AS y UNION ALL SELECT 2, 3 \ + VISUALISE x AS x, y AS y DRAW point \ + LABEL title => NULL, subtitle => 'no title above me'", + )); + } + + #[test] + fn renders_titled_facet() { + // One composition-spanning title over the whole 3-panel strip, not one + // title per panel. + assert_png_or_skip(render( + "SELECT x, y, g FROM (VALUES (1,1,'a'),(2,2,'a'),(1,2,'b'),(2,3,'b'),\ + (1,3,'c'),(2,1,'c')) t(x,y,g) \ + VISUALISE x AS x, y AS y DRAW point FACET g \ + LABEL title => 'One title for all panels'", + )); + } + + #[test] + fn map_range_pads_like_vegalite() { + // 10% of the span, split evenly around the centre — the same framing + // Vega-Lite's projection fit produces from `span * 1.1`. + let r = map_range(0.0, 10.0); + assert_eq!(*r.start(), -0.5); + assert_eq!(*r.end(), 10.5); + assert_eq!((r.end() - r.start()) / 10.0, 1.1); + } + + #[test] + fn map_range_widens_a_degenerate_extent() { + // A single point has no span to pad, so it is widened to a mappable one. + let r = map_range(3.0, 3.0); + assert_eq!(*r.start(), 2.5); + assert_eq!(*r.end(), 3.5); + } + + #[test] + fn rejects_unsupported_geom() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let spec = reader + .execute( + "SELECT 0 AS x, 0 AS y, 1 AS xend, 1 AS yend \ + VISUALISE x AS x, y AS y, xend AS xend, yend AS yend DRAW arrow", + ) + .unwrap(); + let writer = PngWriter::new(320, 240, 96.0); + assert!(matches!( + writer.validate(spec.plot()), + Err(GgsqlError::WriterError(_)) + )); + } +} diff --git a/src/writer/hephaestus/projection.rs b/src/writer/hephaestus/projection.rs new file mode 100644 index 000000000..839d9a5b1 --- /dev/null +++ b/src/writer/hephaestus/projection.rs @@ -0,0 +1,219 @@ +//! Apply a ggsql `PROJECT` clause (coordinate system) to the hephaestus plot, +//! including the coord-appropriate axes. Cartesian (the default when there is no +//! `PROJECT`) gets bottom/left rails; polar gets angular + radial rings. + +use hephaestus::plot::chrome::axis::{Axis, AxisPlacement, PolarRing}; +use hephaestus::plot::projection::{CustomProjection, PolarProjection, Projection as HProj}; +use hephaestus::plot::AspectMode; +use hephaestus::plot::Plot as HPlot; +use hephaestus::scales::chrome::AxisSide; + +use super::channels::{wkt_to_lines, wkt_to_outline}; +use super::facet::{Panel, PanelScales}; +use super::wiring::aesthetic_label; +use crate::plot::projection::{coord::CoordKind, Projection}; +use crate::plot::ParameterValue; +use crate::Plot; + +/// Apply the plot's coordinate system to one panel. No `PROJECT` clause is +/// treated as Cartesian. +pub fn apply_projection(plot: HPlot, spec: &Plot, panel: &Panel, ps: &PanelScales) -> HPlot { + match spec.project.as_ref().map(|p| p.coord.coord_kind()) { + None | Some(CoordKind::Cartesian) => { + apply_proj_cartesian(plot, spec.project.as_ref(), spec, panel, ps) + } + Some(CoordKind::Polar) => apply_proj_polar(plot, spec.project.as_ref().unwrap(), spec, ps), + Some(CoordKind::Map) => apply_proj_map(plot, spec.project.as_ref().unwrap()), + } +} + +fn apply_proj_cartesian( + mut plot: HPlot, + proj: Option<&Projection>, + spec: &Plot, + panel: &Panel, + ps: &PanelScales, +) -> HPlot { + if let Some(proj) = proj { + if let Some(ParameterValue::Boolean(false)) = proj.properties.get("clip") { + plot = plot.clip(false); + } + // The two conventions are inverses: ggsql's `ratio` is ggplot2's + // `coord_fixed` (vertical step : horizontal step), while hephaestus's + // `aspect_ratio` is the screen space one x unit takes per y unit. ggsql's + // parameter constraint keeps it > 0. + if let Some(ParameterValue::Number(ratio)) = proj.properties.get("ratio") { + plot = plot + .aspect_ratio(1.0 / *ratio) + .aspect_mode(AspectMode::Range); + } + } + // Edge-only axes for fixed scales (ggplot2 look): x on the bottom-most panel + // of each column, y on the left column. A free dimension has a per-panel + // domain, so its axis is drawn on every panel. + if panel.last_row || ps.free_x { + add_cartesian_axis(&mut plot, spec, "pos1", &ps.pos1, AxisSide::Bottom); + } + if panel.first_col || ps.free_y { + add_cartesian_axis(&mut plot, spec, "pos2", &ps.pos2, AxisSide::Left); + } + plot +} + +/// Whether a position scale warrants an axis. A missing scale, or a synthetic +/// single-category dummy (`__ggsql_stat_dummy` — e.g. a bar with no x mapped, or +/// a pie's radius), gets none; drawing it would expose the internal placeholder. +/// Mirrors the Vega-Lite writer's `AxisInfo::suppress`. +fn has_real_axis(spec: &Plot, name: &str) -> bool { + spec.find_scale(name).is_some_and(|s| !s.is_dummy()) +} + +/// Add one bottom/left rail bound to `scale_name`. Skipped for absent or dummy +/// scales. `aesthetic` is the ggsql position name (`pos1`/`pos2`); `scale_name` +/// is the registered scale the rail reads (they differ only for a free per-panel +/// scale). The rail carries no title — that belongs to the composition, see +/// [`composition_axis_titles`]. +fn add_cartesian_axis( + plot: &mut HPlot, + spec: &Plot, + aesthetic: &str, + scale_name: &str, + side: AxisSide, +) { + if !has_real_axis(spec, aesthetic) { + return; + } + plot.add_axis(Axis::rail(scale_name, AxisPlacement::Cartesian(side))); +} + +/// The figure's axis titles, as `(side, text)` pairs for the **composition**. +/// +/// Axis titles live in the outer chrome — one centred title per dimension for +/// the whole figure — rather than on each panel's rail: a faceted plot would +/// otherwise title every row and column, and a free dimension (whose axis is +/// drawn on every panel) would repeat the title inside the grid. That also +/// matches the plot-level labels, which sit on the composition for the same +/// reason. Only Cartesian coords carry them: polar rails are untitled and a map +/// has no rails at all. +pub fn composition_axis_titles(spec: &Plot) -> Vec<(AxisSide, String)> { + match spec.project.as_ref().map(|p| p.coord.coord_kind()) { + None | Some(CoordKind::Cartesian) => {} + _ => return Vec::new(), + } + let Some(layer) = spec.layers.first() else { + return Vec::new(); + }; + [(AxisSide::Bottom, "pos1"), (AxisSide::Left, "pos2")] + .into_iter() + .filter(|(_, aesthetic)| has_real_axis(spec, aesthetic)) + .filter_map(|(side, aesthetic)| { + aesthetic_label(spec, layer, aesthetic).map(|title| (side, title)) + }) + .collect() +} + +fn apply_proj_polar(mut plot: HPlot, proj: &Projection, spec: &Plot, ps: &PanelScales) -> HPlot { + plot.clear_axes(); + if let Some(ParameterValue::Boolean(false)) = proj.properties.get("clip") { + plot = plot.clip(false); + } + // Sweep angles in degrees, clockwise from 12 o'clock (ggplot2 / Vega-Lite + // pie convention). `start` defaults to 0 (12 o'clock); `end` defaults to a + // full turn past `start`, so setting only `start` rotates a full circle + // rather than truncating it (matches the VL writer's `start + 360`). + // A categorical angle makes a radar rather than a pie: ggsql resolves that + // and records it as `properties["radar"]` (the Vega-Lite writer reads the + // same flag). `PolarProjection::radar` differs from `full_circle` in two + // ways — `Chord` edges, so a polyline bends at each category boundary + // instead of arcing between them, and `theta_break_fracs` at the band + // centres `(i + 0.5) / N`, which is exactly where `Scale::map` puts a + // discrete scale's categories, so spokes, grid polygons and data line up. + let categories = matches!( + proj.properties.get("radar"), + Some(ParameterValue::Boolean(true)) + ) + .then(|| spec.find_scale("pos2").and_then(|s| s.input_range.as_ref())) + .flatten() + .map(|range| range.len()); + let base = match categories { + Some(n) => PolarProjection::radar(n), + None => PolarProjection::full_circle(), + }; + let num = |k| match proj.properties.get(k) { + Some(ParameterValue::Number(n)) => Some(*n), + _ => None, + }; + let start_deg = num("start").unwrap_or(0.0); + let end_deg = num("end").unwrap_or(start_deg + 360.0); + let deg = |d: f64| base.theta_start() - d * std::f64::consts::PI / 180.0; + let start = deg(start_deg); + let end = deg(end_deg); + let inner = num("inner").unwrap_or(0.0); + // ggsql assigns pos1→radius, pos2→theta (as the Vega-Lite writer does), so a + // value on `y` (pos2) drives the slice angle and `x` (pos1) the radius. + plot = plot.projection(HProj::Polar( + base.channels("y", "x") + .theta_range(start, end) + .inner_radius(inner), + )); + // Suppress an axis whose position scale is a synthetic dummy (e.g. a pie's + // radius), same as the Cartesian path. + if has_real_axis(spec, "pos2") { + plot.add_axis(Axis::rail( + ps.pos2.as_str(), + AxisPlacement::PolarAngular(PolarRing::Outer), + )); + } + if has_real_axis(spec, "pos1") { + // The radial rail runs along the spoke at the *start* of the sweep. + // `theta_frac` is a 0–1 fraction of the sweep, not an angle — the sweep's + // own start is 0.0 whatever `theta_start` works out to be. + plot.add_axis(Axis::rail( + ps.pos1.as_str(), + AxisPlacement::PolarRadius { theta_frac: 0.0 }, + )); + } + plot +} + +/// Map projection. Coordinates arrive **pre-projected from SQL**, so hephaestus +/// performs no reprojection: a `Custom` projection uses the projected clip +/// boundary as its drawing surface (clip + background) and the projected +/// graticule lines as its grid. Position scales are the bbox-framed `pos1`/`pos2` +/// registered in `PngWriter::write`. Mirrors the Vega-Lite writer's +/// identity `MapProjection` (`panel_boundary` + `graticule_*` from `computed`). +fn apply_proj_map(mut plot: HPlot, proj: &Projection) -> HPlot { + // A map has no Cartesian rails; the boundary + graticules are the chrome. + plot.clear_axes(); + + let computed_str = |key: &str| match proj.computed.get(key) { + Some(ParameterValue::String(s)) => Some(s.as_str()), + _ => None, + }; + + // The projected clip boundary becomes the Custom projection's outline (a full + // MultiPolygon — every part with its holes); when absent (a map with no + // CRS/clip), fall back to the default Cartesian identity over the bbox-framed + // scales. + let outline = computed_str("panel_boundary") + .map(wkt_to_outline) + .unwrap_or_default(); + if !outline.is_empty() { + let mut custom = CustomProjection::new(outline); + if let Some(lon) = computed_str("graticule_lon") { + custom = custom.x_major(wkt_to_lines(lon)); + } + if let Some(lat) = computed_str("graticule_lat") { + custom = custom.y_major(wkt_to_lines(lat)); + } + // `clip` applies to every coord kind, as it does in the Vega-Lite writer + // (`apply_clip_to_layers`): the boundary is still the drawing surface + // when clipping is off, marks outside it just aren't cut away. + let clip = !matches!( + proj.properties.get("clip"), + Some(ParameterValue::Boolean(false)) + ); + plot = plot.projection(HProj::Custom(custom)).clip(clip); + } + plot +} diff --git a/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs new file mode 100644 index 000000000..0eefec655 --- /dev/null +++ b/src/writer/hephaestus/scales.rs @@ -0,0 +1,920 @@ +//! Translating resolved ggsql scales into hephaestus scales. +//! +//! ggsql resolves the scale configuration (type, domain, transform, breaks, +//! formatted labels, and — for material aesthetics — a concrete output range); +//! we build the matching hephaestus `Scale`, which performs the value→output +//! mapping at draw time. Palettes are already resolved to concrete values by +//! ggsql's execution stage, so the output range is always an explicit `Array`. + +use std::sync::Arc; + +use hephaestus::color::{rgba, Color}; +use hephaestus::plot::geom::linetype::{dash, gap, pattern, solid}; +use hephaestus::plot::scale::{self, Scale as HScale, TransformKind as HTransform}; +use hephaestus::scales::value::{ + Date as HDate, DateTime as HDateTime, LinetypeStep, Time as HTime, Value as HValue, +}; +use hephaestus::scales::Direction; + +use super::channels::{column_to_channel, column_to_f64, ChannelData, NULL_CATEGORY}; +use crate::naming; +use crate::plot::aesthetic::POSITION_SUFFIXES; +use crate::plot::scale::{linetype_to_stroke_dash, TransformKind as GTransform}; +use crate::plot::{ArrayElement, OutputRange, ParameterValue, Scale as GScale, ScaleTypeKind}; +use crate::DataFrame; + +/// What kind of visual output a scale's range produces. Selects how a resolved +/// `OutputRange::Array` is mapped onto a hephaestus range — and, for the values +/// a scale never touches, how a literal or an identity column converts into a +/// hephaestus value (`wiring::set_literal_channel` / `constant_material`). +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub enum RangeKind { + /// Position scale — no output range; maps to a `[0, 1]` panel fraction. + Position, + /// Color-family aesthetic (fill / stroke): hex/name strings → `Color`. + Color, + /// Numeric aesthetic (size / linewidth / opacity): numbers passed through. + Number, + /// Marker shape: names resolved against the plot's `ShapeRegistry`. + Shape, + /// Line dash pattern: names → builtin linetype patterns. + Linetype, + /// Free-form string aesthetic (a font family): names passed through, to be + /// resolved by whatever consumes them. + Text, + /// Boolean aesthetic (italic): flags passed through. A boolean has no + /// meaningful output range, so only the literal / identity paths use it. + Bool, + /// CSS font weight: keyword (`bold`) or numeric string → `100..=900`. + FontWeight, + /// Rotation in degrees, as ggsql resolves it → radians, hephaestus's unit. + Angle, +} + +/// Build a hephaestus scale from a resolved ggsql scale. `None` when ggsql +/// resolved no scale type, so there is nothing to register. +pub fn build_scale(scale: &GScale, kind: RangeKind) -> Option { + // No resolved scale type → no scale to register. ggsql is the source of scale + // truth; the writer never fabricates one. + let type_kind = scale.scale_type.as_ref().map(|st| st.scale_type_kind())?; + let transform = scale.transform.as_ref().map(|t| t.transform_kind()); + + let mut hs = match type_kind { + ScaleTypeKind::Discrete => scale::discrete(domain_values(Some(scale))), + ScaleTypeKind::Ordinal => scale::ordinal(domain_values(Some(scale))), + ScaleTypeKind::Identity => scale::identity(), + ScaleTypeKind::Binned => { + let h_transform = transform.and_then(map_transform); + let (min, max) = continuous_domain(Some(scale)); + // A binned scale needs at least two edges to have a bin at all; + // resolution normally supplies them, and the domain's own ends are + // the only honest stand-in when it hasn't. + let breaks = Some(scale.numeric_breaks()) + .filter(|edges| edges.len() >= 2) + .unwrap_or_else(|| vec![min, max]); + let mut c = scale::binned(min..=max, breaks); + if let Some(t) = h_transform { + c = c.with_transform(t); + } + c + } + ScaleTypeKind::Continuous => { + let (min, max) = continuous_domain(Some(scale)); + // A temporal channel becomes a calendar-aware scale, so the ticks + // hephaestus generates for itself (and their labels) are dates + // rather than epoch numbers. ggsql's own breaks still win where it + // resolved them — see `apply_breaks`. + match temporal_scale(transform, min, max) { + Some(t) => t, + None => { + let mut c = scale::continuous(min..=max); + if let Some(t) = transform.and_then(map_transform) { + c = c.with_transform(t); + } + c + } + } + } + }; + + // `SETTING reverse => true` is a property ggsql resolves but does not apply, + // leaving each writer to flip its own scale — the Vega-Lite writer emits VL's + // `scale.reverse`, and this is hephaestus's equivalent. Reversal is a property + // of the *mapping*, not of the domain, so one flag covers every scale kind and + // both roles: a position axis runs backwards and a material scale walks its + // palette from the far end, while the domain (and therefore the breaks, the + // bin edges and the order a legend lists its keys in) stays as ggsql resolved + // it. That is also what VL's `reverse` means — it flips the range, not the + // domain — so the two writers order a reversed legend the same way. + if is_reversed(Some(scale)) { + hs = hs.with_direction(Direction::Reversed); + } + + if kind != RangeKind::Position { + if let Some(OutputRange::Array(values)) = scale.output_range.as_ref() { + hs = apply_output_range(hs, kind, values); + } + } + + // Feed ggsql's resolved breaks + formatted labels for every scale (including + // under a non-identity transform), so axis/legend ticks match ggsql — and the + // Vega-Lite writer — exactly. ggsql's breaks pair with the same resolved + // domain hephaestus reads, so they line up. `apply_breaks` is a no-op when + // the scale has no resolved breaks. + Some(apply_breaks(hs, scale, type_kind)) +} + +/// Build a per-panel position scale for a **free** facet dimension, computing +/// the domain from this panel's own data slices. +/// +/// This is a deliberate, scoped exception to ggsql owning all scale domains +/// (fixed dimensions still pass `numeric_domain()` straight through): only free +/// facet dimensions derive a per-panel domain here. Continuous dimensions take +/// the numeric extent of the position family (`pos1`, `pos1min/max/end`, …) +/// present in the slices; discrete/ordinal take the panel's distinct categories; +/// binned dimensions keep ggsql's global bin edges, narrowed to the bins the panel +/// occupies (see [`free_binned_scale`]). ggsql's resolved *continuous* breaks are +/// for the global domain and don't fit a per-panel one, so those ticks are left to +/// hephaestus. +/// +/// The *padding* around a computed extent is still ggsql's: +/// [`Scale::expand_range`](crate::plot::Scale::expand_range) applies the scale's +/// own resolved `expand` factors, so a free panel is padded exactly like a fixed +/// axis. Only the extent is derived here, never the expansion policy. +pub fn free_position_scale( + global: Option<&GScale>, + dfs: &[&DataFrame], + base: &str, +) -> Option { + let type_kind = global + .and_then(|s| s.scale_type.as_ref()) + .map(|st| st.scale_type_kind()) + .unwrap_or(ScaleTypeKind::Continuous); + let transform = global + .and_then(|s| s.transform.as_ref()) + .map(|t| t.transform_kind()); + + let hs = match type_kind { + ScaleTypeKind::Discrete | ScaleTypeKind::Ordinal => { + let vals = panel_categories(global, dfs, base); + // An empty cell has no categories to free the dimension over; `None` + // sends the panel back to the shared scale (`PanelScales::use_shared`) + // rather than registering a domainless axis. + if vals.is_empty() { + return None; + } + if matches!(type_kind, ScaleTypeKind::Ordinal) { + scale::ordinal(vals) + } else { + scale::discrete(vals) + } + } + ScaleTypeKind::Identity => scale::identity(), + ScaleTypeKind::Binned => global + .and_then(|g| free_binned_scale(g, dfs, base)) + // No usable break array → fall back to a plain continuous panel scale. + .or_else(|| free_continuous_scale(global, dfs, base, transform))?, + ScaleTypeKind::Continuous => free_continuous_scale(global, dfs, base, transform)?, + }; + + // The same flag the fixed path sets (see [`build_scale`]): freeing a + // dimension narrows its domain, it does not undo `SETTING reverse => true`. + Some(if is_reversed(global) { + hs.with_direction(Direction::Reversed) + } else { + hs + }) +} + +/// A per-panel continuous position scale over the panel's own data extent. +/// +/// A temporal dimension becomes a temporal scale, and keeps ggsql's global break +/// labels narrowed to the panel — the same treatment [`free_binned_scale`] gives +/// bin edges, and what the Vega-Lite writer does with a free temporal axis. The +/// alternative, letting hephaestus pick per-panel calendar ticks, invents breaks +/// ggsql didn't resolve and packs full ISO labels into a panel too narrow to hold +/// them (the writer does no label thinning). A panel no global break +/// falls inside keeps hephaestus's own ticks rather than a bare axis; they are +/// dates either way, because the scale carries the calendar unit. +fn free_continuous_scale( + global: Option<&GScale>, + dfs: &[&DataFrame], + base: &str, + transform: Option, +) -> Option { + let (min, max) = panel_extent(dfs, base)?; + // A panel extent is raw data, where `numeric_domain()` would already be + // expanded, so pad it with the scale's own resolved expansion — otherwise a + // free panel's marks sit hard against the panel edge while a fixed axis gets + // 5%, and `SETTING expand` silently stops applying once a dimension is freed. + let (min, max) = match global { + Some(g) => g.expand_range(min, max), + None => (min, max), + }; + let (min, max) = pad_degenerate(min, max); + // ggsql's global minors, narrowed to this panel — the same treatment its majors + // get below. Pinning these is what keeps a panel showing one major from being + // filled with hephaestus's own sub-unit minors: ggsql derives minors from the + // global major spacing, so the survivors stay on that grid. `None` (no minors + // resolved) stays None so the fallback survives; an empty list after filtering is + // a panel that genuinely contains none. + let minors: Option> = global + .and_then(|g| g.numeric_minor_breaks()) + .map(|positions| { + positions + .into_iter() + .filter(|pos| *pos >= min && *pos <= max) + .collect() + }); + if let Some(hs) = temporal_scale(transform, min, max) { + let labels: Vec<(HValue, String)> = global + .map(|g| g.break_labels()) + .unwrap_or_default() + .into_iter() + .filter(|(pos, _)| *pos >= min && *pos <= max) + .map(|(pos, label)| (temporal_value(transform, pos), label)) + .collect(); + // Leave the whole tick set automatic when no global break lands in the panel; + // pinning minors around ticks hephaestus chose itself would mix two grids. + return Some(if labels.is_empty() { + hs + } else { + apply_pinned_minors(hs.with_breaks_labeled(labels), minors.as_deref(), transform) + }); + } + let mut c = scale::continuous(min..=max); + if let Some(t) = transform.and_then(map_transform) { + c = c.with_transform(t); + } + Some(apply_pinned_minors(c, minors.as_deref(), transform)) +} + +/// Pin `minors` (ggsql positions, already narrowed to the target domain) on `hs`, +/// wrapping each as the transform's value variant. +/// +/// `None` leaves hephaestus's automatic minors in place — ggsql resolved none, so +/// there is nothing to pass through. `Some(&[])` pins an empty list, which is how +/// hephaestus is told to draw no minors at all: that is `SETTING minor_breaks => 0` +/// arriving intact rather than being mistaken for "nothing to say". +fn apply_pinned_minors( + hs: HScale, + minors: Option<&[f64]>, + transform: Option, +) -> HScale { + match minors { + Some(positions) => hs.with_minor_breaks( + positions + .iter() + .map(|pos| temporal_value(transform, *pos)) + .collect(), + ), + None => hs, + } +} + +/// A per-panel **binned** position scale: ggsql's globally resolved bin edges, +/// narrowed to the window of bins this panel's data occupies. +/// +/// The writer never invents bin boundaries — it only selects from the edges ggsql +/// resolved, and labels them with ggsql's own edge labels. Edges and domain narrow +/// together because a hephaestus binned scale derives band width from its edge +/// count as `1 / (edges - 1)`: keeping every global edge while shrinking the domain +/// would leave each bar a global bin-width wide, hanging off the panel. +/// +/// Neither `expand_range` nor pinned minors here: the band width a bar is drawn at +/// assumes the domain spans exactly the edges, so padding the domain would +/// desynchronise bar width from bin width, and a binned axis's ticks are its edges, +/// with nothing to subdivide. +fn free_binned_scale(global: &GScale, dfs: &[&DataFrame], base: &str) -> Option { + let bins = binned_bins(global); + if bins.is_empty() { + return None; + } + let (lo, hi) = panel_extent(dfs, base)?; + // The inclusive window of bins covering the panel's extent. + let first = bins.iter().rposition(|b| b.lower <= lo).unwrap_or(0); + let last = bins + .iter() + .position(|b| b.upper >= hi) + .unwrap_or(bins.len() - 1); + let (first, last) = (first.min(last), last); + let window = &bins[first..=last]; + + let mut edges = Vec::with_capacity(window.len() + 1); + edges.push(window[0].lower); + edges.extend(window.iter().map(|b| b.upper)); + let mut hs = scale::binned(window[0].lower..=window[window.len() - 1].upper, edges); + if let Some(t) = global + .transform + .as_ref() + .map(|t| t.transform_kind()) + .and_then(map_transform) + { + hs = hs.with_transform(t); + } + // ggsql's edge labels, restricted to the edges this panel's window keeps. + let labels: Vec<(HValue, String)> = global + .break_labels() + .into_iter() + .filter(|(pos, _)| *pos >= window[0].lower && *pos <= window[window.len() - 1].upper) + .map(|(pos, label)| (HValue::Number(pos), label)) + .collect(); + Some(if labels.is_empty() { + hs + } else { + hs.with_breaks_labeled(labels) + }) +} + +/// The finite numeric extent of a position family across the given slices. +fn panel_extent(dfs: &[&DataFrame], base: &str) -> Option<(f64, f64)> { + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + for df in dfs { + // The base aesthetic plus its whole position family, so a panel holding + // only extents (a bar's `pos2end`, a ribbon's `pos2min`/`max`) still + // sizes its axis — the same family `execute/scale.rs` trains a fixed + // scale over. + for suffix in std::iter::once("").chain(POSITION_SUFFIXES.iter().copied()) { + let name = naming::aesthetic_column(&format!("{base}{suffix}")); + if df.column(&name).is_ok() { + if let Ok(values) = column_to_f64(df, &name) { + for v in values.into_iter().filter(|v| v.is_finite()) { + lo = lo.min(v); + hi = hi.max(v); + } + } + } + } + } + (lo.is_finite() && hi.is_finite()).then_some((lo, hi)) +} + +/// The categories a panel occupies: ggsql's globally resolved domain, narrowed +/// to the levels these slices actually contain and left in the global order. +/// +/// Selecting from `input_range` is what keeps a free panel agreeing with a fixed +/// one — the same level order, the same [`channels::NULL_CATEGORY`] sentinel for +/// a null level, and the same value *type* [`column_to_channel`] hands over. +/// Re-deriving the domain from the column's text would break all three. The same +/// narrowing [`free_binned_scale`] does for bin edges. +fn panel_categories(global: Option<&GScale>, dfs: &[&DataFrame], base: &str) -> Vec { + let name = naming::aesthetic_column(base); + let domain = domain_values(global); + let mut present = vec![false; domain.len()]; + for df in dfs { + let Ok(data) = column_to_channel(df, &name) else { + continue; + }; + // Matched with `key_eq`, exactly as hephaestus matches data to domain at + // draw time, so a level counts as present here only if it would resolve + // there too. + for value in channel_values(data) { + if let Some(i) = domain.iter().position(|level| level.key_eq(&value)) { + present[i] = true; + } + } + } + domain + .into_iter() + .zip(present) + .filter_map(|(level, present)| present.then_some(level)) + .collect() +} + +/// A column's values as the hephaestus values a scale domain is matched against. +fn channel_values(data: ChannelData) -> Vec { + match data { + ChannelData::Strings(values) => values + .into_iter() + .map(|v| HValue::String(Arc::from(v.as_str()))) + .collect(), + ChannelData::Floats(values) => values.into_iter().map(HValue::Number).collect(), + } +} + +/// Domain for a continuous scale. ggsql's resolved `numeric_domain` is +/// authoritative — it carries ggsql's global, expanded, transform-aware training +/// over every layer and the whole position family — so pass it straight through, +/// exactly as the Vega-Lite writer uses `input_range`. +fn continuous_domain(scale: Option<&GScale>) -> (f64, f64) { + let domain = scale + .and_then(|s| s.numeric_domain()) + .filter(|(min, max)| min.is_finite() && max.is_finite()) + .unwrap_or((0.0, 1.0)); + pad_degenerate(domain.0, domain.1) +} + +/// Whether the scale carries `SETTING reverse => true`. +pub fn is_reversed(scale: Option<&GScale>) -> bool { + matches!( + scale.and_then(|s| s.properties.get("reverse")), + Some(ParameterValue::Boolean(true)) + ) +} + +/// Category domain for a discrete/ordinal scale, as hephaestus values, in the +/// order ggsql resolved. `reverse` is a direction on the scale rather than a +/// reordering here — see [`build_scale`]. +fn domain_values(scale: Option<&GScale>) -> Vec { + scale + .and_then(|s| s.input_range.as_ref()) + .map(|range| range.iter().map(category_value).collect()) + .unwrap_or_default() +} + +/// A categorical domain entry as a hephaestus value. Identical to +/// [`array_element_to_value`] except for the two levels the data side cannot +/// hand over as themselves, which both sides therefore spell as a string: a +/// null becomes [`channels::NULL_CATEGORY`], and a boolean its category name +/// (`column_to_channel` reads a boolean column as strings — see there). +fn category_value(element: &ArrayElement) -> HValue { + match element { + ArrayElement::Null => HValue::String(Arc::from(NULL_CATEGORY)), + ArrayElement::Boolean(_) => HValue::String(Arc::from(element.to_key_string().as_str())), + other => array_element_to_value(other), + } +} + +/// Attach the resolved output range to a material scale. +fn apply_output_range(hs: HScale, kind: RangeKind, values: &[ArrayElement]) -> HScale { + let values: Vec<&ArrayElement> = values.iter().collect(); + match kind { + RangeKind::Color => hs.range_colors(values.into_iter().filter_map(array_element_to_color)), + RangeKind::Number => hs.range_numbers(values.into_iter().filter_map(|e| e.to_f64())), + RangeKind::Shape | RangeKind::Text => hs.range_strings( + values + .into_iter() + .map(|e| Arc::from(e.to_key_string().as_str())), + ), + RangeKind::Linetype => { + hs.range_linetypes(values.into_iter().map(|e| map_linetype(&e.to_key_string()))) + } + // hephaestus takes a font weight as a number and an angle in radians, so + // the range converts exactly as a literal on the same channel does. + RangeKind::FontWeight => hs.range_numbers( + values + .into_iter() + .map(|e| parse_font_weight(&e.to_key_string())), + ), + RangeKind::Angle => hs.range_numbers( + values + .into_iter() + .filter_map(|e| e.to_f64()) + .map(f64::to_radians), + ), + // Neither a position nor a boolean has an output range: the former maps to + // a panel fraction, the latter is only ever a literal or identity value. + RangeKind::Position | RangeKind::Bool => hs, + } +} + +/// Map a ggsql linetype to a hephaestus dash pattern; unknown → solid. +/// +/// ggsql accepts both names (`dashed`, `twodash`, …) and ggplot2-style hex +/// patterns (`"1343"` = 1 on, 3 off, 4 on, 3 off), and resolves an *ordinal* +/// linetype scale's range entirely to hex. Both forms go through core's +/// [`linetype_to_stroke_dash`], the same parser the Vega-Lite writer uses, so +/// the two writers draw a given linetype identically — matching a name against +/// hephaestus's own builtins would silently render every hex pattern solid and +/// alias `longdash`/`twodash` onto the wrong ones. +/// +/// The resulting on/off lengths are points, which is what hephaestus's linetype +/// steps take. +pub fn map_linetype(name: &str) -> Arc<[LinetypeStep]> { + let Some(lengths) = linetype_to_stroke_dash(name) else { + return solid(); + }; + // `pattern` requires strict dash/gap alternation, so an odd-length pattern + // would panic. The parser doesn't produce one; treat it as unknown anyway. + if lengths.is_empty() || lengths.len() % 2 != 0 { + return solid(); + } + pattern(lengths.iter().enumerate().map(|(i, len)| { + if i % 2 == 0 { + dash(*len as f64) + } else { + gap(*len as f64) + } + })) +} + +/// Map a ggsql `fontweight` to hephaestus's numeric CSS weight (100–900); +/// unknown → 400. ggsql accepts either a keyword or a number, matching the +/// Vega-Lite writer's `parse_fontweight_to_numeric`. +pub fn parse_font_weight(value: &str) -> f64 { + if let Ok(n) = value.parse::() { + return n; + } + match value.to_lowercase().replace('-', "").as_str() { + "thin" | "hairline" => 100.0, + "extralight" | "ultralight" => 200.0, + "light" => 300.0, + "medium" => 500.0, + "semibold" | "demibold" => 600.0, + "bold" | "bolder" => 700.0, + "extrabold" | "ultrabold" => 800.0, + "black" | "heavy" => 900.0, + _ => 400.0, // normal / regular / unknown + } +} + +/// Feed ggsql's resolved breaks + formatted labels into the hephaestus scale so +/// axis/legend ticks match ggsql exactly (including RENAMING overrides). +/// +/// Minor breaks travel the same way, via [`apply_minor_breaks`]: break positions are +/// ggsql's to own, majors and minors alike, so nothing here invents either. +fn apply_breaks(hs: HScale, scale: &GScale, type_kind: ScaleTypeKind) -> HScale { + let hs = apply_minor_breaks(hs, scale, type_kind); + let categorical = matches!(type_kind, ScaleTypeKind::Discrete | ScaleTypeKind::Ordinal); + // A suppressed label means different things either side of this line. On a + // categorical scale it is `RENAMING => null`, i.e. hide the text but + // keep the category — dropping it would misalign the axis. On a numeric one + // it is a binned `oob => 'squish'` terminal, where the edge is not a real + // boundary and its tick and gridline must go too, exactly as the Vega-Lite + // writer filters them out of the axis. + let labels = if categorical { + scale.break_labels() + } else { + scale.visible_break_labels() + }; + if labels.is_empty() { + return hs; + } + match type_kind { + ScaleTypeKind::Discrete | ScaleTypeKind::Ordinal => { + // Pair each label with the category at its resolved position, which + // for a categorical scale is the 1-based index into `input_range`. + // Keyed by position rather than zipped, so a break set that doesn't + // cover every category can't shift every label onto the wrong one. + let Some(range) = scale.input_range.as_ref() else { + return hs; + }; + let pairs: Vec<(HValue, String)> = labels + .into_iter() + .filter_map(|(pos, label)| { + let index = (pos.round() as usize).checked_sub(1)?; + range.get(index).map(|e| (category_value(e), label)) + }) + .collect(); + hs.with_breaks_labeled(pairs) + } + // Binned scales included: their breaks are the bin **edges**, labelled by + // ggsql. Placing an edge break on a binned axis is hephaestus's job, via + // `Scale::map_break` (a break takes its own domain fraction, where a data + // value goes to its bin's centre). Composite "lower – upper" range labels + // belong to keyed legends and facet strips, not axes. + // + // A continuous temporal scale takes its breaks as temporal values, matching + // the variant its own generated breaks come back as, so hephaestus formats + // any break we don't label as a date rather than as an epoch number. + _ => { + let temporal = matches!(type_kind, ScaleTypeKind::Continuous) + .then(|| scale.transform.as_ref().map(|t| t.transform_kind())) + .flatten(); + hs.with_breaks_labeled( + labels + .into_iter() + .map(|(pos, label)| (temporal_value(temporal, pos), label)) + .collect(), + ) + } + } +} + +/// Pin ggsql's resolved minor breaks (sub-ticks / sub-gridlines) so they subdivide +/// ggsql's majors instead of being generated from the domain. Without this a sparse +/// major set — a fixed temporal axis narrowed to one break in a facet panel — gets +/// hephaestus's own sub-unit minors, which read as a dotted rail. +fn apply_minor_breaks(hs: HScale, scale: &GScale, type_kind: ScaleTypeKind) -> HScale { + // Same variant rule as the majors: a temporal scale's positions go back as + // typed temporal values, everything else as plain numbers. + let temporal = matches!(type_kind, ScaleTypeKind::Continuous) + .then(|| scale.transform.as_ref().map(|t| t.transform_kind())) + .flatten(); + apply_pinned_minors(hs, scale.numeric_minor_breaks().as_deref(), temporal) +} + +/// One bin of a resolved ggsql binned scale: its numeric edges, its centre (the +/// value a binned data column actually carries — see `Binned::pre_stat_transform_sql`), +/// and its display label. +pub struct Bin { + pub lower: f64, + pub upper: f64, + pub centre: f64, + pub label: String, +} + +/// The bins of a resolved binned scale, in the numeric form the writer needs: +/// ggsql's own [`Scale::binned_bins`](crate::plot::Scale::binned_bins) labelling +/// — shared with the Vega-Lite writer, so both name a bin the same way — plus +/// each bin's centre, which is the value a binned data column carries. +pub fn binned_bins(scale: &GScale) -> Vec { + scale + .binned_bins() + .into_iter() + .filter_map(|bin| { + let (lower, upper) = (bin.lower.to_f64()?, bin.upper.to_f64()?); + Some(Bin { + lower, + upper, + centre: (lower + upper) / 2.0, + label: bin.label, + }) + }) + .collect() +} + +/// The bin whose centre is closest to `value` — the join from a binned data cell +/// back to its bin. Nearest-centre rather than an interval test because the +/// column carries centres, which are one per bin and a bin width apart (and a +/// temporal centre is truncated to whole days/seconds on the way through SQL). +pub fn bin_at_centre(bins: &[Bin], value: f64) -> Option { + if !value.is_finite() { + return None; + } + bins.iter() + .enumerate() + .min_by(|(_, a), (_, b)| { + (a.centre - value) + .abs() + .total_cmp(&(b.centre - value).abs()) + }) + .map(|(i, _)| i) +} + +/// A hephaestus temporal scale over `min..=max`, in the unit the ggsql temporal +/// transform names: days since epoch for `Date`, microseconds since epoch for +/// `DateTime`, nanoseconds since midnight for `Time` — the same units ggsql's +/// `ArrayElement` uses, which is what a temporal column projects to f64 as. +/// `None` for any non-temporal transform. +fn temporal_scale(transform: Option, min: f64, max: f64) -> Option { + match transform? { + GTransform::Date => Some(scale::temporal( + HDate::from_days(min as i32)..=HDate::from_days(max as i32), + )), + GTransform::DateTime => Some(scale::temporal( + HDateTime::from_micros(min as i64)..=HDateTime::from_micros(max as i64), + )), + GTransform::Time => Some(scale::temporal( + HTime::from_nanos(min as i64)..=HTime::from_nanos(max as i64), + )), + _ => None, + } +} + +/// Wrap a break position as the value variant its scale works in — the temporal +/// variant under a temporal transform, a plain number otherwise. +fn temporal_value(transform: Option, pos: f64) -> HValue { + match transform { + Some(GTransform::Date) => HValue::Date(pos as i32), + Some(GTransform::DateTime) => HValue::DateTime(pos as i64), + Some(GTransform::Time) => HValue::Time(pos as i64), + _ => HValue::Number(pos), + } +} + +/// Map a ggsql transform to its hephaestus equivalent. Cast/temporal transforms +/// have no spacing effect (values arrive already projected to f64), so they map +/// to identity (`None` — hephaestus defaults to identity). +fn map_transform(kind: GTransform) -> Option { + match kind { + GTransform::Log10 => Some(HTransform::Log10), + GTransform::Log2 => Some(HTransform::Log2), + GTransform::Log => Some(HTransform::Log), + GTransform::Sqrt => Some(HTransform::Sqrt), + GTransform::Square => Some(HTransform::Square), + GTransform::Exp10 => Some(HTransform::Exp10), + GTransform::Exp2 => Some(HTransform::Exp2), + GTransform::Exp => Some(HTransform::Exp), + GTransform::Asinh => Some(HTransform::Asinh), + GTransform::PseudoLog => Some(HTransform::PseudoLog), + GTransform::Identity + | GTransform::Date + | GTransform::DateTime + | GTransform::Time + | GTransform::String + | GTransform::Bool + | GTransform::Integer => None, + } +} + +/// Convert a ggsql array element to a hephaestus domain value. +pub fn array_element_to_value(element: &ArrayElement) -> HValue { + match element { + ArrayElement::String(s) => HValue::String(Arc::from(s.as_str())), + ArrayElement::Number(n) => HValue::Number(*n), + ArrayElement::Boolean(b) => HValue::Bool(*b), + ArrayElement::Date(d) => HValue::Date(*d), + ArrayElement::DateTime(dt) => HValue::DateTime(*dt), + ArrayElement::Time(t) => HValue::Time(*t), + ArrayElement::Null => HValue::Null, + } +} + +/// Parse a color output-range element (hex or CSS name) into a hephaestus color. +fn array_element_to_color(element: &ArrayElement) -> Option { + match element { + ArrayElement::String(s) => parse_color(s), + _ => None, + } +} + +/// Parse a CSS color string (hex, name, rgb(), …) into a hephaestus color. +pub fn parse_color(value: &str) -> Option { + csscolorparser::parse(value) + .ok() + .map(|c| rgba(c.r, c.g, c.b, c.a)) +} + +/// Widen a domain that is non-finite or zero-width so a continuous scale can map +/// it without dividing by zero. +fn pad_degenerate(min: f64, max: f64) -> (f64, f64) { + if !min.is_finite() || !max.is_finite() { + return (0.0, 1.0); + } + if (max - min).abs() < f64::EPSILON { + return (min - 0.5, max + 0.5); + } + (min, max) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + /// A binned scale with the given edges, plus optional per-edge label overrides + /// and properties, as ggsql's resolution would leave it. + fn binned_scale(edges: &[f64], props: &[(&str, ParameterValue)]) -> GScale { + let mut scale = GScale::new("facet1"); + scale.scale_type = Some(crate::plot::ScaleType::binned()); + scale.properties.insert( + "breaks".to_string(), + ParameterValue::Array(edges.iter().map(|e| ArrayElement::Number(*e)).collect()), + ); + for (key, value) in props { + scale.properties.insert(key.to_string(), value.clone()); + } + // ggsql always populates `label_mapping` for a resolved scale (the default + // `{}` template applied to every edge). + let mut mapping: HashMap> = HashMap::new(); + for edge in edges { + let key = ArrayElement::Number(*edge).to_key_string(); + mapping.insert(key.clone(), Some(key)); + } + scale.label_mapping = Some(mapping); + scale + } + + fn labels(bins: &[Bin]) -> Vec<&str> { + bins.iter().map(|b| b.label.as_str()).collect() + } + + #[test] + fn binned_bins_labels_ranges() { + let bins = binned_bins(&binned_scale(&[0.0, 10.0, 20.0], &[])); + assert_eq!(labels(&bins), vec!["0 – 10", "10 – 20"]); + assert_eq!(bins[0].centre, 5.0); + assert_eq!(bins[1].centre, 15.0); + } + + #[test] + fn binned_bins_honors_edge_renaming() { + let mut scale = binned_scale(&[0.0, 10.0, 20.0], &[]); + scale + .label_mapping + .as_mut() + .unwrap() + .insert("10".to_string(), Some("ten".to_string())); + assert_eq!(labels(&binned_bins(&scale)), vec!["0 – ten", "ten – 20"]); + } + + #[test] + fn binned_bins_opens_suppressed_terminals() { + // `oob => 'squish'` suppresses the terminal edge labels. + let mut scale = binned_scale(&[0.0, 10.0, 20.0], &[]); + let mapping = scale.label_mapping.as_mut().unwrap(); + mapping.insert("0".to_string(), None); + mapping.insert("20".to_string(), None); + assert_eq!(labels(&binned_bins(&scale)), vec!["< 10", "≥ 10"]); + + scale.properties.insert( + "closed".to_string(), + ParameterValue::String("right".to_string()), + ); + assert_eq!(labels(&binned_bins(&scale)), vec!["≤ 10", "> 10"]); + } + + #[test] + fn binned_bins_empty_without_breaks() { + assert!(binned_bins(&GScale::new("facet1")).is_empty()); + assert!(binned_bins(&binned_scale(&[5.0], &[])).is_empty()); + } + + /// A resolved continuous Date scale: domain and breaks in days since epoch, + /// labelled the way ggsql's resolution leaves them (ISO keys). + fn date_scale(domain: (i32, i32), breaks: &[i32]) -> GScale { + let mut scale = GScale::new("pos1"); + scale.scale_type = Some(crate::plot::scale::ScaleType::continuous()); + scale.transform = Some(crate::plot::scale::transform::Transform::date()); + scale.input_range = Some(vec![ + ArrayElement::Date(domain.0), + ArrayElement::Date(domain.1), + ]); + scale.properties.insert( + "breaks".to_string(), + ParameterValue::Array(breaks.iter().map(|d| ArrayElement::Date(*d)).collect()), + ); + scale + } + + #[test] + fn temporal_scale_labels_ggsql_breaks_as_dates() { + let scale = date_scale((1208, 1264), &[1208, 1236, 1264]); + let hs = build_scale(&scale, RangeKind::Position).expect("scale"); + let locale = hephaestus::scales::locale::Locale::EN_US; + let labels: Vec = hs.breaks(5).iter().map(|b| hs.format(b, &locale)).collect(); + assert_eq!(labels, vec!["1973-04-23", "1973-05-21", "1973-06-18"]); + } + + #[test] + fn temporal_scale_is_calendar_aware() { + // Breaks hephaestus generates for itself come back as dates, not as the + // epoch-day numbers the domain is stored in — that is what a panel scale + // with no in-window ggsql break falls back on. + let hs = temporal_scale(Some(GTransform::Date), 1208.0, 1400.0).expect("temporal scale"); + let locale = hephaestus::scales::locale::Locale::EN_US; + for label in hs.breaks(5).iter().map(|b| hs.format(b, &locale)) { + assert!( + label.starts_with("197"), + "expected a date label, got {label}" + ); + } + assert!(temporal_scale(Some(GTransform::Log10), 1.0, 10.0).is_none()); + assert!(temporal_scale(None, 1.0, 10.0).is_none()); + } + + #[test] + fn boolean_domain_matches_the_data_side() { + // hephaestus matches data to domain by `Value` variant, so a boolean + // column's two representations have to agree: `column_to_channel` reads + // one as category strings, and the domain must spell them the same way. + let mut scale = GScale::new("fill"); + scale.scale_type = Some(crate::plot::ScaleType::discrete()); + scale.input_range = Some(vec![ + ArrayElement::Boolean(false), + ArrayElement::Boolean(true), + ]); + let domain = domain_values(Some(&scale)); + let expected = [ + HValue::String(Arc::from("false")), + HValue::String(Arc::from("true")), + ]; + assert_eq!(domain.len(), expected.len()); + for (got, want) in domain.iter().zip(&expected) { + assert!(got.key_eq(want), "expected {want:?}, got {got:?}"); + } + + let df = crate::df! { "flag" => vec![true, false] }.unwrap(); + let ChannelData::Strings(values) = column_to_channel(&df, "flag").unwrap() else { + panic!("a boolean column should arrive as category strings"); + }; + for value in values { + let value = HValue::String(Arc::from(value.as_str())); + assert!( + domain.iter().any(|level| level.key_eq(&value)), + "no domain level matches {value:?}" + ); + } + } + + #[test] + fn bin_at_centre_finds_nearest_bin() { + let bins = binned_bins(&binned_scale(&[0.0, 10.0, 20.0, 30.0], &[])); + assert_eq!(bin_at_centre(&bins, 5.0), Some(0)); + assert_eq!(bin_at_centre(&bins, 15.0), Some(1)); + assert_eq!(bin_at_centre(&bins, 25.0), Some(2)); + // Tolerant of a centre truncated on the way through SQL. + assert_eq!(bin_at_centre(&bins, 14.0), Some(1)); + // Out of range still lands on the closest bin; NaN does not. + assert_eq!(bin_at_centre(&bins, 99.0), Some(2)); + assert_eq!(bin_at_centre(&bins, f64::NAN), None); + assert_eq!(bin_at_centre(&[], 5.0), None); + } + + #[test] + fn font_weights_parse_like_vegalite() { + // Keywords, in either casing and with or without the hyphen. + assert_eq!(parse_font_weight("bold"), 700.0); + assert_eq!(parse_font_weight("Bold"), 700.0); + assert_eq!(parse_font_weight("semi-bold"), 600.0); + assert_eq!(parse_font_weight("extralight"), 200.0); + // Numbers pass through, as a string or as ggsql's own number formatting. + assert_eq!(parse_font_weight("350"), 350.0); + assert_eq!(parse_font_weight("350.0"), 350.0); + // Anything unrecognised is regular, never a missing glyph. + assert_eq!(parse_font_weight("normal"), 400.0); + assert_eq!(parse_font_weight("wingdings"), 400.0); + } +} diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs new file mode 100644 index 000000000..c10b34591 --- /dev/null +++ b/src/writer/hephaestus/wiring.rs @@ -0,0 +1,1111 @@ +//! Shared geom-wiring: position/material channels, scales, axes, legends, and +//! group keys. Each geom module declares its channel specs; these helpers do the +//! repetitive work, generic over the concrete geom builder. + +use std::collections::HashSet; + +use hephaestus::color::{rgb8, Color}; +use hephaestus::plot::chrome::legend::{Legend, LegendKeySpec}; +use hephaestus::plot::geom::{BuildableGeom, Geom, GeomBuilder, Raw}; +use hephaestus::plot::theme::{Element, Length, RectElement, Theme, DEFAULT_TEXT_LINEHEIGHT}; +use hephaestus::plot::Plot as HPlot; +use hephaestus::scales::chrome::LegendSide; +use hephaestus::scales::value::{DataColumn, Value as HValue}; +use hephaestus::text::rich::{LineHeightSpec, StyleDelta}; + +use super::channels::{ + aesthetic_column_name, build_group_keys, column_to_bool, column_to_channel, column_to_colors, + column_to_f64, column_to_strings, ChannelData, +}; +use super::scales::{map_linetype, parse_color, parse_font_weight, RangeKind}; +use crate::plot::{ParameterValue, ScaleTypeKind}; +use crate::{AestheticValue, DataFrame, GgsqlError, Layer, Plot, Result}; + +/// The chrome ggsql renders with: hephaestus's default theme with the handful of +/// deviations ggsql needs. This is the single hook for chrome — ggsql has no +/// theme concept of its own yet, so anything the two writers must agree on that +/// isn't a scale or a channel belongs here. +/// +/// Three deviations so far: +/// +/// - **A colorbar's frame.** hephaestus's `BarTheme` leaves `linewidth_pt` unset, +/// which cascades to the 1pt ink border every `RectElement` gets by default, so +/// a continuous color legend arrives boxed. Vega-Lite draws no gradient border +/// (its `gradientStrokeWidth` default is 0), and the discrete `KeyTheme` next to +/// it zeroes its own border, so the box is out of place in either comparison. +/// - **Markdown chrome.** ggsql treats chrome strings as rich text, so `**bold**` +/// and `{.red word}` render as styled text rather than literal markers. Set on +/// the root `text` element, it cascades to *every* text slot — which is how a +/// future ggsql theme concept would drive it. What actually parses is whichever +/// of those slots hephaestus consults `markdown` on: today the plot title, +/// subtitle and caption, the axis titles and the facet strip labels. Legend +/// titles and break labels cascade the flag too but shape through +/// `TextRun::new` regardless, so they still draw their markers; they start +/// parsing with no change here once hephaestus reads the flag at those sites +/// (see [Known gaps](CLAUDE.md)). +/// - **One line height for both text paths.** hephaestus's rich-text sheet gives +/// its root selector marquee's `1.6` line height, while the plain path uses the +/// theme's `1.2`. Chrome slots are one-liners whose measured box sets how much +/// room the layout reserves, so the mismatch made every axis title claim ~0.4 +/// lines more than it draws — shrinking the panel, and by a *different* amount +/// horizontally, since the y title measures rotated. Folding the theme's line +/// height onto the sheet's root brings a plain string back to nearly the layout +/// it had unparsed: ~1pt of the ~3pt it was claiming remains, which is the +/// rich block model's own box and not something a sheet entry reaches. +pub fn ggsql_theme() -> Theme { + let mut theme = Theme::default(); + theme.legend.bar.frame = Element::Set(RectElement { + // The bar's own gradient fills the interior; only the border changes. + fill: None, + linewidth_pt: Some(Length::Abs(0.0)), + ..RectElement::default() + }); + theme.text.markdown = Some(true); + let mut sheet = (*theme.rich_text).clone(); + let base = sheet.get("base").cloned().unwrap_or_default(); + sheet.set( + "base", + StyleDelta { + lineheight: Some(LineHeightSpec::Mult(DEFAULT_TEXT_LINEHEIGHT)), + ..base + }, + ); + theme.rich_text = std::sync::Arc::new(sheet); + theme +} + +/// Read-only context for building one layer's geom. +pub struct Ctx<'a> { + pub spec: &'a Plot, + pub layer: &'a Layer, + pub df: &'a DataFrame, + /// Whether the layer is in transposed (horizontal) orientation. + pub transposed: bool, + /// Scale name this panel binds `pos1` (x) to: the shared `"pos1"` when fixed, + /// a per-panel name when the facet dimension is free. + pub pos1_scale: &'a str, + /// Scale name this panel binds `pos2` (y) to. + pub pos2_scale: &'a str, + /// Sink collecting the legends a geom would draw. Legends are registered once + /// on the composition (never on the per-panel plot), so faceted plots get a + /// single shared legend rather than one per panel. `Some` only while building + /// the first panel — every panel produces the same legends (all built from the + /// globally resolved scales), so one capture suffices. + pub legends: Option<&'a std::cell::RefCell>>, +} + +impl Ctx<'_> { + /// The scale name to bind a position channel on `axis` to (panel-aware for + /// free facet scales). + pub fn pos_scale(&self, axis: PanelAxis) -> &str { + match axis { + PanelAxis::X => self.pos1_scale, + PanelAxis::Y => self.pos2_scale, + } + } + + /// Record a legend for later registration on the composition. A no-op once + /// the first panel has been captured (`legends` is `None`). + pub fn push_legend(&self, legend: Legend) { + if let Some(sink) = self.legends { + sink.borrow_mut().push(legend); + } + } +} + +/// Which panel axis a position channel drives. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PanelAxis { + X, + Y, +} + +/// A position channel: hephaestus `channel` ← ggsql `aesthetic`, on `axis`. +pub struct PositionSpec { + pub channel: &'static str, + pub aesthetic: String, + pub axis: PanelAxis, +} + +impl PositionSpec { + pub fn new(channel: &'static str, aesthetic: impl Into, axis: PanelAxis) -> Self { + Self { + channel, + aesthetic: aesthetic.into(), + axis, + } + } +} + +/// A material aesthetic: ggsql `aesthetic` → hephaestus `channel`, producing +/// `kind`, with a fallback `default` applied when the aesthetic isn't mapped. +pub struct MaterialSpec { + pub aesthetic: &'static str, + pub channel: &'static str, + pub kind: RangeKind, + pub default: MatDefault, +} + +impl MaterialSpec { + pub fn new( + aesthetic: &'static str, + channel: &'static str, + kind: RangeKind, + default: MatDefault, + ) -> Self { + Self { + aesthetic, + channel, + kind, + default, + } + } +} + +/// Fallback for an unmapped material channel, so output matches ggsql defaults. +pub enum MatDefault { + None, + Color(Color), + Number(f64), +} + +/// The legend swatch a geom's data-mapped scales use, so the key matches the +/// mark (a colored line for line geoms, a filled rect for bars/areas, etc.). +#[derive(Clone, Copy)] +pub enum LegendKind { + Point, + Line, + Rect, + /// A glyph, for a text layer: a scaled `fontsize` says what it does by + /// drawing letters at each size rather than discs. + Text, +} + +/// What a geom needs wired: its position channels, material table, any raw +/// (unscaled) string channels (e.g. text labels), and whether it groups rows. +pub struct GeomSpec { + pub positions: Vec, + pub material: Vec, + /// Unscaled string channels set from a mapped aesthetic (e.g. text labels): + /// (hephaestus channel, ggsql aesthetic). + pub raw_strings: &'static [(&'static str, &'static str)], + /// Constant panel-space channel values, scale-bypassing (e.g. a rule's + /// 0..1 span, discrete-tile band edges): (hephaestus channel, value). + /// + /// Materialised per row, not set as a scalar: a hephaestus geom whose + /// geometry varies per row (`SegmentGeom`, `RectGeom`, …) requires *every* + /// position channel to be a column, and panics on a constant. + pub raw_numbers: Vec<(&'static str, f64)>, + /// Per-row unscaled channel data the geom computes itself (e.g. bar band + /// edges from width/dodge): (hephaestus channel, one value per row). + pub data_channels: Vec<(&'static str, Vec)>, + /// Legend swatch style for this geom's data-mapped scales. + pub legend_key: LegendKind, + pub grouped: bool, +} + +/// Build a concrete geom from its spec and attach it to the plot. Bindings are +/// written onto `plot`; legends are recorded on `ctx` for one-shot registration +/// on the composition; scales are registered globally from `spec.scales` (see +/// `PngWriter::write`), and axes are created per coordinate system in +/// `projection`. +pub fn build_and_add(plot: &mut HPlot, spec: GeomSpec, ctx: &Ctx) -> Result<()> +where + G: BuildableGeom + Geom + 'static, +{ + let mut builder = GeomBuilder::::new(); + if spec.grouped { + if let Some(keys) = build_group_keys(ctx.df, &ctx.layer.partition_by)? { + builder.keys(keys); + } + } + // Band channels the geom computes itself (bar/tile edges) already carry the + // position adjustment, so `wire_positions` must not overwrite them. + let claimed: Vec<&str> = spec.data_channels.iter().map(|(c, _)| *c).collect(); + wire_positions(&mut builder, &spec.positions, plot, ctx, &claimed)?; + for (channel, aesthetic) in spec.raw_strings { + if let Some(col) = aesthetic_column_name(ctx.layer, aesthetic) { + builder.set(*channel, Raw(column_to_strings(ctx.df, col)?)); + } + } + for (channel, value) in &spec.raw_numbers { + builder.set(*channel, Raw(vec![*value; ctx.df.height()])); + } + for (channel, values) in spec.data_channels { + builder.set(channel, values); + } + wire_material(&mut builder, &spec.material, plot, ctx, spec.legend_key)?; + plot.add_geom(builder.build()); + Ok(()) +} + +/// Set position channels on the builder and bind them to the `pos1`/`pos2` +/// scales. `set_binding` is idempotent, so repeated bindings across layers are +/// harmless. Axis chrome is created later, per coordinate system, in `projection`. +/// +/// Each position also picks up the layer's position adjustment: `dodge` and +/// `jitter` are resolved by ggsql into per-row band fractions on the adjusted +/// axis, which map onto the geom's matching `_band` channel. Channels listed in +/// `claimed` are skipped — a geom that derives its own band edges (bar, tile) has +/// already folded the same offsets in. +fn wire_positions( + builder: &mut GeomBuilder, + positions: &[PositionSpec], + plot: &mut HPlot, + ctx: &Ctx, + claimed: &[&str], +) -> Result<()> { + let offsets = AxisOffsets::new(ctx.df); + for p in positions { + let data = match aesthetic_column_name(ctx.layer, &p.aesthetic) { + Some(col) => column_to_channel(ctx.df, col)?, + // A position given as a bare constant. It is repeated per row rather + // than set as a scalar, because a geom whose geometry varies per row + // rejects a constant position channel. + None => constant_position(ctx, &p.aesthetic) + .ok_or_else(|| missing_aesthetic(ctx, &p.aesthetic))?, + }; + data.apply(builder, p.channel); + plot.set_binding(p.channel, ctx.pos_scale(p.axis)); + + if let Some(values) = offsets.for_axis(p.axis) { + let band = format!("{}_band", p.channel); + if !claimed.contains(&band.as_str()) { + builder.set(band, values.clone()); + } + } + } + Ok(()) +} + +/// A position aesthetic mapped to a bare `Literal`, materialised into one +/// data-space value per row so it still travels through its position scale. +/// +/// ggsql delivers an aesthetic three ways and the writer honours all three +/// everywhere (see `wire_material`); positions are no exception. A number is a +/// continuous coordinate, a string a discrete category, and a boolean is read +/// as its category name, matching how the same values arrive in a column. +fn constant_position(ctx: &Ctx, aesthetic: &str) -> Option { + let n = ctx.df.height(); + match ctx.layer.mappings.get(aesthetic) { + Some(AestheticValue::Literal(ParameterValue::Number(value))) => { + Some(ChannelData::Floats(vec![*value; n])) + } + Some(AestheticValue::Literal(ParameterValue::String(value))) => { + Some(ChannelData::Strings(vec![value.clone(); n])) + } + Some(AestheticValue::Literal(ParameterValue::Boolean(value))) => { + Some(ChannelData::Strings(vec![value.to_string(); n])) + } + _ => None, + } +} + +/// The per-row band-fraction offsets ggsql resolved for a position adjustment, +/// per panel axis. `None` for an axis the layer wasn't adjusted along — which is +/// every axis for `position => 'identity'`, and the value axis always (`stack` +/// rewrites the value columns instead of offsetting). +struct AxisOffsets { + x: Option>, + y: Option>, +} + +impl AxisOffsets { + fn new(df: &DataFrame) -> Self { + Self { + x: offset_column(df, "pos1offset"), + y: offset_column(df, "pos2offset"), + } + } + + fn for_axis(&self, axis: PanelAxis) -> Option<&Vec> { + match axis { + PanelAxis::X => self.x.as_ref(), + PanelAxis::Y => self.y.as_ref(), + } + } +} + +/// Set material channels: data-mapped → bind channel to its (globally +/// registered) scale + record a legend; literal → constant visual value; identity/ +/// annotation → `Raw` per-row values; unmapped → the spec's default. Public so +/// custom-builder geoms (e.g. `spatial`) can wire their scalar aesthetics through +/// the same data-mapped-capable path the generic geoms use. +pub fn wire_material( + builder: &mut GeomBuilder, + material: &[MaterialSpec], + plot: &mut HPlot, + ctx: &Ctx, + legend_kind: LegendKind, +) -> Result<()> { + let mut handled: HashSet<&str> = HashSet::new(); + // One legend per *aesthetic*, not per channel. A geom may drive several + // channels from one aesthetic — a ribbon sends `stroke` to both of its edge + // curves — and each is a separate `MaterialSpec`, but they all describe the + // same scale and want one swatch between them. Recording a second legend + // does not merely duplicate: its key is `scaled` on the mirror channel + // (`stroke2`), which no legend key kind consumes, so it resolves to neither + // fill nor stroke and hephaestus paints its "row isn't empty" placeholder — + // a black outline over the real key. + let mut legended: HashSet<&str> = HashSet::new(); + + for m in material { + if handled.contains(m.channel) { + continue; + } + // ggsql delivers material values three ways (mirroring the Vega-Lite + // writer's `build_encoding_channel`): a bare `Literal` (a fixed value, + // from a geom default or `SETTING`), a data-mapped `Column` (scaled + + // legend), or an identity `AnnotationColumn` (per-row constant, from + // PLACE). + if let Some(AestheticValue::Literal(lit)) = ctx.layer.mappings.get(m.aesthetic) { + if set_literal_channel(builder, m.channel, m.kind, lit) { + handled.insert(m.channel); + } + continue; + } + let Some(col) = aesthetic_column_name(ctx.layer, m.aesthetic) else { + continue; + }; + handled.insert(m.channel); + + let scale = ctx.spec.find_scale(m.aesthetic); + let type_kind = scale + .and_then(|s| s.scale_type.as_ref()) + .map(|st| st.scale_type_kind()); + let data_mapped = scale.is_some() && type_kind != Some(ScaleTypeKind::Identity); + + if data_mapped { + let data = column_to_channel(ctx.df, col)?; + data.apply(builder, m.channel); + // Bind the channel to the aesthetic's scale (registered globally) and + // record a legend the first time this aesthetic is seen. hephaestus + // collapses compatible legends, so repeated records across *layers* + // for the same scale still merge at registration. + plot.set_binding(m.channel, m.aesthetic); + if legended.insert(m.aesthetic) { + ctx.push_legend(material_legend( + ctx, + m.aesthetic, + m.channel, + m.kind, + legend_kind, + material, + )); + } + } else { + match m.kind { + RangeKind::Color => { + builder.set(m.channel, Raw(column_to_colors(ctx.df, col)?)); + } + RangeKind::Shape | RangeKind::Text => { + builder.set(m.channel, Raw(column_to_strings(ctx.df, col)?)); + } + // A linetype column holds ggsql names or hex patterns, which the + // channel cannot read as strings — it takes dash patterns. Map each + // row through the same parser a literal goes through. Built as a + // `DataColumn` because hephaestus has no `Raw(Vec>)` + // conversion, only the `Raw(DataColumn)` one. + RangeKind::Linetype => { + let patterns: Vec<_> = column_to_strings(ctx.df, col)? + .iter() + .map(|s| map_linetype(s)) + .collect(); + builder.set(m.channel, Raw(DataColumn::from(patterns))); + } + RangeKind::Bool => { + builder.set(m.channel, Raw(column_to_bool(ctx.df, col)?)); + } + // A weight column may hold keywords or numbers; both parse. + RangeKind::FontWeight => { + let weights: Vec = column_to_strings(ctx.df, col)? + .iter() + .map(|s| parse_font_weight(s)) + .collect(); + builder.set(m.channel, Raw(weights)); + } + RangeKind::Angle => { + let radians: Vec = column_to_f64(ctx.df, col)? + .into_iter() + .map(f64::to_radians) + .collect(); + builder.set(m.channel, Raw(radians)); + } + _ => { + builder.set(m.channel, Raw(column_to_f64(ctx.df, col)?)); + } + } + } + } + + // Defaults for channels no spec mapped. `Raw` for the same reason literals + // are: a default is a visual value, and a sibling layer may have bound this + // channel to a scale that would otherwise swallow it. + for m in material { + if handled.contains(m.channel) { + continue; + } + match m.default { + MatDefault::Color(c) => { + builder.set(m.channel, Raw(c)); + handled.insert(m.channel); + } + MatDefault::Number(n) => { + builder.set(m.channel, Raw(n)); + handled.insert(m.channel); + } + MatDefault::None => {} + } + } + Ok(()) +} + +/// Set a material channel to a constant from a `Literal` aesthetic value, +/// converting by the channel's `RangeKind` (mirrors the Vega-Lite writer's +/// `build_literal_encoding`). Hephaestus takes sizes/widths in the same units +/// ggsql resolves them to (points), so numbers pass through unscaled. Returns +/// whether the value was applicable (an unparseable color / type mismatch is +/// left to the geom's default). +/// +/// The constant is set **`Raw`**, i.e. scale-bypassing. A literal is already a +/// visual-space value, and a hephaestus binding is per *plot channel*, not per +/// geom: one layer mapping `colour` binds `stroke` to a categorical scale for +/// every layer in the panel, and a sibling layer's plain (non-`Raw`) black +/// would then be looked up in that scale's domain, resolve to `Null`, and +/// vanish. Bypassing keeps each layer's constants its own. +fn set_literal_channel( + builder: &mut GeomBuilder, + channel: &str, + kind: RangeKind, + lit: &ParameterValue, +) -> bool { + match (kind, lit) { + (RangeKind::Color, ParameterValue::String(s)) => match parse_color(s) { + Some(c) => { + builder.set(channel, Raw(c)); + true + } + None => false, + }, + (RangeKind::Shape, ParameterValue::String(s)) => { + builder.set(channel, Raw(s.clone())); + true + } + // An empty string is not "use the default": it is a lookup (a font family, + // say) that misses. Leave the channel unset so hephaestus's default holds. + (RangeKind::Text, ParameterValue::String(s)) if !s.is_empty() => { + builder.set(channel, Raw(s.clone())); + true + } + (RangeKind::Bool, ParameterValue::Boolean(b)) => { + builder.set(channel, Raw(*b)); + true + } + // ggsql's `fontweight` takes a keyword or a number; hephaestus takes 100–900. + (RangeKind::FontWeight, ParameterValue::String(s)) => { + builder.set(channel, Raw(parse_font_weight(s))); + true + } + (RangeKind::FontWeight, ParameterValue::Number(n)) if n.is_finite() => { + builder.set(channel, Raw(*n)); + true + } + (RangeKind::Angle, ParameterValue::Number(n)) if n.is_finite() => { + builder.set(channel, Raw(n.to_radians())); + true + } + (RangeKind::Linetype, ParameterValue::String(s)) => { + builder.set(channel, Raw(HValue::Linetype(map_linetype(s)))); + true + } + (RangeKind::Number, ParameterValue::Number(n)) if n.is_finite() => { + builder.set(channel, Raw(*n)); + true + } + _ => false, + } +} + +/// The axis roles of a banded geom (boxplot / violin / range): its categories — +/// or, for a range, its fixed positions — sit on one axis and its values on the +/// other. ggsql flips the position columns of a transposed (horizontal) layer, so +/// the banded axis becomes `pos2` and the values land in the `pos1` family; +/// every channel name follows from that. +/// +/// Hephaestus channels are named per panel axis (`x`, `y_band`, …), so a geom +/// asks this for the channel that drives its banded or value axis instead of +/// hardcoding `x`/`y`. The `pos1`/`pos2` *bindings* need no swap: a channel +/// always belongs to the same panel axis. +#[derive(Clone, Copy)] +pub struct BandAxes { + transposed: bool, +} + +impl BandAxes { + pub fn new(ctx: &Ctx) -> Self { + Self { + transposed: ctx.transposed, + } + } + + /// The aesthetic family holding the banded-axis positions (`"pos1"`). + pub fn band(&self) -> &'static str { + if self.transposed { + "pos2" + } else { + "pos1" + } + } + + /// The aesthetic family holding the values (`"pos2"`). + pub fn value(&self) -> &'static str { + if self.transposed { + "pos1" + } else { + "pos2" + } + } + + /// The aesthetic carrying position-adjustment (dodge) offsets on the banded + /// axis. + pub fn dodge(&self) -> &'static str { + if self.transposed { + "pos2offset" + } else { + "pos1offset" + } + } + + /// The two banded-axis position channels (`("x", "x2")`). + pub fn band_channels(&self) -> (&'static str, &'static str) { + if self.transposed { + ("y", "y2") + } else { + ("x", "x2") + } + } + + /// The two value-axis position channels (`("y", "y2")`). + pub fn value_channels(&self) -> (&'static str, &'static str) { + if self.transposed { + ("x", "x2") + } else { + ("y", "y2") + } + } + + /// The banded axis's band-fraction offset channels (`("x_band", "x2_band")`), + /// which shift a mark's two edges within the category band. + pub fn band_fraction_channels(&self) -> (&'static str, &'static str) { + if self.transposed { + ("y_band", "y2_band") + } else { + ("x_band", "x2_band") + } + } + + /// The banded axis's absolute-pt offset channels (`("x_offset", + /// "x2_offset")`), for marks sized in points rather than band fractions + /// (hinge caps). + pub fn band_offset_channels(&self) -> (&'static str, &'static str) { + if self.transposed { + ("y_offset", "y2_offset") + } else { + ("x_offset", "x2_offset") + } + } +} + +/// The `side` SETTING as a signed direction along the banded axis: `None` for +/// `'both'` (a full-width mark centred on the band), else the sign of the half +/// the mark occupies. +/// +/// Hephaestus band offsets are positive-right on x and positive-up on y, so +/// `'top'`/`'right'` are positive in either orientation — which reproduces the +/// Vega-Lite writer's visual outcome (there the sign flips with orientation +/// because Vega-Lite's y offsets point down). +pub fn side_sign(layer: &Layer) -> Option { + match layer.parameters.get("side")? { + ParameterValue::String(s) => match s.as_str() { + "top" | "right" => Some(1.0), + "bottom" | "left" => Some(-1.0), + _ => None, + }, + _ => None, + } +} + +/// The two edges of a banded mark, as offsets from the band centre: the full +/// `±half` band for `side => 'both'`, else the centreline → `±half` half-band. +/// Used for band fractions (box, median, violin edge) and for pt-sized marks +/// (hinge caps) alike. +pub fn band_edges(half: f64, side: Option) -> (f64, f64) { + match side { + None => (-half, half), + Some(sign) => (0.0, sign * half), + } +} + +/// Half the band width a banded geom (bar/box/violin) occupies: the +/// dodge-narrowed width if set, else the `width` parameter (or `default`). +pub fn band_half_width(layer: &Layer, default: f64) -> f64 { + let width = layer + .parameters + .get("width") + .and_then(|v| match v { + ParameterValue::Number(n) => Some(*n), + _ => None, + }) + .unwrap_or(default); + layer.adjusted_width.unwrap_or(width).abs() / 2.0 +} + +/// A constant number from an aesthetic, or `default` when unmapped. Reads an +/// annotation column first, then a bare `Literal` number (e.g. `slope => 1`). +pub fn constant_number(ctx: &Ctx, aesthetic: &str, default: f64) -> f64 { + if let Some(n) = aesthetic_column_name(ctx.layer, aesthetic) + .and_then(|c| column_to_f64(ctx.df, c).ok()) + .and_then(|v| v.first().copied()) + .filter(|x| x.is_finite()) + { + return n; + } + if let Some(AestheticValue::Literal(ParameterValue::Number(n))) = + ctx.layer.mappings.aesthetics.get(aesthetic) + { + if n.is_finite() { + return *n; + } + } + default +} + +/// A position-adjustment offset column (per-row band fractions), or zeros when +/// the layer wasn't adjusted along that axis. For geoms that derive their own band +/// edges and so need the offsets as numbers; the generic path wires the same +/// column onto a `_band` channel in [`wire_positions`]. +pub fn dodge_offsets(df: &DataFrame, aesthetic: &str) -> Vec { + offset_column(df, aesthetic).unwrap_or_else(|| vec![0.0; df.height()]) +} + +/// ggsql's resolved offset column for one axis, when the layer carries one. +fn offset_column(df: &DataFrame, aesthetic: &str) -> Option> { + let name = crate::naming::aesthetic_column(aesthetic); + df.column(&name).ok()?; + column_to_f64(df, &name).ok() +} + +/// Resolve a label for an aesthetic: explicit `LABEL` wins (`None` suppresses), +/// else the original mapped column name is the default. +pub fn aesthetic_label(spec: &Plot, layer: &Layer, aesthetic: &str) -> Option { + if let Some(labels) = &spec.labels { + if let Some(entry) = labels.labels.get(aesthetic) { + return entry.clone(); + } + } + match layer.mappings.get(aesthetic) { + Some(AestheticValue::Column { + original_name: Some(name), + .. + }) => Some(name.clone()), + _ => None, + } +} + +/// Resolve a plot-level label (`title`, `subtitle`, `caption`) from the `LABEL` +/// clause. `None` covers both "not set" and `LABEL => NULL` (suppressed). +/// +/// Literal `\n` in the SQL string literal becomes a real newline, matching the +/// Vega-Lite writer's `split_label_on_newlines`. +pub fn plot_label(spec: &Plot, key: &str) -> Option { + let text = spec.labels.as_ref()?.labels.get(key)?.as_ref()?; + Some(text.replace("\\n", "\n")) +} + +/// A resolved material aesthetic for a composite geom, mirroring the Vega-Lite +/// writer's shared-encoding model: either a data-mapped column (scaled through a +/// registered scale that is bound + legended once, carrying that scale's name) or +/// a constant visual value. Components select the rows they cover and apply it to +/// a channel, so one resolved aesthetic styles every part of the composite. +pub enum MaterialSource { + Data { data: ChannelData, scale: String }, + Constant(HValue), +} + +impl MaterialSource { + /// Set `channel` for the `idx` rows: the scaled data subset, or the constant. + pub fn apply( + &self, + builder: &mut GeomBuilder, + channel: &str, + idx: &[usize], + ) { + match self { + MaterialSource::Data { data, .. } => data.select(idx).apply(builder, channel), + // `Raw`: a resolved constant is a visual value and must not be looked + // up in whatever scale another layer bound to this channel. + MaterialSource::Constant(v) => { + builder.set(channel, Raw(v.clone())); + } + } + } + + /// The registered scale name, when data-mapped (for binding extra channels, + /// e.g. a ribbon's far edge, to the same scale). + pub fn scale_name(&self) -> Option<&str> { + match self { + MaterialSource::Data { scale, .. } => Some(scale), + MaterialSource::Constant(_) => None, + } + } +} + +/// The column backing an aesthetic a geom cannot draw without. +pub fn require_column<'a>(ctx: &'a Ctx, aesthetic: &str) -> Result<&'a str> { + aesthetic_column_name(ctx.layer, aesthetic).ok_or_else(|| missing_aesthetic(ctx, aesthetic)) +} + +/// The error for a geom that reached the writer without an aesthetic it needs, +/// named in the user's own terms: `map_internal_to_user` turns `pos1` into `x` +/// (`y` for a transposed layer, `theta`/`radius` under polar), so the message +/// reads like the query that produced it. Core validation normally catches this +/// first; the writer's check is the backstop. +pub fn missing_aesthetic(ctx: &Ctx, aesthetic: &str) -> GgsqlError { + let user = ctx + .spec + .get_aesthetic_context() + .map_internal_to_user(aesthetic); + GgsqlError::WriterError(format!( + "{} layer has no '{user}' mapping", + ctx.layer.geom.geom_type() + )) +} + +/// [`MaterialSource::apply`] for an aesthetic that may not have resolved. +/// `None` leaves the channel unset, so hephaestus's own default stands. +pub fn apply_material( + builder: &mut GeomBuilder, + source: Option<&MaterialSource>, + channel: &str, + idx: &[usize], +) { + if let Some(source) = source { + source.apply(builder, channel, idx); + } +} + +/// Resolve a color aesthetic (`fill`, `stroke`, …) for a composite geom, falling +/// back to `default` when unmapped. See [`resolve_material`]. +pub fn resolve_color( + ctx: &Ctx, + plot: &mut HPlot, + aesthetic: &'static str, + channel: &'static str, + default: Color, + legend_kind: LegendKind, + material: &[MaterialSpec], +) -> Result { + Ok(resolve_material( + ctx, + plot, + aesthetic, + channel, + RangeKind::Color, + legend_kind, + material, + )? + .unwrap_or(MaterialSource::Constant(HValue::Color(default)))) +} + +/// Resolve a material aesthetic for a composite geom, dispatching the same three +/// ways as [`wire_material`] does for simple geoms, but returning a value the +/// caller can apply to a row subset (which `wire_material`, being whole-column, +/// cannot). +/// +/// A data-mapped non-identity scale binds `channel` to the aesthetic's (globally +/// registered) scale and records one legend; the full column is returned for +/// components to select. Otherwise the constant visual value: an identity / +/// annotation column's first value, else the mapped literal (`SETTING linewidth +/// => 3`). `None` when the aesthetic isn't mapped at all. +/// +/// hephaestus collapses compatible legends, so repeated binds across a +/// composite's components merge at registration. +pub fn resolve_material( + ctx: &Ctx, + plot: &mut HPlot, + aesthetic: &'static str, + channel: &'static str, + kind: RangeKind, + legend_kind: LegendKind, + material: &[MaterialSpec], +) -> Result> { + if is_data_mapped(ctx, aesthetic) { + let col = aesthetic_column_name(ctx.layer, aesthetic); + plot.set_binding(channel, aesthetic); + ctx.push_legend(material_legend( + ctx, + aesthetic, + channel, + kind, + legend_kind, + material, + )); + return Ok(Some(MaterialSource::Data { + data: column_to_channel(ctx.df, col.unwrap())?, + scale: aesthetic.to_string(), + })); + } + Ok(constant_material(ctx, aesthetic, kind).map(MaterialSource::Constant)) +} + +/// Whether an aesthetic maps a data column through a scale that actually +/// transforms it — i.e. it is scaled and legended, rather than carrying +/// visual-space values (an identity scale / annotation column) or a constant. +fn is_data_mapped(ctx: &Ctx, aesthetic: &str) -> bool { + let scale = ctx.spec.find_scale(aesthetic); + let type_kind = scale + .and_then(|s| s.scale_type.as_ref()) + .map(|st| st.scale_type_kind()); + aesthetic_column_name(ctx.layer, aesthetic).is_some() + && scale.is_some() + && type_kind != Some(ScaleTypeKind::Identity) +} + +/// The constant visual value of an unscaled material aesthetic: an identity / +/// annotation column's first value, else a bare `Literal`, converted by the +/// channel's `RangeKind` (hephaestus takes widths in points, as ggsql resolves +/// them, so numbers pass through). `None` when unmapped or inapplicable. +fn constant_material(ctx: &Ctx, aesthetic: &str, kind: RangeKind) -> Option { + let col = aesthetic_column_name(ctx.layer, aesthetic); + let literal = match ctx.layer.mappings.aesthetics.get(aesthetic) { + Some(AestheticValue::Literal(lit)) => Some(lit), + _ => None, + }; + match kind { + RangeKind::Color => { + if let Some(c) = col + .and_then(|c| column_to_colors(ctx.df, c).ok()) + .and_then(|v| v.first().copied()) + { + return Some(HValue::Color(c)); + } + match literal { + Some(ParameterValue::String(s)) => parse_color(s).map(HValue::Color), + _ => None, + } + } + RangeKind::Number | RangeKind::Position => { + if let Some(n) = col + .and_then(|c| column_to_f64(ctx.df, c).ok()) + .and_then(|v| v.first().copied()) + .filter(|x| x.is_finite()) + { + return Some(HValue::Number(n)); + } + match literal { + Some(ParameterValue::Number(n)) if n.is_finite() => Some(HValue::Number(*n)), + _ => None, + } + } + RangeKind::Linetype => { + let name = col + .and_then(|c| column_to_strings(ctx.df, c).ok()) + .and_then(|v| v.first().cloned()) + .or_else(|| match literal { + Some(ParameterValue::String(s)) => Some(s.clone()), + _ => None, + })?; + Some(HValue::Linetype(map_linetype(&name))) + } + RangeKind::Shape | RangeKind::Text => { + let name = col + .and_then(|c| column_to_strings(ctx.df, c).ok()) + .and_then(|v| v.first().cloned()) + .or_else(|| match literal { + Some(ParameterValue::String(s)) => Some(s.clone()), + _ => None, + }) + .filter(|s| !s.is_empty())?; + Some(HValue::String(name.into())) + } + RangeKind::Bool => { + if let Some(b) = col + .and_then(|c| column_to_bool(ctx.df, c).ok()) + .and_then(|v| v.first().copied()) + { + return Some(HValue::Bool(b)); + } + match literal { + Some(ParameterValue::Boolean(b)) => Some(HValue::Bool(*b)), + _ => None, + } + } + RangeKind::FontWeight => { + let weight = col + .and_then(|c| column_to_strings(ctx.df, c).ok()) + .and_then(|v| v.first().cloned()) + .or_else(|| match literal { + Some(ParameterValue::String(s)) => Some(s.clone()), + Some(ParameterValue::Number(n)) => Some(n.to_string()), + _ => None, + })?; + Some(HValue::Number(parse_font_weight(&weight))) + } + RangeKind::Angle => { + let degrees = col + .and_then(|c| column_to_f64(ctx.df, c).ok()) + .and_then(|v| v.first().copied()) + .or(match literal { + Some(ParameterValue::Number(n)) => Some(*n), + _ => None, + }) + .filter(|d| d.is_finite())?; + Some(HValue::Number(degrees.to_radians())) + } + } +} + +/// Build a legend for a data-mapped material scale. Continuous color uses a +/// colorbar; everything else a keyed legend (swatch per `legend_kind`) at the +/// scale's breaks. +/// +/// A binned scale flips whichever body it got into hephaestus's **binned** mode, +/// because ggsql's binned breaks are the bin *edges*: `N + 1` breaks describe `N` +/// bins. Binned mode draws one key (or one constant-color block) per bin and puts +/// the edge labels on a tick rail *between* them, so every edge is labelled once +/// at the boundary it names. That is why neither writer needs a compound +/// `"lower – upper"` label here — unlike the Vega-Lite writer, which has no +/// between-keys rail and so must reverse-engineer Vega's own range labels in +/// `encoding::build_symbol_legend_label_mapping`. +/// `scale_name` is the ggsql aesthetic, which is also the key the scale is +/// registered under — so the scale's type and the legend's title both follow +/// from it rather than being passed in. +pub fn material_legend( + ctx: &Ctx, + scale_name: &str, + channel: &str, + kind: RangeKind, + legend_kind: LegendKind, + material: &[MaterialSpec], +) -> Legend { + let type_kind = ctx + .spec + .find_scale(scale_name) + .and_then(|s| s.scale_type.as_ref()) + .map(|st| st.scale_type_kind()); + let title = aesthetic_label(ctx.spec, ctx.layer, scale_name); + let continuous_color = kind == RangeKind::Color + && matches!( + type_kind, + Some(ScaleTypeKind::Continuous) | Some(ScaleTypeKind::Binned) + ); + let mut legend = if continuous_color { + Legend::colorbar(scale_name).side(LegendSide::Right) + } else { + let key = match legend_kind { + LegendKind::Point => LegendKeySpec::point(), + LegendKind::Line => LegendKeySpec::line(), + LegendKind::Rect => LegendKeySpec::rect(), + LegendKind::Text => LegendKeySpec::text(), + } + .scaled(channel, scale_name); + Legend::new(scale_name) + .side(LegendSide::Right) + .key(pin_constants( + ctx, + key, + material, + channel, + legend_kind, + kind, + )) + }; + if type_kind == Some(ScaleTypeKind::Binned) { + legend = legend.binned(); + } + if let Some(title) = title { + legend = legend.title(title); + } + legend +} + +/// Dress a legend key in everything the layer holds constant, so the swatch +/// looks like the marks it describes: a translucent area's key is translucent, a +/// map layer's key carries its border color, a dashed line's key is dashed. +/// +/// A key paints only what it is told to paint — nothing is inherited from the +/// plot — so every constant has to be pinned explicitly. The geom's own +/// `MaterialSpec` table is the source: it already names each ggsql aesthetic's +/// hephaestus channel *and* that geom's aliasing (`color` → `fill` for an area, +/// → `stroke` for a line), so the key is styled exactly like the geom is. +/// `LegendKeySpec::fixed` ignores channels the key kind doesn't consume, which +/// is what lets one table serve point, line and rect keys. +/// +/// Two channels are deliberately left alone: the one the legend is *scaled* on +/// (pinning it would override the very thing being shown), and any channel a +/// scale owns — a data-mapped aesthetic's column holds domain values, not visual +/// ones, and it carries its own legend anyway. Everything else pins, including +/// the channels that decide how much room the glyph takes (`size`, `linewidth`, +/// `shape`): hephaestus sizes each swatch cell from the key it holds, so a +/// `SETTING size => 12` marker gets a cell that fits it. +fn pin_constants( + ctx: &Ctx, + mut key: LegendKeySpec, + material: &[MaterialSpec], + scaled_channel: &str, + legend_kind: LegendKind, + kind: RangeKind, +) -> LegendKeySpec { + // `claimed` is "do not pin this channel again"; `pinned` is "this channel + // actually got a value". They differ for a channel a *scale* owns: nothing + // may pin over it, but it has no constant either. + let mut claimed: HashSet<&str> = HashSet::from([scaled_channel]); + let mut pinned: HashSet<&str> = HashSet::from([scaled_channel]); + for m in material { + if claimed.contains(m.channel) { + continue; + } + // A channel another scale drives is spoken for, whichever aesthetic + // reached it first — claim it so a later alias can't pin over it. + if is_data_mapped(ctx, m.aesthetic) { + claimed.insert(m.channel); + continue; + } + let value = constant_material(ctx, m.aesthetic, m.kind).or(match m.default { + MatDefault::Color(c) => Some(HValue::Color(c)), + MatDefault::Number(n) => Some(HValue::Number(n)), + MatDefault::None => None, + }); + if let Some(value) = value { + claimed.insert(m.channel); + pinned.insert(m.channel); + key = key.fixed(m.channel, value); + } + } + // Last resort: a key whose body color is neither scaled nor constant renders + // as an empty swatch next to its label. That happens when the geom leaves + // the body unmapped with no default, and — more often — when a *different* + // scale owns it: a `size` legend on a layer that also maps `fill` cannot + // borrow the fill column, since it holds domain values rather than colors. + // A neutral grey is the honest stand-in; that scale carries its own legend. + // + // A color-scaled legend never needs it: the scale itself paints the key. It + // must also not get it, because ggsql maps `color` onto *both* `fill` and + // `stroke`, and hephaestus only collapses those two legends into one swatch + // while their keys stay equivalent — a grey body on just the `stroke` one + // splits them, leaving a second key drawn over the first. + if kind != RangeKind::Color { + let body = match legend_kind { + LegendKind::Line => "stroke", + LegendKind::Point | LegendKind::Rect | LegendKind::Text => "fill", + }; + // A layer that fades its body out keeps the grey harmlessly: the + // `fill_opacity` / `stroke_opacity` pinned above is what hephaestus + // paints it at, so `opacity => 0` leaves the key as unfilled as the + // marks are. + if !pinned.contains(body) { + key = key.fixed(body, HValue::Color(rgb8(64, 64, 64))); + } + } + key +} diff --git a/src/writer/mod.rs b/src/writer/mod.rs index db1aa1d2f..a0aad469a 100644 --- a/src/writer/mod.rs +++ b/src/writer/mod.rs @@ -23,17 +23,34 @@ //! let json = writer.render(&spec)?; //! println!("{}", json); //! ``` +//! +//! Writers are configured by their own constructors, or generically from +//! key–value [`WriterOptions`] when a frontend collects settings from a user +//! without knowing which writer they picked. use crate::reader::Spec; use crate::{DataFrame, Plot, Result}; use std::collections::HashMap; +pub mod options; + +pub use options::WriterOptions; + #[cfg(feature = "vegalite")] pub mod vegalite; #[cfg(feature = "vegalite")] pub use vegalite::VegaLiteWriter; +// The raster writer is backed by the hephaestus renderer, which the module name +// records. That is an implementation detail: the writer is public as `PngWriter` +// and the module itself is not part of the API. +#[cfg(feature = "png")] +mod hephaestus; + +#[cfg(feature = "png")] +pub use hephaestus::{rgba, Color, PngWriter}; + /// Trait for visualization output writers /// /// Writers take a Plot and data sources and produce formatted output @@ -47,6 +64,22 @@ pub trait Writer { /// The output type produced by this writer. type Output; + /// Construct the writer from free-form key–value options. + /// + /// This is the entry point for a frontend that collects settings from a + /// user (`--writer-option width=1600`) and has no compile-time knowledge of + /// the chosen writer. Implementations start by calling + /// [`WriterOptions::reject_unknown`] so a mistyped key is reported instead + /// of ignored, then fall back to their own defaults for anything unset. + /// + /// # Errors + /// + /// Returns `GgsqlError::WriterError` if an option is unknown to this writer + /// or its value cannot be interpreted. + fn from_options(options: &WriterOptions) -> Result + where + Self: Sized; + /// Generate output from a visualization specification and data sources /// /// # Arguments diff --git a/src/writer/options.rs b/src/writer/options.rs new file mode 100644 index 000000000..aa9bc411f --- /dev/null +++ b/src/writer/options.rs @@ -0,0 +1,293 @@ +//! Free-form key–value options for writers. +//! +//! A frontend collects `key=value` pairs from its user (`-D width=1600`, or +//! `-D 'width=1600;dpi=150'`, on the CLI) and hands them to +//! [`Writer::from_options`](super::Writer::from_options). Each writer therefore +//! exposes its own configuration without any frontend needing to know the +//! writer's shape, and a writer that takes no options needs no special casing. + +use std::collections::BTreeMap; + +use crate::util::or_list_quoted; +use crate::{GgsqlError, Result}; + +/// Key–value configuration handed to a [`Writer`](super::Writer). +/// +/// Keys are normalised — trimmed, lowercased, and `-` folded to `_` — so +/// `background-color`, `Background_Color`, and `background_color` are the same +/// key. Values are stored verbatim; the accessors below interpret them, and the +/// errors they produce name the offending option so a frontend can pass them +/// straight to the user. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct WriterOptions { + values: BTreeMap, +} + +impl WriterOptions { + /// An empty set of options — every writer then uses its own defaults. + pub fn new() -> Self { + Self::default() + } + + /// Parse `key=value` strings, as a frontend collects them from a repeatable + /// flag. + /// + /// One string may carry several options separated by `;`, so a caller can + /// write out either form, or mix them: + /// + /// ```text + /// ["width=1600", "height=1200"] // one option per flag + /// ["width=1600;height=1200"] // collapsed into one + /// ``` + /// + /// `;` is the only separator. `,` is not, because it is common *inside* a + /// value — `background=rgba(0,0,0,0)` has to survive. The value is + /// everything from the first `=` to the next `;`, so values may contain `=` + /// themselves, and a later occurrence of a key overrides an earlier one. + /// + /// # Errors + /// + /// Returns `GgsqlError::WriterError` if an entry has no `=` or an empty key. + pub fn parse(pairs: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let mut options = Self::new(); + for pair in pairs { + // An empty segment is a trailing or doubled `;`, not a mistake worth + // an error. + for entry in pair.as_ref().split(';').filter(|e| !e.trim().is_empty()) { + let Some((key, value)) = entry.split_once('=') else { + return Err(GgsqlError::WriterError(format!( + "invalid writer option '{}': expected 'key=value'", + entry.trim() + ))); + }; + if normalise_key(key).is_empty() { + return Err(GgsqlError::WriterError(format!( + "invalid writer option '{}': the key is empty", + entry.trim() + ))); + } + options = options.set(key, value.trim()); + } + } + Ok(options) + } + + /// Set one option, overriding any previous value for the same key. + pub fn set(mut self, key: &str, value: impl Into) -> Self { + self.values.insert(normalise_key(key), value.into()); + self + } + + /// True when no options were supplied. + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } + + /// The raw value of `key`, or `None` when it was not supplied. + pub fn get(&self, key: &str) -> Option<&str> { + self.values.get(&normalise_key(key)).map(String::as_str) + } + + /// The value of `key` parsed as a finite number. + /// + /// # Errors + /// + /// Returns `GgsqlError::WriterError` if the value is not a finite number. + pub fn number(&self, key: &str) -> Result> { + let Some(raw) = self.get(key) else { + return Ok(None); + }; + match raw.parse::() { + Ok(value) if value.is_finite() => Ok(Some(value)), + _ => Err(GgsqlError::WriterError(format!( + "writer option '{}' expects a number, got '{raw}'", + normalise_key(key) + ))), + } + } + + /// The value of `key`, checked against a closed set of allowed values. + /// + /// Matching ignores case and surrounding whitespace, mirroring how keys are + /// normalised. + /// + /// # Errors + /// + /// Returns `GgsqlError::WriterError` if the value is not in `allowed`. + pub fn one_of<'a>(&self, key: &str, allowed: &[&'a str]) -> Result> { + let Some(raw) = self.get(key) else { + return Ok(None); + }; + let needle = raw.trim().to_lowercase(); + match allowed.iter().find(|value| **value == needle) { + Some(value) => Ok(Some(value)), + None => Err(GgsqlError::WriterError(format!( + "writer option '{}' expects {}, got '{raw}'", + normalise_key(key), + or_list_quoted(allowed, '\'') + ))), + } + } + + /// Reject any option the writer does not understand. + /// + /// Writers call this first so a mistyped key is an error rather than a + /// silently ignored setting. + /// + /// # Errors + /// + /// Returns `GgsqlError::WriterError` naming the unknown keys and listing + /// the supported ones. + pub fn reject_unknown(&self, known: &[&str]) -> Result<()> { + let unknown: Vec<&str> = self + .values + .keys() + .map(String::as_str) + .filter(|key| !known.contains(key)) + .collect(); + if unknown.is_empty() { + return Ok(()); + } + let subject = if unknown.len() == 1 { + "option" + } else { + "options" + }; + let supported = if known.is_empty() { + "this writer takes no options".to_string() + } else { + format!("supported options: {}", known.join(", ")) + }; + Err(GgsqlError::WriterError(format!( + "unknown writer {subject} {} — {supported}", + or_list_quoted(&unknown, '\'') + ))) + } +} + +/// Fold a key to its canonical form: trimmed, lowercased, `-` as `_`. +fn normalise_key(key: &str) -> String { + key.trim().to_lowercase().replace('-', "_") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_reads_key_value_pairs() { + let options = WriterOptions::parse(["width=1600", "height=1200"]).unwrap(); + assert_eq!(options.get("width"), Some("1600")); + assert_eq!(options.number("height").unwrap(), Some(1200.0)); + assert_eq!(options.get("dpi"), None); + assert_eq!(options.number("dpi").unwrap(), None); + } + + #[test] + fn parse_normalises_keys_and_trims_values() { + let options = WriterOptions::parse([" Background-Color = red "]).unwrap(); + assert_eq!(options.get("background_color"), Some("red")); + assert_eq!(options.get("BACKGROUND-COLOR"), Some("red")); + } + + #[test] + fn parse_collapses_several_options_into_one_entry() { + let collapsed = WriterOptions::parse(["width=1600;height=1200;units=px"]).unwrap(); + let separate = WriterOptions::parse(["width=1600", "height=1200", "units=px"]).unwrap(); + assert_eq!(collapsed, separate); + // The two forms mix, and a stray or trailing `;` is not an error. + let mixed = WriterOptions::parse(["width=1600;height=1200;", "units=px"]).unwrap(); + assert_eq!(mixed, separate); + } + + #[test] + fn parse_keeps_commas_inside_a_value() { + let options = WriterOptions::parse(["background=rgb(255, 0, 0);dpi=150"]).unwrap(); + assert_eq!(options.get("background"), Some("rgb(255, 0, 0)")); + assert_eq!(options.number("dpi").unwrap(), Some(150.0)); + } + + #[test] + fn parse_splits_on_the_first_equals_only() { + let options = WriterOptions::parse(["background=rgba(0,0,0,0)", "title=a=b"]).unwrap(); + assert_eq!(options.get("background"), Some("rgba(0,0,0,0)")); + assert_eq!(options.get("title"), Some("a=b")); + } + + #[test] + fn parse_lets_a_later_occurrence_win() { + let options = WriterOptions::parse(["width=100", "width=200"]).unwrap(); + assert_eq!(options.get("width"), Some("200")); + } + + #[test] + fn parse_rejects_malformed_entries() { + let err = WriterOptions::parse(["width"]).unwrap_err().to_string(); + assert!(err.contains("expected 'key=value'"), "{err}"); + let err = WriterOptions::parse(["=1600"]).unwrap_err().to_string(); + assert!(err.contains("the key is empty"), "{err}"); + } + + #[test] + fn number_rejects_non_numeric_values() { + let options = WriterOptions::parse(["width=wide"]).unwrap(); + let err = options.number("width").unwrap_err().to_string(); + assert!( + err.contains("'width' expects a number, got 'wide'"), + "{err}" + ); + let options = WriterOptions::parse(["width=inf"]).unwrap(); + assert!(options.number("width").is_err()); + } + + #[test] + fn one_of_matches_case_insensitively() { + let options = WriterOptions::parse(["units=CM"]).unwrap(); + assert_eq!(options.one_of("units", &["px", "cm"]).unwrap(), Some("cm")); + assert_eq!( + WriterOptions::new().one_of("units", &["px", "cm"]).unwrap(), + None + ); + } + + #[test] + fn one_of_rejects_values_outside_the_set() { + let options = WriterOptions::parse(["units=furlongs"]).unwrap(); + let err = options.one_of("units", &["px", "cm"]).unwrap_err(); + assert!( + err.to_string() + .contains("'units' expects 'px' or 'cm', got 'furlongs'"), + "{err}" + ); + } + + #[test] + fn reject_unknown_names_the_bad_keys() { + let options = WriterOptions::parse(["width=10", "hight=10", "colour=red"]).unwrap(); + let err = options + .reject_unknown(&["width", "height"]) + .unwrap_err() + .to_string(); + assert!(err.contains("unknown writer options"), "{err}"); + assert!(err.contains("'colour' or 'hight'"), "{err}"); + assert!(err.contains("supported options: width, height"), "{err}"); + assert!(options + .reject_unknown(&["width", "height", "hight", "colour"]) + .is_ok()); + } + + #[test] + fn reject_unknown_says_so_when_no_options_are_taken() { + let err = WriterOptions::parse(["width=10"]) + .unwrap() + .reject_unknown(&[]) + .unwrap_err() + .to_string(); + assert!(err.contains("this writer takes no options"), "{err}"); + assert!(WriterOptions::new().reject_unknown(&[]).is_ok()); + } +} diff --git a/src/writer/vegalite/CLAUDE.md b/src/writer/vegalite/CLAUDE.md index a666fe01c..907f5aa6c 100644 --- a/src/writer/vegalite/CLAUDE.md +++ b/src/writer/vegalite/CLAUDE.md @@ -132,6 +132,15 @@ PreparedData::Composite { - ✅ Return multiple layers if needed (composite geoms) - ✅ Modify encodings that reference transformed fields +The source filter is not the only transform that arrives before `finalize`. An +encoding may bring its own: an identity-scaled `size` / `linewidth` / `fontsize` / +`shape` / `linetype` column is converted per row by a `calculate` (encoding.rs's +`identity_conversion`, since `SCALE IDENTITY` means the values are per-row literals +and Vega-Lite has no per-datum arithmetic in an encoding), and the encoding points at +the derived `_visual` field. A renderer that *replaces* the transform array +therefore drops the conversion and leaves its encoding pointing at a field that no +longer exists — the mark silently falls back to the channel default. + **DON'T**: - ❌ Set `layer_spec["data"]` - layers use the unified top-level dataset - ❌ Replace the transforms array without preserving existing ones @@ -318,6 +327,33 @@ fn modify_spec(...) -> Result<()> { } ``` +Note the `clip: true`: `geom_to_mark` puts it on every mark, and *replacing* the +mark object drops it. A layer that can draw outside its scale domain — a violin +at `width => 4`, a bar with squished limits — then spills into the chrome, where +the raster writer (and ggplot2) would have clipped it. + +### Pattern 4: Band-Fraction Offsets (Dodge, Jitter, `side`) + +Every band-fraction displacement — dodge, jitter, a violin's density half-width, +a half-boxplot's side shift — is encoded as a quantitative `xOffset`/`yOffset` +whose scale domain spans one band. Build it with `encoding::offset_encoding` +rather than by hand: + +```rust +layer_spec["encoding"][offset_channel] = + encoding::offset_encoding(&combined_offset_col, is_horizontal); +``` + +The domain is `[-0.5, 0.5]` on the primary channel but `[0.5, -0.5]` on the +secondary one, because a ggsql offset is positive-up while a Vega-Lite `yOffset` +is positive-down; the reversed domain negates it without touching the data. +A renderer that writes its own `{"domain": [-0.5, 0.5]}` loses that flip and +displaces its marks the opposite way to every other layer on the same axis. + +For the same reason `side_is_positive` is orientation-independent: `'top'` and +`'right'` are the positive half in either orientation, and the axis flip lives in +the domain, not in the sign. + ## Debugging Tips ### Issue: Layer has no data / nothing renders diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index ffe714bf3..7689baba0 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -6,7 +6,7 @@ use crate::array_util::as_str; use crate::plot::aesthetic::{is_position_aesthetic, AestheticContext}; use crate::plot::scale::{linetype_to_stroke_dash, shape_to_svg_path, ScaleTypeKind}; -use crate::plot::ParameterValue; +use crate::plot::{ParameterValue, Scale}; use crate::{AestheticValue, DataFrame, GgsqlError, Plot, Result}; use arrow::array::Array; use arrow::datatypes::DataType; @@ -20,6 +20,14 @@ fn is_free(aesthetic: &str, facet: Option<&crate::plot::Facet>) -> bool { facet.is_some_and(|f| f.is_free(aesthetic)) } +/// Whether a scale lays its input out as bands rather than as a continuum. +fn is_categorical(scale: &crate::Scale) -> bool { + matches!( + scale.scale_type.as_ref().map(|st| st.scale_type_kind()), + Some(ScaleTypeKind::Discrete) | Some(ScaleTypeKind::Ordinal) + ) +} + /// Build a Vega-Lite labelExpr from label mappings /// /// Generates a conditional expression that renames or suppresses labels: @@ -99,81 +107,31 @@ pub(super) fn build_label_expr( parts.join(" : ") } -/// Build label mappings for threshold scale symbol legends -/// -/// Maps Vega-Lite's auto-generated range labels to our desired labels. -/// VL format: "" for most bins (en-dash U+2013), "≥ " for last bin. +/// Build label mappings for threshold scale symbol legends. /// -/// # Arguments -/// * `breaks` - All break values including terminals [0, 25, 50, 75, 100] -/// * `label_mapping` - Our desired labels keyed by break value string -/// * `closed` - Which side of bin is closed: "left" (default) or "right" -/// -/// # Returns -/// HashMap mapping Vega-Lite's predicted labels to our replacement labels -pub(super) fn build_symbol_legend_label_mapping( - breaks: &[crate::plot::ArrayElement], - label_mapping: &HashMap>, - closed: &str, -) -> HashMap> { - let mut result = HashMap::new(); - - // We have N breaks = N-1 bins - // legend.values has N-1 entries (last terminal excluded for symbol legends) - if breaks.len() < 2 { - return result; - } - let num_bins = breaks.len() - 1; - - for i in 0..num_bins { - let lower = &breaks[i]; - let upper = &breaks[i + 1]; - let lower_str = lower.to_key_string(); - let upper_str = upper.to_key_string(); - - // Get our desired label for this bin (keyed by lower bound) - let our_label = label_mapping.get(&lower_str).cloned().flatten(); - - // Predict Vega-Lite's generated label - // All but last: "" (en-dash U+2013 with spaces) - // Last bin: "≥ " (greater-than-or-equal U+2265) - let vl_label = if i == num_bins - 1 { - format!("≥ {}", lower_str) - } else { - format!("{} – {}", lower_str, upper_str) - }; - - // Check if terminals are suppressed (mapped to None) - let lower_suppressed = label_mapping.get(&lower_str) == Some(&None); - let upper_suppressed = label_mapping.get(&upper_str) == Some(&None); - - // Get labels for building range format (fall back to break values) - let lower_label = our_label.clone().unwrap_or_else(|| lower_str.clone()); - let upper_label = label_mapping - .get(&upper_str) - .cloned() - .flatten() - .unwrap_or_else(|| upper_str.clone()); - - // Determine the replacement label - // Priority: terminal suppression → range format with custom labels - let replacement = if i == 0 && lower_suppressed { - // First bin with suppressed lower terminal → open format - let symbol = if closed == "right" { "≤" } else { "<" }; - Some(format!("{} {}", symbol, upper_label)) - } else if i == num_bins - 1 && upper_suppressed { - // Last bin with suppressed upper terminal → open format - let symbol = if closed == "right" { ">" } else { "≥" }; - Some(format!("{} {}", symbol, lower_label)) - } else { - // Use range format with custom labels: "" - Some(format!("{} – {}", lower_label, upper_label)) - }; - - result.insert(vl_label, replacement); - } - - result +/// Vega-Lite generates its own text for each bin of a symbol legend — a +/// `""` range (en dash U+2013) for every bin but the last, which +/// it renders as `"≥ "`. Those strings are the keys a `labelExpr` has to +/// match, so this pairs each with the label ggsql resolved for that bin +/// ([`Scale::binned_bins`], shared with the raster writer). +pub(super) fn build_symbol_legend_label_mapping(scale: &Scale) -> HashMap> { + let bins = scale.binned_bins(); + let last = bins.len().saturating_sub(1); + bins.iter() + .enumerate() + .map(|(i, bin)| { + let vl_label = if i == last { + format!("≥ {}", bin.lower.to_key_string()) + } else { + format!( + "{} – {}", + bin.lower.to_key_string(), + bin.upper.to_key_string() + ) + }; + (vl_label, Some(bin.label.clone())) + }) + .collect() } /// Count the number of binned material scales in the spec. @@ -342,6 +300,33 @@ fn insert_legend_property(encoding: &mut Value, key: &str, value: Value) { } } +/// Encode a band-fraction offset column on a position offset channel. +/// +/// The offsets ggsql resolves — dodge and jitter displacements, a violin's +/// density half-width, a half-boxplot's side shift — are fractions of the band, +/// which is what a `[-0.5, 0.5]` domain makes of them: the scale's range is the +/// band width, so a value of `w` shifts the mark by `w` bands. +/// +/// For the **secondary** channel the domain runs `0.5 → -0.5` instead, because a +/// ggsql offset is positive-up (matching the bottom-up categorical `y` the band +/// domain is reversed for) while a Vega-Lite `yOffset` is positive-down. +/// Flipping the domain negates the offset without touching the data, so every +/// mark — and every component of a composite one — reads the same way round as +/// the axis it sits on. The primary channel needs no flip: `xOffset` is +/// positive-right, as ggsql's offsets are. +pub(super) fn offset_encoding(field: &str, is_secondary: bool) -> Value { + let domain = if is_secondary { + json!([0.5, -0.5]) + } else { + json!([-0.5, 0.5]) + }; + json!({ + "field": field, + "type": "quantitative", + "scale": { "domain": domain } + }) +} + // ============================================================================= // Phase 2: Logical Section Helpers // ============================================================================= @@ -473,7 +458,16 @@ fn build_scale_properties( // Skip for free facet scales - Vega-Lite should compute independent domains if !ctx.is_binned_legend && !skip_domain { if let Some(ref domain_values) = scale.input_range { - let domain_json: Vec = domain_values.iter().map(|elem| elem.to_json()).collect(); + let mut domain_json: Vec = + domain_values.iter().map(|elem| elem.to_json()).collect(); + // A categorical `y` runs bottom-up, as in ggplot2: the first level + // sits at the bottom of the panel. Vega-Lite lays a band domain out + // top-to-bottom, so the domain is handed over backwards to put it + // the right way up. `scale.reverse` still composes on top, flipping + // whatever the default now is. + if ctx.aesthetic == "pos2" && is_categorical(scale) { + domain_json.reverse(); + } scale_obj.insert("domain".to_string(), json!(domain_json)); } } @@ -525,8 +519,19 @@ fn build_scale_properties( } } - // Handle reverse property (SETTING clause) - if let Some(ParameterValue::Boolean(true)) = scale.properties.get("reverse") { + // Handle reverse property (SETTING clause). + // + // A free facet dimension emits no domain — Vega-Lite computes one per panel + // — so a categorical `y` there cannot be handed over backwards the way a + // fixed one is above. `reverse` expresses the same bottom-up default + // instead. A user `SETTING reverse => true` composes on top of whichever + // default applies, which for the free case means the two cancel. + let bottom_up_default = skip_domain && ctx.aesthetic == "pos2" && is_categorical(scale); + let user_reverse = matches!( + scale.properties.get("reverse"), + Some(ParameterValue::Boolean(true)) + ); + if user_reverse != bottom_up_default { scale_obj.insert("reverse".to_string(), json!(true)); } @@ -735,12 +740,8 @@ fn apply_label_mapping_to_encoding( // Symbol legends compare VL's predicted range labels (e.g. "-20 – 0") // as strings via datum.label, not as numeric datum.value. - let filtered_mapping = if let (true, Some(breaks)) = (is_symbol, breaks) { - let closed = match scale.properties.get("closed") { - Some(ParameterValue::String(s)) => s.as_str(), - _ => "left", - }; - build_symbol_legend_label_mapping(breaks, label_mapping, closed) + let filtered_mapping = if is_symbol { + build_symbol_legend_label_mapping(scale) } else { label_mapping.clone() }; @@ -780,6 +781,10 @@ pub(super) struct EncodingContext<'a> { pub spec: &'a Plot, pub titled_families: &'a mut HashSet, pub primary_aesthetics: &'a HashSet, + /// `calculate` transforms the built encodings need on their layer, in the + /// order they were requested. Currently only the unit conversion an + /// identity-scaled column needs — see [`identity_unit_conversion`]. + pub transforms: &'a mut Vec, } /// Build encoding channel from aesthetic mapping @@ -844,9 +849,22 @@ fn build_column_encoding( // Binned legend = binned + material (needs threshold scale) let is_binned_legend = is_binned && !is_position_aesthetic(aesthetic); + // An identity scale hands the column to the aesthetic untouched, so each value + // means what the same value written as a literal means. Convert it exactly as + // `build_literal_encoding` converts that literal, per row. + let field = match identity_conversion(aesthetic, col, ctx.spec.find_scale(primary)) { + Some(expr) if identity_scale => { + let converted = format!("{col}_visual"); + ctx.transforms + .push(json!({"calculate": expr, "as": converted.clone()})); + converted + } + _ => col.to_string(), + }; + // Build base encoding let mut encoding = json!({ - "field": col, + "field": field, "type": field_type, }); @@ -923,6 +941,77 @@ fn build_column_encoding( Ok(encoding) } +/// The conversion an identity-scaled column needs, as a Vega expression over +/// `datum`, or `None` for an aesthetic Vega-Lite already takes in ggsql's own terms. +/// +/// `SCALE IDENTITY ` passes the data through unscaled, which means each value is +/// read the way the same value written as a `SETTING` literal is — so it needs the +/// conversion [`build_literal_encoding`] gives that literal and [`convert_range_element`] +/// gives a resolved output range. All three paths must agree. Two shapes of conversion: +/// +/// - **Units.** `size` is a radius in points and Vega-Lite wants a symbol area in px²; +/// `linewidth` / `fontsize` are points and it wants px. Plain arithmetic per row. +/// - **Names.** `shape` and `linetype` are ggsql names (`'star'`, `'dashed'`) and +/// Vega-Lite wants an SVG path and a dash array. Vega has no lookup function over an +/// inline table, so the mapping is a conditional chain over the values the scale +/// resolved, with anything unrecognised passed through — a column may already hold +/// paths or dash arrays, exactly as a literal may. +/// +/// Either way the arithmetic is per-datum, which in Vega-Lite only exists as a +/// transform, hence a `calculate` feeding a derived field rather than a scale. +fn identity_conversion(aesthetic: &str, col: &str, scale: Option<&crate::Scale>) -> Option { + let datum = format!("datum['{}']", super::escape_vega_string(col)); + match aesthetic { + // Size: radius (points) → area (pixels²) + "size" => Some(format!("{datum} * {datum} * {POINTS_TO_AREA}")), + // Linewidth: points → pixels + "linewidth" | "fontsize" => Some(format!("{datum} * {POINTS_TO_PIXELS}")), + // Shape name → SVG path + "shape" => identity_lookup_expr(&datum, scale, |name| { + shape_to_svg_path(name).map(|path| json!(path)) + }), + // Linetype name → dash array + "linetype" => identity_lookup_expr(&datum, scale, |name| { + linetype_to_stroke_dash(name).map(|dashes| json!(dashes)) + }), + _ => None, + } +} + +/// A Vega conditional chain mapping each value an identity scale resolved to its +/// Vega-Lite equivalent, falling through to the datum itself. +/// +/// `None` when nothing needs converting — no resolved values, or none of them is a +/// name `convert` recognises — so no transform is emitted and the column reaches the +/// mark untouched, which is what a column of ready-made paths or dash arrays wants. +fn identity_lookup_expr( + datum: &str, + scale: Option<&crate::Scale>, + convert: impl Fn(&str) -> Option, +) -> Option { + let values = scale?.input_range.as_ref()?; + let mut parts: Vec = Vec::new(); + + for value in values { + let crate::plot::ArrayElement::String(name) = value else { + continue; + }; + if let Some(converted) = convert(name) { + parts.push(format!( + "{datum} == '{}' ? {}", + super::escape_vega_string(name), + converted + )); + } + } + + if parts.is_empty() { + return None; + } + parts.push(datum.to_string()); + Some(parts.join(" : ")) +} + /// Build encoding for a literal aesthetic value fn build_literal_encoding(aesthetic: &str, lit: &ParameterValue) -> Result { let val = match lit { @@ -1218,7 +1307,13 @@ mod tests { label_mapping.insert("-20".to_string(), Some("cold".to_string())); label_mapping.insert("0".to_string(), Some("hot".to_string())); - let symbol_mapping = build_symbol_legend_label_mapping(&breaks, &label_mapping, "left"); + let mut scale = Scale::new("fill"); + scale.scale_type = Some(crate::plot::ScaleType::binned()); + scale + .properties + .insert("breaks".to_string(), ParameterValue::Array(breaks)); + scale.label_mapping = Some(label_mapping); + let symbol_mapping = build_symbol_legend_label_mapping(&scale); // The resulting mapping uses VL's range-style label strings as keys let expr = build_label_expr(&symbol_mapping, None, None, "nominal"); diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index f94438f9f..24cca45f1 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -19,7 +19,7 @@ use std::any::Any; use std::collections::HashMap; use super::data::{dataframe_to_values, dataframe_to_values_with_bins, ROW_INDEX_COLUMN}; -use super::encoding::RenderContext; +use super::encoding::{offset_encoding, RenderContext}; // ============================================================================= // Basic Geom Utilities @@ -55,17 +55,14 @@ pub fn geom_to_mark(geom: &Geom) -> Value { }) } -/// Map a `side` value to a positive/negative sign in the orientation-aware way -/// shared by violin, boxplot, and (effectively) jitter rendering. Returns true -/// if `side` falls on the positive offset half (right/top of a vertical layer, -/// bottom/left of a horizontal layer). Caller is responsible for handling -/// `"both"` separately. -fn side_is_positive(side: &str, is_horizontal: bool) -> bool { - if is_horizontal { - matches!(side, "bottom" | "left") - } else { - matches!(side, "top" | "right") - } +/// Whether a `side` value falls on the positive half of the band, in the +/// positive-right / positive-up convention every ggsql offset uses. Shared by +/// violin and boxplot rendering; the caller handles `"both"` separately. +/// +/// One predicate serves both orientations because the axis flip lives in the +/// offset channel's scale domain (see [`offset_encoding`]), not in the sign. +fn side_is_positive(side: &str) -> bool { + matches!(side, "top" | "right") } /// Validate column references for a single layer against its specific DataFrame @@ -1682,7 +1679,8 @@ impl GeomRenderer for ViolinRenderer { ) -> Result<()> { layer_spec["mark"] = json!({ "type": "line", - "filled": true + "filled": true, + "clip": true }); let offset_col = naming::aesthetic_column("offset"); @@ -1692,7 +1690,7 @@ impl GeomRenderer for ViolinRenderer { // It'll be implemented as an offset. let violin_offset = match layer.parameters.get("side") { Some(ParameterValue::String(side)) if side != "both" => { - if side_is_positive(side, is_horizontal) { + if side_is_positive(side) { format!("[datum.{offset}]", offset = offset_col) } else { format!("[-datum.{offset}]", offset = offset_col) @@ -1846,7 +1844,8 @@ impl GeomRenderer for ViolinRenderer { } } - // Offset channel based on orientation + // Offset channel based on orientation: the categorical axis is pos2 for + // a horizontal violin, pos1 otherwise. let offset_channel = if is_horizontal { pos2_offset } else { @@ -1854,13 +1853,7 @@ impl GeomRenderer for ViolinRenderer { }; encoding.insert( offset_channel.clone(), - json!({ - "field": "__final_offset", - "type": "quantitative", - "scale": { - "domain": [-0.5, 0.5] - } - }), + offset_encoding("__final_offset", is_horizontal), ); encoding.insert( "order".to_string(), @@ -2196,7 +2189,7 @@ impl BoxplotRenderer { }) .unwrap_or("both"); let half_side = side != "both"; - let side_positive = half_side && side_is_positive(side, is_horizontal); + let side_positive = half_side && side_is_positive(side); // For `side != "both"`, halve the bar width and shift the bar to // one side of the band. We reuse the same mechanism dodge already @@ -2258,11 +2251,8 @@ impl BoxplotRenderer { })); layer_spec["transform"] = json!(transforms); - layer_spec["encoding"][offset_channel] = json!({ - "field": combined_offset_col, - "type": "quantitative", - "scale": {"domain": [-0.5, 0.5]}, - }); + layer_spec["encoding"][offset_channel] = + offset_encoding(&combined_offset_col, is_horizontal); }; // Box (bar from y to y2, where y=q1 and y2=q3) @@ -2355,11 +2345,8 @@ impl BoxplotRenderer { "as": hinge_offset_col, })); layer_spec["transform"] = json!(transforms); - layer_spec["encoding"][offset_channel] = json!({ - "field": hinge_offset_col, - "type": "quantitative", - "scale": {"domain": [-0.5, 0.5]}, - }); + layer_spec["encoding"][offset_channel] = + offset_encoding(hinge_offset_col, is_horizontal); }; apply_hinge_offset(&mut lower_hinge); apply_hinge_offset(&mut upper_hinge); @@ -4062,25 +4049,27 @@ mod tests { format!("[datum.{}]", offset_col) ); - // Horizontal orientation: x=quantitative, y=nominal - // "bottom" and "left" - only positive offset + // Horizontal orientation: x=quantitative, y=nominal. The sign follows + // ggsql's positive-up convention in either orientation — the yOffset + // scale's reversed domain is what points the half-violin upward. + // "bottom" and "left" - only negative offset assert_eq!( get_violin_offset_expr(Some("bottom"), true), - format!("[datum.{}]", offset_col) + format!("[-datum.{}]", offset_col) ); assert_eq!( get_violin_offset_expr(Some("left"), true), - format!("[datum.{}]", offset_col) + format!("[-datum.{}]", offset_col) ); - // "top" and "right" - only negative offset + // "top" and "right" - only positive offset assert_eq!( get_violin_offset_expr(Some("top"), true), - format!("[-datum.{}]", offset_col) + format!("[datum.{}]", offset_col) ); assert_eq!( get_violin_offset_expr(Some("right"), true), - format!("[-datum.{}]", offset_col) + format!("[datum.{}]", offset_col) ); } @@ -4253,35 +4242,42 @@ mod tests { let (box_h, _, _, _, _) = render_marks(None, true); assert_eq!(extract_side_shift(&box_h), 0.0); - // Horizontal "bottom" / "left" → positive (per violin convention, - // mapped to positive yOffset which renders below the centerline). + // Horizontal "bottom" / "left" → negative, as in the vertical case: the + // shift is expressed in ggsql's positive-up convention whatever the + // orientation, and the yOffset scale's reversed domain is what turns it + // into a downward one. for s in ["bottom", "left"] { let (b, m, _, _, _) = render_marks(Some(s), true); assert!( - (extract_side_shift(&b) - shift_mag).abs() < 1e-9, - "side={s}: expected +{shift_mag}" + (extract_side_shift(&b) + shift_mag).abs() < 1e-9, + "side={s}: expected -{shift_mag}" ); assert!( - (extract_side_shift(&m) - shift_mag).abs() < 1e-9, + (extract_side_shift(&m) + shift_mag).abs() < 1e-9, "side={s} median" ); - // Horizontal uses yOffset, not xOffset. + // Horizontal uses yOffset, not xOffset, and reverses its domain. assert_eq!( b["encoding"]["yOffset"]["field"], json!("__ggsql_box_side_offset__"), "side={s}" ); + assert_eq!( + b["encoding"]["yOffset"]["scale"]["domain"], + json!([0.5, -0.5]), + "side={s}" + ); } - // Horizontal "top" / "right" → negative. + // Horizontal "top" / "right" → positive. for s in ["top", "right"] { let (b, m, _, _, _) = render_marks(Some(s), true); assert!( - (extract_side_shift(&b) + shift_mag).abs() < 1e-9, - "side={s}: expected -{shift_mag}" + (extract_side_shift(&b) - shift_mag).abs() < 1e-9, + "side={s}: expected +{shift_mag}" ); assert!( - (extract_side_shift(&m) + shift_mag).abs() < 1e-9, + (extract_side_shift(&m) - shift_mag).abs() < 1e-9, "side={s} median" ); } diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index e7e62b882..5e462048a 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -27,7 +27,7 @@ mod projection; use crate::plot::ArrayElement; use crate::plot::{ParameterValue, Parameters, Scale, ScaleTypeKind}; -use crate::writer::Writer; +use crate::writer::{Writer, WriterOptions}; use crate::{naming, AestheticValue, DataFrame, GgsqlError, Plot, Result}; use serde_json::{json, Value}; use std::collections::HashMap; @@ -201,12 +201,16 @@ fn build_layers( })); } + // Build encoding for this layer. Some encodings need a `calculate` of their + // own — an identity-scaled column is converted per row — and those run after + // the source filter, so they are appended to the same array. + let (mut encoding, encoding_transforms) = + build_layer_encoding(layer, df, spec, projection)?; + transforms.extend(encoding_transforms); + // Set transform array on layer spec layer_spec["transform"] = json!(transforms); - // Build encoding for this layer - let mut encoding = build_layer_encoding(layer, df, spec, projection)?; - // For point marks, remove fill: null from encoding — Vega-Lite point marks // are unfilled by default, so omitting it achieves the same visual result // without making legend symbols (e.g., size) invisible. Other mark types @@ -245,13 +249,17 @@ fn build_layers( /// /// The `projection` determines how internal position aesthetics (pos1, pos2) are /// mapped to Vega-Lite encoding channel names (x/y for cartesian, theta/radius for polar). +/// +/// Returns the encoding plus any `calculate` transforms its channels depend on (see +/// `EncodingContext::transforms`), which the caller must add to the layer. fn build_layer_encoding( layer: &crate::plot::Layer, df: &DataFrame, spec: &Plot, projection: &dyn ProjectionRenderer, -) -> Result> { +) -> Result<(serde_json::Map, Vec)> { let mut encoding = serde_json::Map::new(); + let mut transforms: Vec = Vec::new(); // Get aesthetic context for name transformation let aesthetic_ctx = spec.get_aesthetic_context(); @@ -280,6 +288,7 @@ fn build_layer_encoding( spec, titled_families: &mut titled_families, primary_aesthetics: &primary_aesthetics, + transforms: &mut transforms, }; // Build encoding channels for each aesthetic mapping @@ -355,37 +364,23 @@ fn build_layer_encoding( encoding::RenderContext::new(&spec.scales, projection, spec.get_aesthetic_context()); let (_, _, pos1_offset, pos2, _, pos2_offset) = &context.channels; - // Add pos1 offset encoding for dodged positions (pos1offset column) - // This column is created by position::apply_dodge() for Position::Dodge - // The offset values are centered around 0 (e.g., -0.3, 0, +0.3 for 3 groups) - // We set domain [-0.5, 0.5] to ensure the scale is symmetric and maps to full band width + // Add the offset encodings for dodged and jittered positions (the + // pos1offset / pos2offset columns, created by the position adjustment). + // The offset values are centered around 0 (e.g., -0.3, 0, +0.3 for 3 + // groups) and read as band fractions — see `encoding::offset_encoding`. let pos1offset_col = naming::aesthetic_column("pos1offset"); if df.column(&pos1offset_col).is_ok() { encoding.insert( pos1_offset.clone(), - json!({ - "field": pos1offset_col, - "type": "quantitative", - "scale": { - "domain": [-0.5, 0.5] - } - }), + encoding::offset_encoding(&pos1offset_col, false), ); } - // Add pos2 offset encoding for vertical jitter (pos2offset column) - // This column is created by position::Jitter when pos2 axis is discrete let pos2offset_col = naming::aesthetic_column("pos2offset"); if df.column(&pos2offset_col).is_ok() { encoding.insert( pos2_offset.clone(), - json!({ - "field": pos2offset_col, - "type": "quantitative", - "scale": { - "domain": [-0.5, 0.5] - } - }), + encoding::offset_encoding(&pos2offset_col, true), ); } @@ -406,7 +401,7 @@ fn build_layer_encoding( let renderer = get_renderer(&layer.geom); renderer.modify_encoding(&mut encoding, layer, &context)?; - Ok(encoding) + Ok((encoding, transforms)) } /// Apply faceting to Vega-Lite spec @@ -743,7 +738,7 @@ fn apply_facet_label_renaming( let label_expr = if is_binned { // For binned facets: reuse build_symbol_legend_label_mapping and build_label_expr - build_binned_facet_label_expr(label_mapping, scale) + build_binned_facet_label_expr(scale) } else { // For discrete facets: compare datum.value against string values build_discrete_facet_label_expr(label_mapping) @@ -753,98 +748,28 @@ fn apply_facet_label_renaming( facet_def["header"] = json!({ "labelExpr": label_expr }); } -/// Build labelExpr for binned facet values. -/// -/// For binned facets, `datum.value` contains the bin midpoint (e.g., 25 for bin [20-30)). -/// This function maps midpoint values to range-style labels like "Lower – Upper", -/// using custom labels from label_mapping when available. +/// Build a `labelExpr` naming each panel of a binned facet by its bin's range. /// -/// Unlike `build_symbol_legend_label_mapping` which maps Vega-Lite's auto-generated -/// range labels, this function maps numeric midpoints to our range labels. -fn build_binned_facet_label_expr( - label_mapping: Option<&HashMap>>, - scale: Option<&Scale>, -) -> String { +/// Unlike `build_symbol_legend_label_mapping`, which keys on Vega-Lite's +/// auto-generated range labels, this keys on the numeric midpoints the facet +/// column carries. +fn build_binned_facet_label_expr(scale: Option<&Scale>) -> String { let Some(scale) = scale else { return "datum.value".to_string(); }; - - let breaks = match scale.properties.get("breaks") { - Some(ParameterValue::Array(arr)) => arr, - _ => return "datum.value".to_string(), - }; - - if breaks.len() < 2 { - return "datum.value".to_string(); - } - - // Get closed property for determining open-format labels - let closed = scale - .properties - .get("closed") - .and_then(|v| match v { - ParameterValue::String(s) => Some(s.as_str()), - _ => None, + // A facet column carries each bin's midpoint, so the expression compares + // `datum.value` against those rather than against Vega-Lite's own range + // text. The labels themselves are ggsql's, shared with the symbol legend and + // the raster writer via `Scale::binned_bins`. + let midpoint_to_range: Vec<(String, Option)> = scale + .binned_bins() + .iter() + .filter_map(|bin| { + let midpoint = + calculate_midpoint_string(&bin.lower, &bin.upper, scale.transform.as_ref())?; + Some((midpoint, Some(bin.label.clone()))) }) - .unwrap_or("left"); - - let num_bins = breaks.len() - 1; - - // Build mapping from midpoint to range label - let mut midpoint_to_range: Vec<(String, Option)> = Vec::new(); - - for i in 0..num_bins { - let lower = &breaks[i]; - let upper = &breaks[i + 1]; - - // Calculate midpoint for comparison - let midpoint_str = calculate_midpoint_string(lower, upper, scale.transform.as_ref()); - let Some(midpoint_str) = midpoint_str else { - continue; - }; - - // Get break values as strings (for default labels) - let lower_str = lower.to_key_string(); - let upper_str = upper.to_key_string(); - - // Build the range label - let range_label = if let Some(label_mapping) = label_mapping { - // Check if terminals are suppressed - let lower_suppressed = label_mapping.get(&lower_str) == Some(&None); - let upper_suppressed = label_mapping.get(&upper_str) == Some(&None); - - // Get custom labels (fall back to break values) - let lower_label = label_mapping - .get(&lower_str) - .cloned() - .flatten() - .unwrap_or_else(|| lower_str.clone()); - let upper_label = label_mapping - .get(&upper_str) - .cloned() - .flatten() - .unwrap_or_else(|| upper_str.clone()); - - // Determine label format based on terminal suppression - if i == 0 && lower_suppressed { - // First bin with suppressed lower terminal → open format - let symbol = if closed == "right" { "≤" } else { "<" }; - Some(format!("{} {}", symbol, upper_label)) - } else if i == num_bins - 1 && upper_suppressed { - // Last bin with suppressed upper terminal → open format - let symbol = if closed == "right" { ">" } else { "≥" }; - Some(format!("{} {}", symbol, lower_label)) - } else { - // Standard range format: "lower – upper" - Some(format!("{} – {}", lower_label, upper_label)) - } - } else { - // No label mapping - use default range format with break values - Some(format!("{} – {}", lower_str, upper_str)) - }; - - midpoint_to_range.push((midpoint_str, range_label)); - } + .collect(); if midpoint_to_range.is_empty() { return "datum.value".to_string(); @@ -1070,6 +995,25 @@ impl VegaLiteWriter { "stroke": null, "fill": "#EBEBEB" }, + // A band fraction is a fraction of the full step, as in ggplot2 — + // so a bar at `width => 0.9` occupies 90% of its step and the gap + // between bars is the remaining 10%. Vega-Lite otherwise subtracts + // its own default `bandPaddingInner` from `bandwidth()` first, + // narrowing every banded mark a second time: bars, dodge and jitter + // spread, violin and boxplot widths, discrete tile extents. ggsql + // has no band-padding concept of its own, so pinning this to 0 is + // what makes the two writers agree on width. + // + // A band scale carrying a nested `xOffset`/`yOffset` — every dodged, + // jittered, half-boxplot or violin layer — takes its padding from + // `bandWithNestedOffsetPadding*` instead, which defaults to 0.2/0.2. + // Left alone it shrinks each offset to 80% of the fraction ggsql + // resolved *and* insets the band centres, moving the category ticks. + "scale": { + "bandPaddingInner": 0, + "bandWithNestedOffsetPaddingInner": 0, + "bandWithNestedOffsetPaddingOuter": 0 + }, "axis": { "domain": false, "grid": true, @@ -1124,6 +1068,13 @@ impl Default for VegaLiteWriter { impl Writer for VegaLiteWriter { type Output = String; + /// Vega-Lite output is resolution-independent — size, DPI and background are + /// the consumer's business — so this writer takes no options and rejects any. + fn from_options(options: &WriterOptions) -> Result { + options.reject_unknown(&[])?; + Ok(Self::new()) + } + fn write(&self, spec: &Plot, data: &HashMap) -> Result { // 1. Validate spec self.validate(spec)?; @@ -1287,6 +1238,25 @@ mod tests { .collect() } + /// A resolved binned scale, as `resolve` would leave one. + fn binned_scale( + breaks: Vec, + label_mapping: HashMap>, + closed: &str, + ) -> Scale { + let mut scale = Scale::new("fill"); + scale.scale_type = Some(crate::plot::ScaleType::binned()); + scale + .properties + .insert("breaks".to_string(), ParameterValue::Array(breaks)); + scale.properties.insert( + "closed".to_string(), + ParameterValue::String(closed.to_string()), + ); + scale.label_mapping = Some(label_mapping); + scale + } + fn rewrite_refs(val: &mut Value) { match val { Value::Object(obj) => { @@ -1747,6 +1717,187 @@ mod tests { assert_eq!(range[1].as_f64().unwrap(), 20.0 * POINTS_TO_PIXELS); } + /// An identity-scaled column is a per-row literal, so it must reach Vega-Lite + /// in the same unit a literal does: `size` as a radius in points, converted to + /// an area in px². The conversion is a `calculate` transform because Vega-Lite + /// has no per-datum arithmetic in an encoding. + #[test] + fn test_identity_column_converts_like_literal() { + use crate::plot::{Scale, ScaleType}; + + let writer = VegaLiteWriter::new(); + + let mut spec = Plot::new(); + spec.layers.push( + Layer::new(Geom::point()) + .with_aesthetic( + "pos1".to_string(), + AestheticValue::standard_column("x".to_string()), + ) + .with_aesthetic( + "pos2".to_string(), + AestheticValue::standard_column("y".to_string()), + ) + .with_aesthetic( + "size".to_string(), + AestheticValue::standard_column("radius".to_string()), + ), + ); + let mut scale = Scale::new("size"); + scale.scale_type = Some(ScaleType::identity()); + spec.scales.push(scale); + + let df = df! { + "x" => vec![1, 2], + "y" => vec![1, 2], + "radius" => vec![3.0, 3.0], + } + .unwrap(); + + let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); + let vl_spec: Value = serde_json::from_str(&json_str).unwrap(); + let layer = &vl_spec["layer"][0]; + + // The encoding reads the derived field, unscaled + let size = &layer["encoding"]["size"]; + assert_eq!(size["field"].as_str().unwrap(), "radius_visual"); + assert!(size["scale"].is_null(), "identity scale stays unscaled"); + + // ... which a calculate transform fills with the literal conversion + let calculate = layer["transform"] + .as_array() + .unwrap() + .iter() + .find_map(|t| t.get("calculate").and_then(|c| c.as_str())) + .expect("identity size needs a calculate transform"); + assert_eq!( + calculate, + format!("datum['radius'] * datum['radius'] * {POINTS_TO_AREA}") + ); + + // A radius of 3 pt is the same area whether it arrives as a column or a literal + let literal = build_spec_with_size_literal(&writer); + assert_eq!(3.0 * 3.0 * POINTS_TO_AREA, literal); + } + + /// A name-valued identity column (`shape`, `linetype`) needs the same name → + /// Vega-Lite value mapping a literal gets: without it `'star'` reaches Vega as a + /// symbol name it cannot parse, and `'dashed'` as a dash array it cannot read. + #[test] + fn test_identity_names_convert_like_literals() { + use crate::plot::scale::{linetype_to_stroke_dash, shape_to_svg_path}; + use crate::plot::{ArrayElement, Scale, ScaleType}; + + let writer = VegaLiteWriter::new(); + + for (aesthetic, geom, names) in [ + ("shape", Geom::point(), ["star", "square"]), + ("linetype", Geom::line(), ["dashed", "dotted"]), + ] { + let mut spec = Plot::new(); + spec.layers.push( + Layer::new(geom) + .with_aesthetic( + "pos1".to_string(), + AestheticValue::standard_column("x".to_string()), + ) + .with_aesthetic( + "pos2".to_string(), + AestheticValue::standard_column("y".to_string()), + ) + .with_aesthetic( + aesthetic.to_string(), + AestheticValue::standard_column("name".to_string()), + ), + ); + let mut scale = Scale::new(aesthetic); + scale.scale_type = Some(ScaleType::identity()); + scale.input_range = Some( + names + .iter() + .map(|n| ArrayElement::String(n.to_string())) + .collect(), + ); + spec.scales.push(scale); + + let df = df! { + "x" => vec![1, 2], + "y" => vec![1, 2], + "name" => names.to_vec(), + } + .unwrap(); + + let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); + let vl_spec: Value = serde_json::from_str(&json_str).unwrap(); + let layer = &vl_spec["layer"][0]; + + let calculate = layer["transform"] + .as_array() + .unwrap() + .iter() + .find_map(|t| t.get("calculate").and_then(|c| c.as_str())) + .unwrap_or_else(|| panic!("identity {aesthetic} needs a calculate transform")); + + // Every resolved name maps to the value its literal would have produced, + // and an unrecognised one falls through to the datum + for name in names { + let expected = match aesthetic { + "shape" => json!(shape_to_svg_path(name).unwrap()), + _ => json!(linetype_to_stroke_dash(name).unwrap()), + }; + assert!( + calculate.contains(&format!("== '{name}' ? {expected}")), + "{aesthetic}: {name} should map to {expected} in {calculate}" + ); + } + assert!( + calculate.ends_with("datum['name']"), + "{aesthetic}: unrecognised values should pass through: {calculate}" + ); + + let channel = if aesthetic == "shape" { + "shape" + } else { + "strokeDash" + }; + assert_eq!( + layer["encoding"][channel]["field"].as_str().unwrap(), + "name_visual" + ); + assert!(layer["encoding"][channel]["scale"].is_null()); + } + } + + /// The `size` value Vega-Lite receives for `SETTING size => 3`. + fn build_spec_with_size_literal(writer: &VegaLiteWriter) -> f64 { + let mut spec = Plot::new(); + let mut layer = Layer::new(Geom::point()) + .with_aesthetic( + "pos1".to_string(), + AestheticValue::standard_column("x".to_string()), + ) + .with_aesthetic( + "pos2".to_string(), + AestheticValue::standard_column("y".to_string()), + ); + layer.mappings.insert( + "size".to_string(), + AestheticValue::Literal(crate::plot::ParameterValue::Number(3.0)), + ); + spec.layers.push(layer); + + let df = df! { + "x" => vec![1, 2], + "y" => vec![1, 2], + } + .unwrap(); + let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); + let vl_spec: Value = serde_json::from_str(&json_str).unwrap(); + vl_spec["layer"][0]["encoding"]["size"]["value"] + .as_f64() + .unwrap() + } + #[test] fn test_literal_color() { let writer = VegaLiteWriter::new(); @@ -1936,7 +2087,8 @@ mod tests { label_mapping.insert("75".to_string(), Some("Very High".to_string())); label_mapping.insert("100".to_string(), Some("Max".to_string())); // Will be excluded - let result = build_symbol_legend_label_mapping(&breaks, &label_mapping, "left"); + let result = + build_symbol_legend_label_mapping(&binned_scale(breaks, label_mapping, "left")); // VL generates: "0 – 25", "25 – 50", "50 – 75", "≥ 75" // We map to range format using custom labels: "lower_label – upper_label" @@ -2070,7 +2222,11 @@ mod tests { label_mapping.insert("100".to_string(), None); // Suppressed // Test with closed='left' (default) - let result_left = build_symbol_legend_label_mapping(&breaks, &label_mapping, "left"); + let result_left = build_symbol_legend_label_mapping(&binned_scale( + breaks.clone(), + label_mapping.clone(), + "left", + )); // First bin: suppressed lower terminal → "< 25" (open format) assert_eq!( @@ -2086,7 +2242,8 @@ mod tests { ); // Test with closed='right' - let result_right = build_symbol_legend_label_mapping(&breaks, &label_mapping, "right"); + let result_right = + build_symbol_legend_label_mapping(&binned_scale(breaks, label_mapping, "right")); // First bin: suppressed lower terminal → "≤ 25" (right-closed means upper included) assert_eq!( @@ -2517,7 +2674,8 @@ mod tests { label_mapping.insert("40".to_string(), Some("High".to_string())); label_mapping.insert("60".to_string(), Some("Very High".to_string())); - let expr = build_binned_facet_label_expr(Some(&label_mapping), Some(&scale)); + scale.label_mapping = Some(label_mapping); + let expr = build_binned_facet_label_expr(Some(&scale)); // Should contain midpoint comparisons: // Bin [0, 20) -> midpoint 10 @@ -2580,7 +2738,8 @@ mod tests { label_mapping.insert("50".to_string(), Some("High".to_string())); label_mapping.insert("100".to_string(), Some("Max".to_string())); - let expr = build_binned_facet_label_expr(Some(&label_mapping), Some(&scale)); + scale.label_mapping = Some(label_mapping); + let expr = build_binned_facet_label_expr(Some(&scale)); // First bin with suppressed lower terminal → open format "< 50" or "< High" // (uses upper bound label since lower is suppressed) @@ -2615,7 +2774,7 @@ mod tests { ); // No label_mapping - should use break values in range format - let expr = build_binned_facet_label_expr(None, Some(&scale)); + let expr = build_binned_facet_label_expr(Some(&scale)); // Should use default range format with break values assert!(