From 2aeda828fd30c40cb764e75c03672f3ded92ad7a Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 22 Jun 2026 10:28:58 +0200 Subject: [PATCH 01/29] Setup hephaestus writer --- .github/workflows/build.yaml | 13 + Cargo.lock | 1052 ++++++++++++++++++++++++++- src/Cargo.toml | 5 + src/writer/hephaestus/PLAN.md | 275 +++++++ src/writer/hephaestus/channels.rs | 32 + src/writer/hephaestus/geom/mod.rs | 4 + src/writer/hephaestus/geom/point.rs | 17 + src/writer/hephaestus/mod.rs | 286 ++++++++ src/writer/hephaestus/scales.rs | 49 ++ src/writer/mod.rs | 6 + 10 files changed, 1723 insertions(+), 16 deletions(-) create mode 100644 src/writer/hephaestus/PLAN.md create mode 100644 src/writer/hephaestus/channels.rs create mode 100644 src/writer/hephaestus/geom/mod.rs create mode 100644 src/writer/hephaestus/geom/point.rs create mode 100644 src/writer/hephaestus/mod.rs create mode 100644 src/writer/hephaestus/scales.rs diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 17f0fa7e7..cce287633 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -37,6 +37,13 @@ jobs: # which invokes `protoc` at build time. Required for the ADBC test path. run: sudo apt-get install -y protobuf-compiler + - name: Install software Vulkan (lavapipe) + # The hephaestus 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. + run: sudo apt-get install -y mesa-vulkan-drivers + - 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 +105,12 @@ jobs: - name: Run ADBC SQLite equivalence tests run: cargo +stable test --features "adbc sqlite" --lib -- --ignored equivalence + - name: Run hephaestus writer tests + # Non-default feature. The hephaestus git crate 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 hephaestus --lib writer::hephaestus + - name: Build WASM library working-directory: ggsql-wasm/library run: npm install && npm run build diff --git a/Cargo.lock b/Cargo.lock index 0acea443d..e53d2dcf5 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.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119b9306cc17492f50224736a2b2595543665c3095ba40c04c5c5181d53678bc" +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,37 @@ 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 = "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", + "roxmltree", + "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1932,6 +2088,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,10 +2246,12 @@ dependencies = [ "csscolorparser", "duckdb", "geozero", + "hephaestus", "jsonschema", "libloading", "palette", "parquet", + "png", "rand 0.8.6", "regex", "rusqlite", @@ -2151,12 +2320,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 +2414,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", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2209,6 +2467,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 +2495,36 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hephaestus" +version = "0.0.1" +source = "git+https://github.com/posit-dev/hephaestus.git?rev=ecce261845751969a12fa59c74c11256e8df2a41#ecce261845751969a12fa59c74c11256e8df2a41" +dependencies = [ + "bytemuck", + "clipper2-rust", + "futures-intrusive", + "kurbo", + "parley", + "peniko", + "png", + "pollster", + "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 +2676,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 +2699,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 +2759,8 @@ checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2459,6 +2768,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 +2861,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 +2938,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 +2970,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 +3124,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 +3199,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 +3245,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 +3369,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 +3488,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 +3549,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", +] + +[[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 +3659,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" @@ -3180,26 +3743,69 @@ dependencies = [ ] [[package]] -name = "phf_shared" -version = "0.13.1" +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", + "uncased", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +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 = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" dependencies = [ - "siphasher", - "uncased", + "arrayvec", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "portable-atomic" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] -name = "pkg-config" -version = "0.3.33" +name = "portable-atomic-util" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] [[package]] name = "potential_utf" @@ -3207,6 +3813,8 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -3219,6 +3827,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 +3861,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" @@ -3329,7 +3949,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "socket2", "thiserror 2.0.18", @@ -3349,7 +3969,7 @@ dependencies = [ "lru-slab", "rand 0.9.4", "ring", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "slab", @@ -3459,6 +4079,40 @@ 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", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3551,6 +4205,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 +4294,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 +4346,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 +4684,31 @@ 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", +] + [[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 +4731,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 +4787,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 +4863,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 +4941,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 +5023,7 @@ checksum = "7e54bc85fc7faa8bc175c4bab5b92ba8d9a3ce893d0e9f42cc455c8ab16a9e09" dependencies = [ "byteorder", "integer-encoding", - "ordered-float", + "ordered-float 2.10.1", ] [[package]] @@ -4318,6 +5042,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -4821,6 +5546,51 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vello" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261359dbef879f8110ef7e1c442246c838d33d3d91cb05e0ea9288d432760c9f" +dependencies = [ + "bytemuck", + "futures-intrusive", + "log", + "peniko", + "png", + "skrifa", + "static_assertions", + "thiserror 2.0.18", + "vello_encoding", + "vello_shaders", + "wgpu", +] + +[[package]] +name = "vello_encoding" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2346f5f0d7dccb3582fcd397b4a57b43165209f1424e0d76e85dd814db164af7" +dependencies = [ + "bytemuck", + "guillotiere", + "peniko", + "skrifa", + "smallvec", +] + +[[package]] +name = "vello_shaders" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd38937516fa4b47423d9255bb5e4a65e839ec9d57c38c4af6189ce56bf46b7" +dependencies = [ + "bytemuck", + "log", + "naga", + "thiserror 2.0.18", + "vello_encoding", +] + [[package]] name = "version_check" version = "0.9.5" @@ -4966,6 +5736,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 +5777,174 @@ 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", + "parking_lot", + "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-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-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 +5976,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 +6010,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 +6049,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 +6157,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 +6411,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 +6534,7 @@ dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -5524,6 +6543,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/src/Cargo.toml b/src/Cargo.toml index 95614b06d..5d6f6e274 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -39,6 +39,10 @@ adbc_core = { version = "0.23", optional = true } # Spatial geozero = { workspace = true, optional = true, features = ["with-wkb", "with-wkt", "with-geojson"] } +# Hephaestus raster writer (non-default; gated, excluded from the MSRV 1.86 build) +hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "ecce261845751969a12fa59c74c11256e8df2a41", optional = true, default-features = false, features = ["vello", "png", "text"] } +png = { version = "0.18", optional = true } + # Serialization serde.workspace = true serde_json.workspace = true @@ -70,5 +74,6 @@ adbc = ["dep:adbc_core"] odbc = ["dep:toml_edit", "dep:libloading"] spatial = ["dep:geozero", "rusqlite?/load_extension"] vegalite = [] +hephaestus = ["dep:hephaestus", "dep:png"] builtin-data = [] all-readers = ["duckdb", "sqlite", "odbc"] diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md new file mode 100644 index 000000000..cc4b6faa8 --- /dev/null +++ b/src/writer/hephaestus/PLAN.md @@ -0,0 +1,275 @@ +# Plan: a Hephaestus writer for ggsql + +A second `Writer` implementation that renders a resolved ggsql `Spec` to raster +output via [`hephaestus`](https://github.com/posit-dev/hephaestus) — a +backend-agnostic 2D scene renderer with a high-level grammar-of-graphics plot +API. Intended as the eventual default writer, replacing the Vega-Lite JSON +writer. + +Status: **planning**. No code written yet. This document is the design of +record; update it as decisions land. + +## 1. Why this is a good fit + +ggsql and hephaestus are architecturally complementary and the seam is clean: + +| ggsql produces (resolved `Spec`) | hephaestus consumes | +| --- | --- | +| Per-layer `DataFrame`s with **raw values** in internal columns (`__ggsql_aes_pos1__`, …) | Geoms hold **raw columnar `Value` data** and map through scales at draw time | +| Fully-resolved `Scale`s (type, domain, transform, breaks, formatted labels, palette) | `Scale` with `domain_*`, `with_transform`, `with_breaks_labeled`, `with_format`, `range_colors` | +| Scale-type enum: Continuous/Discrete/Binned/Ordinal/Identity | **Identical** enum: Continuous/Discrete/Binned/Ordinal/Identity | +| Transforms: log/log2/sqrt/square/asinh/pseudo_log/… | **Identical** `TransformKind` set (`PseudoLog` doc'd as "matching ggsql's pseudo_log") | +| Coords: Cartesian / Polar / Map (pre-projected in SQL) | `Projection::Cartesian` / `Polar(PolarProjection)` / `Custom(CustomProjection)` | +| Facets: Wrap / Grid | `composition::{grid, beside, stack}` + `PlotComposition` orchestrator | + +Decisive point: **ggsql resolves scale config but does not apply scales** — the +DataFrame still holds raw values, and value→pixel mapping was always the +renderer's job (Vega-Lite did it; hephaestus does it too). We feed hephaestus +the raw columns plus `Scale` objects built from ggsql's resolved config. No +data is recomputed. + +Confirmed during investigation: +- hephaestus already implements the **full transform set** ggsql needs + (`src/scales/transform.rs`: `Log10/Log2/Log/Sqrt/Square/Exp*/Asinh/PseudoLog*`). + The "Identity-only in v1" note in its scales doc is stale. +- ggsql resolves palettes → colors **in the writer**, not earlier + (`src/writer/vegalite/encoding.rs:501` via `lookup_palette` in + `src/plot/scale/palettes.rs`). The hephaestus writer reuses the same helper. +- hephaestus `Scale::with_breaks_labeled(Vec<(Value, String)>)` and + `with_format(closure)` accept ggsql's resolved breaks + formatted labels + directly (`src/plot/scale/mod.rs`). +- hephaestus has every geom ggsql needs (Point/Line/Rect/Ribbon/Polygon/ + Segment/Wedge/Ellipse/Geometry/Text + composites). + +## 2. Constraints & risks (settle before / during Phase 1) + +In priority order: + +1. **MSRV conflict — the big one.** ggsql MSRV is **Rust 1.86** (CRAN-locked, + enforced in CI per root `CLAUDE.md`). hephaestus declares + `rust-version = "1.88"` and pulls `wgpu`/`vello`. This is the same situation + the repo already handles for the `adbc` test path and `ggsql-wasm`: the + feature must be **gated and excluded from the MSRV-enforced build** and out + of the path the R bindings compile. + +2. **GPU + heavy deps.** The only working hephaestus backend today is `vello` + (wgpu/GPU); `svg`/`pdf`/`blend2d` are declared placeholders with no code. + Implications: + - Output is **raster** (RGBA8 → PNG) for the foreseeable future. No SVG/PDF. + - Needs a GPU adapter at render time; headless CI/containers need a software + adapter (e.g. `llvmpipe`). Document this operational footgun. + - Not viable for the R/CRAN target; not the default for `ggsql-wasm` (wasm + can run wgpu but it is a separate, heavy build). + +3. **hephaestus maturity.** `version = "0.0.1"`, `publish = false`. Consume as a + **git rev or path dependency**; API is pre-1.0 and will churn. Pin a commit. + +4. **`text` feature required.** All chrome (axes, legends, titles) and text + geoms are gated on hephaestus's `text` feature (parley shaper). Turn it on. + The shaper is "scaffolding, meant to be replaced by the host" — acceptable + for v1; flag as future work. + +**Recommendation:** ship behind a non-default `hephaestus` cargo feature in +`src/Cargo.toml`; mirror the `adbc` CI exemption (build/test with `cargo ++stable`, keep out of the 1.86 job); depend on hephaestus with +`features = ["vello", "png", "text"]`. + +**Answers from author** + +follow recommendations + +## 3. Architecture + +The Vega-Lite writer flattens everything into one declarative JSON document and +lets the Vega-Lite runtime do layout + scale application. hephaestus **is** the +runtime, so this writer's job is to build a `PlotComposition` and render it, not +emit a spec. + +``` +HephaestusWriter::render(&Spec) + ├─ build_scale_registry(plot.scales) → one hephaestus Scale per aesthetic ("pos1","pos2","color"…) + ├─ build_composition(plot.facet, data) → Composition (Patches) — "our own faceting" + ├─ for each panel (facet cell): + │ Plot::new(patch_id) + │ .bind("x","pos1").bind("y","pos2")… ← channel→scale-name bindings (projection-aware) + │ for each ggsql Layer: + │ GeomRenderer::build(layer, panel_df) → Vec> + channel bindings + │ set projection (Cartesian/Polar/Custom) + │ add axes / legend / titles from Labels + scales + ├─ PlotComposition::render(scene, size, dpi) + └─ VelloRenderer → RGBA8 → PNG bytes +``` + +### Core trait — analog of the VL `GeomRenderer` + +```rust +/// Translates one ggsql Layer (for one facet panel's data slice) into +/// hephaestus geoms plus the channel→scale-name bindings they need. +trait GeomRenderer { + /// Channel bindings this geom contributes, e.g. [("x","pos1"),("y","pos2"),("fill","color")]. + fn bindings(&self, layer: &Layer, ctx: &RenderCtx) -> Vec<(String, String)>; + /// Build the concrete geom(s) from the panel DataFrame. + fn build(&self, layer: &Layer, df: &DataFrame, ctx: &RenderCtx) -> Result>>; +} +``` + +`RenderCtx` carries the `AestheticContext` (user↔internal name map), the +projection kind, and the theme. One impl per `GeomType`, dispatched from a +factory — mirrors the VL `GeomRenderer` registry. + +### Channel-name translation + +Scales live in the hephaestus `ScaleRegistry` keyed by the **ggsql aesthetic** +(`pos1`, `color`, …); each `Plot` binds its channel (`x`) to that scale name +(`pos1`). Two panels binding `x→pos1` share one scale — hephaestus's fixed-scale +faceting model. + +| ggsql internal | hephaestus channel (Cartesian) | (Polar) | +| --- | --- | --- | +| `pos1` | `x` | `theta` | +| `pos2` | `y` | `radius` | +| `pos1min/max`, `pos2min/max` | `x`/`x2`, `y`/`y2` (ribbon/range) | … | +| `pos1end`, `pos2end` | `x1`/`y1` (segment) | … | +| `color`/`fill`/`stroke`/`size`/`shape`/`linetype` | same | same | + +## 4. Component mapping + +**Geoms** (`GeomType` → hephaestus geom): + +| ggsql | hephaestus | notes | +| --- | --- | --- | +| point | `PointGeom` | direct | +| line, path | `LineGeom` | path ordered by `__ggsql_order__` | +| bar, histogram, tile | `RectGeom` | x/x2/y/y2 from pos extents | +| area, ribbon | `RibbonGeom` | orientation from which of x2/y2 present | +| polygon | `PolygonGeom` | | +| segment, rule | `SegmentGeom` | | +| arrow | `SegmentGeom` + endpoint marker (`ShapeRegistry`) | | +| range | `SegmentGeom` or `RibbonGeom` | | +| boxplot | composite: `RectGeom`+`SegmentGeom`(+`PointGeom` outliers) | multiple geoms, like VL `PreparedData::Composite` | +| violin | `PolygonGeom`/`RibbonGeom` | | +| density, smooth | `LineGeom` (+ `RibbonGeom` for CI) | | +| text | `TextGeom` | needs `text` feature | +| spatial | `GeometryGeom` | WKB/WKT via `geom-*` features | +| (polar bar / pie) | `WedgeGeom` | when projection is Polar | + +**Scales:** map `ScaleTypeKind` 1:1; `scale.numeric_domain()`→`domain_continuous`, +discrete categories→`domain_discrete`; `scale.break_labels()`→`with_breaks_labeled` +(bake ggsql's formatted strings in directly — simplest way to preserve ggsql's +label semantics exactly); `OutputRange::Palette` → reuse `lookup_palette()` → +`range_colors`; transforms map by name to `TransformKind`. + +**Coords:** Cartesian→`Projection::Cartesian`; Polar→`Projection::Polar` +(`full_circle`/`gauge`/`radar` from properties); Map→`Projection::Custom` fed +the pre-projected `panel_boundary`/`bbox` from `Projection.computed` (same data +the VL `MapProjection` reads — coordinates already projected in SQL). + +**Faceting (the "roll our own" piece):** read `FacetLayout`. Group panel rows by +`__ggsql_aes_facet1__`/`facet2__`. Build the `Composition`: `Wrap` → +`grid(nrow, ncol, patches)` (ncol from properties); `Grid` → nested `grid` +indexed by (row-var, col-var). Strip labels via `Slot::StripTop`/`StripLeft`. +`scales: fixed|free_x|free_y|free` decides whether panels share one scale per +aesthetic (fixed) or get per-panel scales (free) in the registry. + +**Labels/titles/theme:** `plot.labels` → `Plot::set_title/subtitle`, axis titles +via `Axis::title`, legend titles. ggsql has **no theme concept**, so v1 picks a +hephaestus default theme; expose theme selection later. + +## 5. New module layout + +``` +src/writer/hephaestus/ +├── mod.rs HephaestusWriter (config: size, dpi, bg, theme), Writer impl, render orchestration +├── CLAUDE.md architecture doc (mirror the vegalite one) +├── scales.rs ggsql Scale → hephaestus Scale; palette/transform/break mapping +├── composition.rs FacetLayout → Composition; panel data splitting (our faceting) +├── channels.rs aesthetic↔channel translation (projection-aware) +├── layer.rs GeomRenderer trait + factory + RenderCtx +├── geom/ one renderer per GeomType (point.rs, line.rs, rect.rs, ribbon.rs, boxplot.rs, …) +└── projection.rs Coord → hephaestus Projection (cartesian/polar/custom-map) +``` + +`HephaestusWriter` is a *configured* writer (width/height/dpi/background/theme) — +hephaestus rendering needs a target size, unlike the resolution-independent VL +JSON. Output type: see Decision 2. + +Wire-up: feature `hephaestus` in `src/Cargo.toml`; `pub mod hephaestus` + +`pub use HephaestusWriter` under that gate in `src/writer/mod.rs`; CLI +`--format`/output-extension routing in `ggsql-cli`; CI exemption matching +`adbc`. + +## 6. Phased implementation + +1. **Spike / skeleton.** Gated dependency + feature. `HephaestusWriter` that + renders a single-panel, single-`point`-layer, Cartesian, fixed-scale plot to + PNG. Proves the dep/MSRV/GPU path end-to-end. (`scales.rs` continuous-only, + `channels.rs`, `geom/point.rs`.) +2. **Scales & axes.** All scale types + transforms + palettes + breaks/labels; + Cartesian axes, gridlines, axis titles; discrete/binned/ordinal; legends for + color/size/shape. +3. **Geom coverage.** line/path, rect family (bar/histogram/tile), ribbon/area, + polygon, segment/rule/arrow/range, text; composite boxplot/violin; + density/smooth. +4. **Faceting.** Wrap + Grid via `composition`; fixed vs free scales; strip + labels; composition-level title. +5. **Projections.** Polar (pie/rose/radar), then Map via `Custom` projection + + pre-projected geometry + clip boundary. +6. **Polish.** Theme defaults, title/subtitle/caption, snapshot (PNG) tests, + CLAUDE.md, CHANGELOG entry. Decide default-writer switchover criteria. + +Each phase ends with `cargo fmt` + `cargo clippy` (per CLAUDE.md) and visual +inspection against the equivalent Vega-Lite output. + +## 7. Decisions (resolved) + +1. **Dependency mode** — pinned **git rev** of `posit-dev/hephaestus` (now a + public repo, so CI fetches it with no credentials). +2. **Output type** — `Output = Vec` (PNG bytes); ggsql encodes hephaestus's + RGBA buffer via the `png` crate. +3. **First milestone** — the Phase 1 spike (this is implemented; see below). +4. **Default-writer ambition** — land the gated alternative now, promote later. + +## Phase 1 — status: implemented + +Single-panel, single `point` layer, Cartesian, continuous scales with +bottom/left axes → PNG bytes, behind the non-default `hephaestus` feature. + +- Module: `src/writer/hephaestus/{mod.rs, scales.rs, channels.rs, geom/point.rs}`. +- Dep + feature in `src/Cargo.toml`; gate in `src/writer/mod.rs`; stable-only + CI step (+ lavapipe install) in `.github/workflows/build.yaml`. +- Verified: feature build, default build (hephaestus absent), `cargo +1.86` + build (MSRV intact), fmt, clippy, tests (render + reject), output eyeballed. + +Hephaestus deficiencies noted during Phase 1 (for upstream): +- `png::write_png` is file-only — no in-memory PNG/RGBA-bytes API, so each host + re-implements byte encoding. +- `render_to_buffer` returns premultiplied RGBA (fine for opaque backgrounds; + transparent ones need un-premultiplying before PNG encode). +- `src/scales/` docs claim transforms are Identity-only, but `TransformKind` + already implements the full Log/Sqrt/PseudoLog set — stale doc. + +## 8. Key source references + +ggsql: +- `src/writer/mod.rs` — `Writer` trait (`type Output`, `write`, `render`). +- `src/writer/vegalite/` — the writer to mirror; `layer.rs` `GeomRenderer`, + `projection/` `ProjectionRenderer`, `encoding.rs:501` palette resolution. +- `src/reader/spec.rs` — `Spec` accessors (`plot()`, `data()`, `layer_data()`). +- `src/naming.rs` — `__ggsql_*__` column conventions; data keys. +- `src/plot/` — `Plot`, `Layer`, `Geom`/`GeomType`, `Scale`/`ScaleTypeKind`, + `Facet`/`FacetLayout`, `Projection`/`CoordKind`, `Labels`, `AestheticContext`. +- `src/plot/scale/palettes.rs` — `lookup_palette()`. + +hephaestus (`~/GitHub/hephaestus`): +- `src/plot/composition.rs` — `PlotComposition` orchestrator, `render`. +- `src/plot/plot.rs` — `Plot`, `bind`, `add_geom`, chrome. +- `src/plot/scale/mod.rs` + `constructors.rs` — `Scale`, `ScaleRegistry`. +- `src/plot/geom/` — concrete geoms + `Geom`/`GeomBuilder` traits. +- `src/plot/chrome/axis.rs` — `Axis`, `AxisPlacement`. +- `src/plot/projection.rs` — `Projection::{Cartesian, Polar, Custom}`. +- `src/composition/` — `Composition`, `Patch`, `grid`/`beside`/`stack`, `Slot`. +- `src/backend/vello/` — `VelloRenderer`; `src/png.rs` — PNG writer. + + + +[{"question":"How should ggsql depend on hephaestus while both are pre-1.0?","header":"Dependency mode","multiSelect":false,"options":[{"label":"Path dep (../hephaestus)","description":"Easiest for co-development; both crates evolve together locally. Not reproducible on CI/other machines without the sibling checkout."},{"label":"Pinned git rev","description":"Reproducible everywhere, CI-friendly. Requires bumping the rev to pick up hephaestus changes."}]},{"question":"What should the writer's Output type be?","header":"Output type","multiSelect":false,"options":[{"label":"PNG bytes only (Vec)","description":"Simplest; matches the existing text/bytes Writer pattern. Callers decode if they need pixels."},{"label":"Also expose raw RGBA8","description":"A RasterImage { width, height, rgba } struct so wasm/embedders can present without re-decoding a PNG."}]},{"question":"What's the right first deliverable to build after this plan?","header":"First milestone","multiSelect":false,"options":[{"label":"Phase 1 spike only","description":"Single point layer, Cartesian, fixed scale → PNG. Proves the dep/MSRV/GPU path before investing further."},{"label":"Through Phase 3","description":"Full single-panel coverage: all scales, axes, legends, and all geoms. Bigger first chunk, no faceting/projections yet."}]},{"question":"What's the ambition for this effort?","header":"Ambition","multiSelect":false,"options":[{"label":"Land gated alternative, promote later","description":"Ship behind the hephaestus feature, iterate to parity over time, flip the default in a later effort."},{"label":"Drive to parity + flip default","description":"Treat full Vega-Lite parity and becoming the default writer as the goal of this effort."}]}] diff --git a/src/writer/hephaestus/channels.rs b/src/writer/hephaestus/channels.rs new file mode 100644 index 000000000..6e2789049 --- /dev/null +++ b/src/writer/hephaestus/channels.rs @@ -0,0 +1,32 @@ +//! Bridging ggsql aesthetic mappings and DataFrame columns to the raw numeric +//! vectors hephaestus geoms consume. + +use arrow::array::Float64Array; +use arrow::datatypes::DataType; + +use crate::array_util::{as_f64, cast_array}; +use crate::{AestheticValue, DataFrame, Layer, Result}; + +/// 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, + } +} + +/// 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: &Float64Array = 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()) +} diff --git a/src/writer/hephaestus/geom/mod.rs b/src/writer/hephaestus/geom/mod.rs new file mode 100644 index 000000000..5f222536e --- /dev/null +++ b/src/writer/hephaestus/geom/mod.rs @@ -0,0 +1,4 @@ +//! ggsql geom → hephaestus geom translation. Phase 1 ships `point` only; +//! further geoms land in later phases (see `src/writer/hephaestus/PLAN.md`). + +pub mod point; diff --git a/src/writer/hephaestus/geom/point.rs b/src/writer/hephaestus/geom/point.rs new file mode 100644 index 000000000..9e4147d19 --- /dev/null +++ b/src/writer/hephaestus/geom/point.rs @@ -0,0 +1,17 @@ +//! `point` geom: one marker per row. + +use hephaestus::color::Color; +use hephaestus::plot::PointGeom; + +/// Build a `PointGeom` from x/y panel data with a constant fill and size. +/// +/// Phase 1 treats `fill`/`size` as constants; data- and literal-mapped visual +/// channels arrive in Phase 2. +pub fn build(xs: &[f64], ys: &[f64], fill: Color, size: f64) -> PointGeom { + PointGeom::builder() + .set("x", xs.to_vec()) + .set("y", ys.to_vec()) + .set("fill", fill) + .set("size", size) + .build() +} diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs new file mode 100644 index 000000000..721471b67 --- /dev/null +++ b/src/writer/hephaestus/mod.rs @@ -0,0 +1,286 @@ +//! Hephaestus raster writer. +//! +//! Renders a resolved ggsql `Spec` to PNG bytes via the [`hephaestus`] 2D scene +//! renderer. +//! +//! **Phase 1 scope** (see `src/writer/hephaestus/PLAN.md`): a single-panel, +//! single `point` layer in Cartesian coordinates with continuous position +//! scales and basic bottom/left axes. Unsupported specs are rejected by +//! [`HephaestusWriter::validate`] rather than rendered incorrectly. Further +//! geoms, scales, faceting and projections arrive in later phases. +//! +//! 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 geom; +mod scales; + +use std::collections::HashMap; + +use hephaestus::backend::vello::VelloRenderer; +use hephaestus::color::{rgb8, Color}; +use hephaestus::composition::{Composition, Patch, Span}; +use hephaestus::geometry::Size; +use hephaestus::plot::chrome::axis::{Axis, AxisPlacement}; +use hephaestus::plot::{Plot as HPlot, PlotComposition}; +use hephaestus::scales::chrome::AxisSide; +use hephaestus::Renderer; + +use crate::plot::layer::geom::GeomType; +use crate::plot::projection::coord::CoordKind; +use crate::plot::types::ParameterValue; +use crate::writer::Writer; +use crate::{AestheticValue, DataFrame, GgsqlError, Layer, Plot, Result}; + +/// Internal patch id for the single panel rendered in Phase 1. +const PANEL_ID: &str = "ggsql_panel"; +/// Fallback marker diameter (pt) when no `size` is supplied. +const DEFAULT_SIZE: f64 = 4.0; + +/// Writer that renders a ggsql plot to a PNG image via hephaestus. +/// +/// The writer is configured with a target pixel size and DPI because raster +/// rendering needs concrete dimensions, unlike the resolution-independent +/// Vega-Lite JSON writer. +pub struct HephaestusWriter { + width: u32, + height: u32, + dpi: f64, + background: Color, +} + +impl HephaestusWriter { + /// Create a writer for the given pixel dimensions and DPI, with a white + /// background. + pub fn new(width: u32, height: u32, dpi: f64) -> Self { + Self { + width, + height, + dpi, + background: rgb8(255, 255, 255), + } + } + + /// Set the background color used to clear the canvas before rendering. + pub fn background(mut self, color: Color) -> Self { + self.background = color; + self + } +} + +impl Writer for HephaestusWriter { + type Output = Vec; + + fn validate(&self, spec: &Plot) -> Result<()> { + if spec.facet.is_some() { + return Err(GgsqlError::WriterError( + "hephaestus writer (phase 1) does not support FACET yet".into(), + )); + } + if let Some(projection) = &spec.project { + if projection.coord.coord_kind() != CoordKind::Cartesian { + return Err(GgsqlError::WriterError( + "hephaestus writer (phase 1) supports only Cartesian coordinates".into(), + )); + } + } + if spec.layers.len() != 1 { + return Err(GgsqlError::WriterError(format!( + "hephaestus writer (phase 1) supports exactly one layer, got {}", + spec.layers.len() + ))); + } + let geom_type = spec.layers[0].geom.geom_type(); + if geom_type != GeomType::Point { + return Err(GgsqlError::WriterError(format!( + "hephaestus writer (phase 1) supports only the 'point' geom, got '{geom_type}'" + ))); + } + Ok(()) + } + + fn write(&self, spec: &Plot, data: &HashMap) -> Result { + self.validate(spec)?; + + let layer = &spec.layers[0]; + let df = layer_dataframe(layer, data)?; + + // Raw x / y data columns. + let x_col = channels::aesthetic_column_name(layer, "pos1") + .ok_or_else(|| GgsqlError::WriterError("point layer has no x (pos1) mapping".into()))?; + let y_col = channels::aesthetic_column_name(layer, "pos2") + .ok_or_else(|| GgsqlError::WriterError("point layer has no y (pos2) mapping".into()))?; + let xs = channels::column_to_f64(df, x_col)?; + let ys = channels::column_to_f64(df, y_col)?; + + // Continuous scales, with the data extent as a domain fallback. + let scale_x = scales::build_continuous(spec.find_scale("pos1"), extent(&xs)); + let scale_y = scales::build_continuous(spec.find_scale("pos2"), extent(&ys)); + + // Single-panel composition; the same shape feeds Plot::new and the + // orchestrator (the orchestrator rebuilds from its own copy). + let mut plot = HPlot::new(&single_panel(), PANEL_ID) + .bind("x", "pos1") + .bind("y", "pos2"); + plot.add_geom(geom::point::build( + &xs, + &ys, + rgb8(70, 120, 220), + literal_size(layer), + )); + plot.add_axis(Axis::rail( + "pos1", + AxisPlacement::Cartesian(AxisSide::Bottom), + )); + plot.add_axis(Axis::rail("pos2", AxisPlacement::Cartesian(AxisSide::Left))); + + let mut view = PlotComposition::new(single_panel()) + .add_scale("pos1", scale_x) + .add_scale("pos2", scale_y) + .with_plot(plot); + + let issues = view.validate(); + if !issues.is_empty() { + return Err(GgsqlError::WriterError(format!( + "hephaestus composition validation failed: {issues:?}" + ))); + } + + render_png( + &mut view, + self.width, + self.height, + self.dpi, + self.background, + ) + } +} + +/// The single-panel composition Phase 1 renders into. +fn single_panel() -> Composition { + Composition::empty(1, 1).place(1, 1, Span::cell(), Patch::new(PANEL_ID)) +} + +/// Look up the DataFrame backing a layer by its execution-assigned data key. +fn layer_dataframe<'a>( + layer: &Layer, + data: &'a HashMap, +) -> Result<&'a DataFrame> { + let key = layer.data_key.as_deref().unwrap_or("__ggsql_layer_0__"); + data.get(key) + .ok_or_else(|| GgsqlError::WriterError(format!("no data found for layer key '{key}'"))) +} + +/// A literal numeric `size` aesthetic, or the Phase 1 default. +fn literal_size(layer: &Layer) -> f64 { + match layer.mappings.get("size") { + Some(AestheticValue::Literal(ParameterValue::Number(n))) => *n, + _ => DEFAULT_SIZE, + } +} + +/// Finite (min, max) of the data, or `(0, 1)` when there are no finite values. +fn extent(values: &[f64]) -> (f64, f64) { + let mut min = f64::INFINITY; + let mut max = f64::NEG_INFINITY; + for &v in values { + if v.is_finite() { + min = min.min(v); + max = max.max(v); + } + } + if min <= max { + (min, max) + } else { + (0.0, 1.0) + } +} + +/// 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 hephaestus 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!("hephaestus render failed: {e}")))?; + + encode_png(width, height, &pixels) +} + +/// Encode a premultiplied RGBA8 buffer as PNG bytes. +fn encode_png(width: u32, height: u32, rgba: &[u8]) -> Result> { + let mut buf = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut buf, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut header = encoder + .write_header() + .map_err(|e| GgsqlError::WriterError(format!("PNG header write failed: {e}")))?; + header + .write_image_data(rgba) + .map_err(|e| GgsqlError::WriterError(format!("PNG data write failed: {e}")))?; + } + Ok(buf) +} + +#[cfg(all(test, feature = "duckdb"))] +mod tests { + use super::*; + use crate::reader::{DuckDBReader, Reader}; + + #[test] + fn renders_point_plot_to_png() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let spec = reader + .execute( + "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", + ) + .unwrap(); + let writer = HephaestusWriter::new(640, 480, 96.0); + match writer.render(&spec) { + 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") => { + // No wgpu adapter available (e.g. headless CI without a software + // rasteriser); the spec→composition path still exercised. Skip + // the render assertion rather than failing the suite. + eprintln!("skipping render assertion: {msg}"); + } + Err(e) => panic!("unexpected error rendering point plot: {e}"), + } + } + + #[test] + fn rejects_unsupported_geom() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let spec = reader + .execute( + "SELECT 1 AS x, 2 AS y UNION ALL SELECT 2, 3 \ + VISUALISE x AS x, y AS y DRAW line", + ) + .unwrap(); + let writer = HephaestusWriter::new(320, 240, 96.0); + assert!(matches!( + writer.validate(spec.plot()), + Err(GgsqlError::WriterError(_)) + )); + } +} diff --git a/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs new file mode 100644 index 000000000..0fd310d64 --- /dev/null +++ b/src/writer/hephaestus/scales.rs @@ -0,0 +1,49 @@ +//! Translating resolved ggsql scales into hephaestus scales. +//! +//! Phase 1 handles continuous position scales only. ggsql resolves the scale +//! configuration (domain, breaks, formatted labels); we hand those to +//! hephaestus, which performs the value→pixel mapping at draw time. + +use hephaestus::plot::scale::{self, Scale as HScale}; +use hephaestus::scales::value::Value as HValue; + +use crate::Scale; + +/// Build a hephaestus continuous scale from a resolved ggsql position scale. +/// +/// Uses the ggsql scale's resolved domain and formatted breaks when available, +/// falling back to the supplied data extent (and hephaestus's own break +/// selection) when the scale is absent or carries no real domain. +pub fn build_continuous(scale: Option<&Scale>, data_extent: (f64, f64)) -> HScale { + let usable = scale.filter(|s| !s.is_dummy()); + let (min, max) = usable + .and_then(|s| s.numeric_domain()) + .unwrap_or(data_extent); + let (min, max) = pad_degenerate(min, max); + + let mut hs = scale::continuous(min..=max); + if let Some(s) = usable { + let labels = s.break_labels(); + if !labels.is_empty() { + hs = hs.with_breaks_labeled( + labels + .into_iter() + .map(|(pos, label)| (HValue::Number(pos), label)) + .collect(), + ); + } + } + hs +} + +/// Widen a domain that is non-finite or zero-width into something a continuous +/// scale can map 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) +} diff --git a/src/writer/mod.rs b/src/writer/mod.rs index db1aa1d2f..e47edff8e 100644 --- a/src/writer/mod.rs +++ b/src/writer/mod.rs @@ -34,6 +34,12 @@ pub mod vegalite; #[cfg(feature = "vegalite")] pub use vegalite::VegaLiteWriter; +#[cfg(feature = "hephaestus")] +pub mod hephaestus; + +#[cfg(feature = "hephaestus")] +pub use hephaestus::HephaestusWriter; + /// Trait for visualization output writers /// /// Writers take a Plot and data sources and produce formatted output From 22a3af48b6f99562a5af7b069b2969f3af5869e9 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 22 Jun 2026 14:47:33 +0200 Subject: [PATCH 02/29] Hook up scales --- src/writer/hephaestus/PLAN.md | 39 ++++ src/writer/hephaestus/channels.rs | 103 ++++++++- src/writer/hephaestus/geom/point.rs | 70 ++++-- src/writer/hephaestus/mod.rs | 327 ++++++++++++++++++++-------- src/writer/hephaestus/scales.rs | 209 +++++++++++++++--- 5 files changed, 619 insertions(+), 129 deletions(-) diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index cc4b6faa8..5ca53a634 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -248,6 +248,45 @@ Hephaestus deficiencies noted during Phase 1 (for upstream): - `src/scales/` docs claim transforms are Identity-only, but `TransformKind` already implements the full Log/Sqrt/PseudoLog set — stale doc. +## Phase 2 — status: implemented + +All scale types, transforms, material aesthetics, axis titles, and legends for +the single-panel point geom. + +- `scales.rs::build_scale` builds continuous/discrete/ordinal/binned scales; + maps ggsql transforms → hephaestus `TransformKind` (cast/temporal → identity); + maps resolved `OutputRange::Array` → `range_colors/range_numbers/range_strings` + (palettes already concrete; hex/name colors parsed via `csscolorparser`). +- `channels.rs` extracts columns as typed channel data (text → category strings, + numeric → f64) and parses literal colors. +- `mod.rs` discovers mapped aesthetics, registers a scale + binds a channel per + data-mapped aesthetic, sets `Raw` constants for identity/literal aesthetics, + applies ggsql defaults otherwise, and adds axis titles + legends. +- Channels driven by the same `(data source, output kind)` bind to **one** + shared scale (ggsql's `color` → fill + stroke); their legends then share a + `domain_scale` and **hephaestus auto-collapses them** — no bespoke legend + dedup. Generalized via `shared_scales` keyed on `(aesthetic_source, kind)`. +- Continuous domains come from the **data extent** unless the user set `FROM` + explicitly; ggsql's inferred log-scale domain collapses its lower bound to + `f64::MIN_POSITIVE`, which would push all points to one edge. Under a + non-identity transform, hephaestus computes its own (transform-aware) breaks + rather than ggsql's (which were derived from that loose domain). +- hephaestus builtin shape names match ggsql's 1:1 (pass-through). +- Verified: discrete-color (single collapsed legend), continuous-size, + log-scale (correct log spacing), axis-title tests; output eyeballed; default + + 1.86 builds, fmt, clippy clean. + +Hephaestus deficiency noted in Phase 2 (for upstream): +- No domain expansion / "nice" padding. Edge data points sit exactly on the + panel boundary and clip; consumers must pre-expand domains by hand, which is + awkward for non-linear transforms (data-space padding can push a log lower + bound ≤ 0). A scale-level expansion factor would help. + +Deferred to later phases: domain expansion (above); calendar-native temporal +axes (continuous + ggsql formatted breaks used for now); `linetype` material +aesthetic (line geoms, Phase 3); ggsql's degenerate inferred log domain is +worked around here but is worth fixing upstream in ggsql too. + ## 8. Key source references ggsql: diff --git a/src/writer/hephaestus/channels.rs b/src/writer/hephaestus/channels.rs index 6e2789049..6fcc8e682 100644 --- a/src/writer/hephaestus/channels.rs +++ b/src/writer/hephaestus/channels.rs @@ -1,12 +1,46 @@ -//! Bridging ggsql aesthetic mappings and DataFrame columns to the raw numeric -//! vectors hephaestus geoms consume. +//! Bridging ggsql aesthetic mappings and DataFrame columns to the typed data +//! hephaestus geoms consume. -use arrow::array::Float64Array; +use arrow::array::{Array, StringArray}; use arrow::datatypes::DataType; -use crate::array_util::{as_f64, cast_array}; +use hephaestus::color::Color; +use hephaestus::plot::geom::{BuildableGeom, GeomBuilder}; + +use super::scales::parse_color; +use crate::array_util::{as_f64, as_str, cast_array}; use crate::{AestheticValue, DataFrame, Layer, Result}; +/// A column extracted in the type hephaestus expects for a channel: numeric +/// columns become `f64`s, text columns become category strings. +pub enum ChannelData { + Floats(Vec), + Strings(Vec), +} + +impl ChannelData { + /// Finite (min, max) of numeric data; `(0, 1)` for categorical (which feeds + /// a discrete scale that ignores the extent). + pub fn extent(&self) -> (f64, f64) { + match self { + ChannelData::Floats(values) => extent(values), + ChannelData::Strings(_) => (0.0, 1.0), + } + } + + /// 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> { @@ -17,12 +51,23 @@ pub fn aesthetic_column_name<'a>(layer: &'a Layer, aesthetic: &str) -> Option<&' } } +/// Extract a column as the channel type implied by its arrow dtype: text → +/// category strings, everything else → `f64`. +pub fn column_to_channel(df: &DataFrame, name: &str) -> Result { + let array = df.column(name)?; + if matches!(array.data_type(), DataType::Utf8 | DataType::LargeUtf8) { + Ok(ChannelData::Strings(column_to_strings(df, name)?)) + } else { + Ok(ChannelData::Floats(column_to_f64(df, name)?)) + } +} + /// 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: &Float64Array = if matches!(array.data_type(), DataType::Float64) { + let f64_array = if matches!(array.data_type(), DataType::Float64) { as_f64(array)? } else { casted = cast_array(array, &DataType::Float64)?; @@ -30,3 +75,51 @@ pub fn column_to_f64(df: &DataFrame, name: &str) -> Result> { }; 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. +pub fn column_to_strings(df: &DataFrame, name: &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) { + String::new() + } else { + str_array.value(i).to_string() + } + }) + .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()) +} + +/// Finite (min, max) of the data, or `(0, 1)` when there are no finite values. +fn extent(values: &[f64]) -> (f64, f64) { + let mut min = f64::INFINITY; + let mut max = f64::NEG_INFINITY; + for &v in values { + if v.is_finite() { + min = min.min(v); + max = max.max(v); + } + } + if min <= max { + (min, max) + } else { + (0.0, 1.0) + } +} diff --git a/src/writer/hephaestus/geom/point.rs b/src/writer/hephaestus/geom/point.rs index 9e4147d19..de029eb5f 100644 --- a/src/writer/hephaestus/geom/point.rs +++ b/src/writer/hephaestus/geom/point.rs @@ -1,17 +1,61 @@ //! `point` geom: one marker per row. +//! +//! Phase 2 wires the point geom generically — the writer iterates [`MATERIAL`] +//! and, per entry, either binds a data-mapped scale or sets a constant. The +//! actual `PointGeom` is assembled in the parent module. -use hephaestus::color::Color; -use hephaestus::plot::PointGeom; +use super::super::scales::RangeKind; -/// Build a `PointGeom` from x/y panel data with a constant fill and size. -/// -/// Phase 1 treats `fill`/`size` as constants; data- and literal-mapped visual -/// channels arrive in Phase 2. -pub fn build(xs: &[f64], ys: &[f64], fill: Color, size: f64) -> PointGeom { - PointGeom::builder() - .set("x", xs.to_vec()) - .set("y", ys.to_vec()) - .set("fill", fill) - .set("size", size) - .build() +/// A material aesthetic the point geom supports: the ggsql aesthetic name, the +/// hephaestus channel it drives, and the kind of output it produces. +pub struct Material { + pub aesthetic: &'static str, + pub channel: &'static str, + pub kind: RangeKind, } + +/// Material aesthetics in priority order. `color`/`colour` are aliases for the +/// `fill` channel (point's primary color aesthetic); when several map to the +/// same channel the first present wins. +pub const MATERIAL: &[Material] = &[ + Material { + aesthetic: "fill", + channel: "fill", + kind: RangeKind::Color, + }, + Material { + aesthetic: "color", + channel: "fill", + kind: RangeKind::Color, + }, + Material { + aesthetic: "colour", + channel: "fill", + kind: RangeKind::Color, + }, + Material { + aesthetic: "stroke", + channel: "stroke", + kind: RangeKind::Color, + }, + Material { + aesthetic: "size", + channel: "size", + kind: RangeKind::Number, + }, + Material { + aesthetic: "opacity", + channel: "fill_opacity", + kind: RangeKind::Number, + }, + Material { + aesthetic: "linewidth", + channel: "linewidth", + kind: RangeKind::Number, + }, + Material { + aesthetic: "shape", + channel: "shape", + kind: RangeKind::Shape, + }, +]; diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index 721471b67..faf3d9816 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -3,11 +3,11 @@ //! Renders a resolved ggsql `Spec` to PNG bytes via the [`hephaestus`] 2D scene //! renderer. //! -//! **Phase 1 scope** (see `src/writer/hephaestus/PLAN.md`): a single-panel, -//! single `point` layer in Cartesian coordinates with continuous position -//! scales and basic bottom/left axes. Unsupported specs are rejected by -//! [`HephaestusWriter::validate`] rather than rendered incorrectly. Further -//! geoms, scales, faceting and projections arrive in later phases. +//! **Scope** (see `src/writer/hephaestus/PLAN.md`): single-panel, single `point` +//! layer in Cartesian coordinates. All scale types, transforms, and material +//! aesthetics (fill/stroke/size/shape/opacity/linewidth) are supported, with +//! axis titles and legends. Faceting, projections, and other geoms arrive in +//! later phases; unsupported specs are rejected by [`HephaestusWriter::validate`]. //! //! Rendering uses hephaestus's Vello (GPU) backend, so a working wgpu adapter //! (hardware or software, e.g. lavapipe) is required at render time. @@ -16,33 +16,43 @@ mod channels; mod geom; mod scales; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use hephaestus::backend::vello::VelloRenderer; use hephaestus::color::{rgb8, Color}; use hephaestus::composition::{Composition, Patch, Span}; use hephaestus::geometry::Size; use hephaestus::plot::chrome::axis::{Axis, AxisPlacement}; -use hephaestus::plot::{Plot as HPlot, PlotComposition}; -use hephaestus::scales::chrome::AxisSide; +use hephaestus::plot::chrome::legend::{Legend, LegendKeySpec}; +use hephaestus::plot::geom::Raw; +use hephaestus::plot::scale::Scale as HScale; +use hephaestus::plot::{Plot as HPlot, PlotComposition, PointGeom}; +use hephaestus::scales::chrome::{AxisSide, LegendSide}; +use hephaestus::shape::ShapeRegistry; use hephaestus::Renderer; use crate::plot::layer::geom::GeomType; use crate::plot::projection::coord::CoordKind; -use crate::plot::types::ParameterValue; +use crate::plot::ScaleTypeKind; use crate::writer::Writer; use crate::{AestheticValue, DataFrame, GgsqlError, Layer, Plot, Result}; -/// Internal patch id for the single panel rendered in Phase 1. +use channels::{ + aesthetic_column_name, column_to_channel, column_to_colors, column_to_f64, column_to_strings, +}; +use scales::{build_scale, RangeKind}; + +/// Internal patch id for the single panel. const PANEL_ID: &str = "ggsql_panel"; -/// Fallback marker diameter (pt) when no `size` is supplied. -const DEFAULT_SIZE: f64 = 4.0; +/// ggsql point geom defaults (mirrors `plot/layer/geom/point.rs`), applied when +/// a channel isn't otherwise set so output matches ggsql. +const DEFAULT_SIZE: f64 = 3.0; +const DEFAULT_OPACITY: f64 = 0.8; /// Writer that renders a ggsql plot to a PNG image via hephaestus. /// -/// The writer is configured with a target pixel size and DPI because raster -/// rendering needs concrete dimensions, unlike the resolution-independent -/// Vega-Lite JSON writer. +/// Configured with a target pixel size and DPI because raster rendering needs +/// concrete dimensions, unlike the resolution-independent Vega-Lite writer. pub struct HephaestusWriter { width: u32, height: u32, @@ -51,8 +61,7 @@ pub struct HephaestusWriter { } impl HephaestusWriter { - /// Create a writer for the given pixel dimensions and DPI, with a white - /// background. + /// Create a writer for the given pixel dimensions and DPI, white background. pub fn new(width: u32, height: u32, dpi: f64) -> Self { Self { width, @@ -75,26 +84,26 @@ impl Writer for HephaestusWriter { fn validate(&self, spec: &Plot) -> Result<()> { if spec.facet.is_some() { return Err(GgsqlError::WriterError( - "hephaestus writer (phase 1) does not support FACET yet".into(), + "hephaestus writer does not support FACET yet".into(), )); } if let Some(projection) = &spec.project { if projection.coord.coord_kind() != CoordKind::Cartesian { return Err(GgsqlError::WriterError( - "hephaestus writer (phase 1) supports only Cartesian coordinates".into(), + "hephaestus writer supports only Cartesian coordinates".into(), )); } } if spec.layers.len() != 1 { return Err(GgsqlError::WriterError(format!( - "hephaestus writer (phase 1) supports exactly one layer, got {}", + "hephaestus writer supports exactly one layer, got {}", spec.layers.len() ))); } let geom_type = spec.layers[0].geom.geom_type(); if geom_type != GeomType::Point { return Err(GgsqlError::WriterError(format!( - "hephaestus writer (phase 1) supports only the 'point' geom, got '{geom_type}'" + "hephaestus writer supports only the 'point' geom, got '{geom_type}'" ))); } Ok(()) @@ -106,39 +115,124 @@ impl Writer for HephaestusWriter { let layer = &spec.layers[0]; let df = layer_dataframe(layer, data)?; - // Raw x / y data columns. - let x_col = channels::aesthetic_column_name(layer, "pos1") - .ok_or_else(|| GgsqlError::WriterError("point layer has no x (pos1) mapping".into()))?; - let y_col = channels::aesthetic_column_name(layer, "pos2") - .ok_or_else(|| GgsqlError::WriterError("point layer has no y (pos2) mapping".into()))?; - let xs = channels::column_to_f64(df, x_col)?; - let ys = channels::column_to_f64(df, y_col)?; - - // Continuous scales, with the data extent as a domain fallback. - let scale_x = scales::build_continuous(spec.find_scale("pos1"), extent(&xs)); - let scale_y = scales::build_continuous(spec.find_scale("pos2"), extent(&ys)); - - // Single-panel composition; the same shape feeds Plot::new and the - // orchestrator (the orchestrator rebuilds from its own copy). - let mut plot = HPlot::new(&single_panel(), PANEL_ID) - .bind("x", "pos1") - .bind("y", "pos2"); - plot.add_geom(geom::point::build( - &xs, - &ys, - rgb8(70, 120, 220), - literal_size(layer), - )); - plot.add_axis(Axis::rail( - "pos1", - AxisPlacement::Cartesian(AxisSide::Bottom), - )); - plot.add_axis(Axis::rail("pos2", AxisPlacement::Cartesian(AxisSide::Left))); + let mut builder = PointGeom::builder(); + let mut registered: Vec<(String, HScale)> = Vec::new(); + let mut bindings: Vec<(&'static str, String)> = Vec::new(); + let mut axes: Vec = Vec::new(); + let mut legends: Vec = Vec::new(); + + // ── Positions ──────────────────────────────────────────────────── + for (aesthetic, channel, side) in [ + ("pos1", "x", AxisSide::Bottom), + ("pos2", "y", AxisSide::Left), + ] { + let col = aesthetic_column_name(layer, aesthetic).ok_or_else(|| { + GgsqlError::WriterError(format!("point layer has no {aesthetic} mapping")) + })?; + let data = column_to_channel(df, col)?; + let extent = data.extent(); + let scale = build_scale(spec.find_scale(aesthetic), extent, RangeKind::Position); + data.apply(&mut builder, channel); + registered.push((aesthetic.to_string(), scale)); + bindings.push((channel, aesthetic.to_string())); + + let mut axis = Axis::rail(aesthetic, AxisPlacement::Cartesian(side)); + if let Some(title) = aesthetic_label(spec, layer, aesthetic) { + axis = axis.title(title); + } + axes.push(axis); + } - let mut view = PlotComposition::new(single_panel()) - .add_scale("pos1", scale_x) - .add_scale("pos2", scale_y) - .with_plot(plot); + // ── Material aesthetics ────────────────────────────────────────── + let mut handled: HashSet<&str> = HashSet::new(); + // Channels driven by the same data source and output kind share one + // scale (e.g. ggsql's `color` → fill + stroke); their legends then + // share a `domain_scale` and hephaestus collapses them. + let mut shared_scales: HashMap<(String, RangeKind), String> = HashMap::new(); + for material in geom::point::MATERIAL { + if handled.contains(material.channel) { + continue; + } + let Some(col) = aesthetic_column_name(layer, material.aesthetic) else { + continue; + }; + handled.insert(material.channel); + + let scale = spec.find_scale(material.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 channel_data = column_to_channel(df, col)?; + let extent = channel_data.extent(); + + let source = aesthetic_source(layer, material.aesthetic); + let scale_name = shared_scales + .entry((source, material.kind)) + .or_insert_with(|| { + let hs = build_scale(scale, extent, material.kind); + registered.push((material.aesthetic.to_string(), hs)); + material.aesthetic.to_string() + }) + .clone(); + + channel_data.apply(&mut builder, material.channel); + bindings.push((material.channel, scale_name.clone())); + legends.push(material_legend( + &scale_name, + material.channel, + material.kind, + type_kind, + aesthetic_label(spec, layer, material.aesthetic), + )); + } else { + // Identity / literal: the column holds visual-space values. + match material.kind { + RangeKind::Color => { + builder.set(material.channel, Raw(column_to_colors(df, col)?)); + } + RangeKind::Shape => { + builder.set(material.channel, Raw(column_to_strings(df, col)?)); + } + _ => { + builder.set(material.channel, Raw(column_to_f64(df, col)?)); + } + } + } + } + + // ── ggsql defaults for unset channels ──────────────────────────── + if !handled.contains("fill") { + builder.set("fill", rgb8(0, 0, 0)); + } + if !handled.contains("size") { + builder.set("size", DEFAULT_SIZE); + } + if !handled.contains("fill_opacity") { + builder.set("fill_opacity", DEFAULT_OPACITY); + } + + // ── Assemble plot + composition ────────────────────────────────── + let mut plot = + HPlot::new(&single_panel(), PANEL_ID).shape_registry(ShapeRegistry::with_builtins()); + for (channel, scale_name) in &bindings { + plot.set_binding(*channel, scale_name.clone()); + } + plot.add_geom(builder.build()); + for axis in axes { + plot.add_axis(axis); + } + for legend in legends { + plot.add_legend(legend); + } + + let mut view = PlotComposition::new(single_panel()); + for (name, scale) in registered { + view.insert_scale(name, scale); + } + view.attach_plot(plot); let issues = view.validate(); if !issues.is_empty() { @@ -157,7 +251,7 @@ impl Writer for HephaestusWriter { } } -/// The single-panel composition Phase 1 renders into. +/// The single-panel composition the writer renders into. fn single_panel() -> Composition { Composition::empty(1, 1).place(1, 1, Span::cell(), Patch::new(PANEL_ID)) } @@ -172,29 +266,63 @@ fn layer_dataframe<'a>( .ok_or_else(|| GgsqlError::WriterError(format!("no data found for layer key '{key}'"))) } -/// A literal numeric `size` aesthetic, or the Phase 1 default. -fn literal_size(layer: &Layer) -> f64 { - match layer.mappings.get("size") { - Some(AestheticValue::Literal(ParameterValue::Number(n))) => *n, - _ => DEFAULT_SIZE, +/// Resolve a label for an aesthetic: an explicit `LABEL` wins (`None` +/// suppresses), otherwise the original mapped column name is the default. +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, } } -/// Finite (min, max) of the data, or `(0, 1)` when there are no finite values. -fn extent(values: &[f64]) -> (f64, f64) { - let mut min = f64::INFINITY; - let mut max = f64::NEG_INFINITY; - for &v in values { - if v.is_finite() { - min = min.min(v); - max = max.max(v); - } +/// Identify a mapping's underlying data source — the original column name when +/// known, else the internal column name. Lets color-family channels that share +/// a source (ggsql's `color` → fill + stroke) collapse to one scale + legend. +fn aesthetic_source(layer: &Layer, aesthetic: &str) -> String { + match layer.mappings.get(aesthetic) { + Some(AestheticValue::Column { + original_name: Some(name), + .. + }) => name.clone(), + Some(AestheticValue::Column { name, .. }) => name.clone(), + Some(AestheticValue::AnnotationColumn { name }) => name.clone(), + _ => aesthetic.to_string(), } - if min <= max { - (min, max) +} + +/// Build a legend for a data-mapped material scale. Continuous color uses a +/// colorbar; everything else a keyed point legend at the scale's breaks. +fn material_legend( + scale_name: &str, + channel: &str, + kind: RangeKind, + type_kind: Option, + title: Option, +) -> Legend { + 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 { - (0.0, 1.0) + Legend::new(scale_name) + .side(LegendSide::Right) + .key(LegendKeySpec::point().scaled(channel, scale_name)) + }; + if let Some(title) = title { + legend = legend.title(title); } + legend } /// Render the composition to an RGBA8 buffer and encode it as PNG bytes. @@ -243,31 +371,60 @@ mod tests { use super::*; use crate::reader::{DuckDBReader, Reader}; - #[test] - fn renders_point_plot_to_png() { + fn render(query: &str) -> Result> { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); - let spec = reader - .execute( - "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", - ) - .unwrap(); - let writer = HephaestusWriter::new(640, 480, 96.0); - match writer.render(&spec) { + let spec = reader.execute(query).unwrap(); + HephaestusWriter::new(640, 480, 96.0).render(&spec) + } + + /// 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") => { - // No wgpu adapter available (e.g. headless CI without a software - // rasteriser); the spec→composition path still exercised. Skip - // the render assertion rather than failing the suite. eprintln!("skipping render assertion: {msg}"); } - Err(e) => panic!("unexpected error rendering point plot: {e}"), + 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_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 rejects_unsupported_geom() { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); diff --git a/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs index 0fd310d64..68af186df 100644 --- a/src/writer/hephaestus/scales.rs +++ b/src/writer/hephaestus/scales.rs @@ -1,43 +1,200 @@ //! Translating resolved ggsql scales into hephaestus scales. //! -//! Phase 1 handles continuous position scales only. ggsql resolves the scale -//! configuration (domain, breaks, formatted labels); we hand those to -//! hephaestus, which performs the value→pixel mapping at draw time. +//! 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 hephaestus::plot::scale::{self, Scale as HScale}; +use std::sync::Arc; + +use hephaestus::color::{rgba, Color}; +use hephaestus::plot::scale::{self, Scale as HScale, TransformKind as HTransform}; use hephaestus::scales::value::Value as HValue; -use crate::Scale; +use crate::plot::scale::TransformKind as GTransform; +use crate::plot::{ArrayElement, OutputRange, Scale as GScale, ScaleTypeKind}; + +/// What kind of visual output a scale's range produces. Selects how a resolved +/// `OutputRange::Array` is mapped onto a hephaestus range. +#[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, +} -/// Build a hephaestus continuous scale from a resolved ggsql position scale. +/// Build a hephaestus scale from a resolved ggsql scale. /// -/// Uses the ggsql scale's resolved domain and formatted breaks when available, -/// falling back to the supplied data extent (and hephaestus's own break -/// selection) when the scale is absent or carries no real domain. -pub fn build_continuous(scale: Option<&Scale>, data_extent: (f64, f64)) -> HScale { +/// `data_extent` is the finite (min, max) of the channel's data, used as the +/// domain fallback when the ggsql scale carries none (continuous scales only). +pub fn build_scale(scale: Option<&GScale>, data_extent: (f64, f64), kind: RangeKind) -> HScale { let usable = scale.filter(|s| !s.is_dummy()); - let (min, max) = usable + let type_kind = usable + .and_then(|s| s.scale_type.as_ref()) + .map(|st| st.scale_type_kind()); + let transform = usable + .and_then(|s| s.transform.as_ref()) + .map(|t| t.transform_kind()); + + let mut hs = match type_kind { + Some(ScaleTypeKind::Discrete) => scale::discrete(domain_values(usable)), + Some(ScaleTypeKind::Ordinal) => scale::ordinal(domain_values(usable)), + // Continuous, Binned, Identity, or unknown: a continuous mapper. + _ => { + let (min, max) = continuous_domain(usable, data_extent); + let mut c = scale::continuous(min..=max); + if let Some(t) = transform.and_then(map_transform) { + c = c.with_transform(t); + } + c + } + }; + + if kind != RangeKind::Position { + if let Some(OutputRange::Array(values)) = usable.and_then(|s| s.output_range.as_ref()) { + hs = apply_output_range(hs, kind, values); + } + } + + // Feed ggsql's resolved breaks for categorical scales and for + // identity-transform continuous scales (preserving ggsql's formatting). + // Under a non-identity transform ggsql's breaks are computed in transform + // space against a possibly-loose domain, so let hephaestus derive its own + // transform-aware breaks instead. + let feed_breaks = matches!( + type_kind, + Some(ScaleTypeKind::Discrete) | Some(ScaleTypeKind::Ordinal) | Some(ScaleTypeKind::Binned) + ) || matches!(transform, None | Some(GTransform::Identity)); + if feed_breaks { + if let Some(s) = usable { + hs = apply_breaks(hs, s, type_kind); + } + } + hs +} + +/// Domain for a continuous scale. ggsql's resolved domain is authoritative only +/// when the user set it explicitly (`FROM`); inferred domains can be degenerate +/// (e.g. a log scale's lower bound collapses to `f64::MIN_POSITIVE`), so fall +/// back to the actual data extent. +fn continuous_domain(scale: Option<&GScale>, data_extent: (f64, f64)) -> (f64, f64) { + let domain = scale + .filter(|s| s.explicit_input_range) .and_then(|s| s.numeric_domain()) .unwrap_or(data_extent); - let (min, max) = pad_degenerate(min, max); - - let mut hs = scale::continuous(min..=max); - if let Some(s) = usable { - let labels = s.break_labels(); - if !labels.is_empty() { - hs = hs.with_breaks_labeled( - labels - .into_iter() - .map(|(pos, label)| (HValue::Number(pos), label)) - .collect(), - ); + pad_degenerate(domain.0, domain.1) +} + +/// Category domain for a discrete/ordinal scale, as hephaestus values. +fn domain_values(scale: Option<&GScale>) -> Vec { + scale + .and_then(|s| s.input_range.as_ref()) + .map(|range| range.iter().map(array_element_to_value).collect()) + .unwrap_or_default() +} + +/// Attach the resolved output range to a material scale. +fn apply_output_range(hs: HScale, kind: RangeKind, values: &[ArrayElement]) -> HScale { + match kind { + RangeKind::Color => hs.range_colors(values.iter().filter_map(array_element_to_color)), + RangeKind::Number => hs.range_numbers(values.iter().filter_map(|e| e.to_f64())), + RangeKind::Shape => { + hs.range_strings(values.iter().map(|e| Arc::from(e.to_key_string().as_str()))) } + RangeKind::Position => hs, } - hs } -/// Widen a domain that is non-finite or zero-width into something a continuous -/// scale can map without dividing by zero. +/// Feed ggsql's resolved breaks + formatted labels into the hephaestus scale so +/// axis/legend ticks match ggsql exactly (including RENAMING overrides). +fn apply_breaks(hs: HScale, scale: &GScale, type_kind: Option) -> HScale { + let labels = scale.break_labels(); + if labels.is_empty() { + return hs; + } + match type_kind { + Some(ScaleTypeKind::Discrete) | Some(ScaleTypeKind::Ordinal) => { + // Pair each category value with its (possibly renamed) label. + let Some(range) = scale.input_range.as_ref() else { + return hs; + }; + let pairs: Vec<(HValue, String)> = range + .iter() + .map(array_element_to_value) + .zip(labels.into_iter().map(|(_, l)| l)) + .collect(); + hs.with_breaks_labeled(pairs) + } + _ => hs.with_breaks_labeled( + labels + .into_iter() + .map(|(pos, label)| (HValue::Number(pos), label)) + .collect(), + ), + } +} + +/// 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); From e81c1aec665ad85bcb2b14aeaf0ae980ef4ca679 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 22 Jun 2026 22:58:29 +0200 Subject: [PATCH 03/29] Additional geoms --- src/writer/hephaestus/PLAN.md | 35 +++ src/writer/hephaestus/channels.rs | 28 +- src/writer/hephaestus/geom/area.rs | 77 ++++++ src/writer/hephaestus/geom/line.rs | 57 ++++ src/writer/hephaestus/geom/mod.rs | 67 ++++- src/writer/hephaestus/geom/point.rs | 94 +++---- src/writer/hephaestus/geom/polygon.rs | 48 ++++ src/writer/hephaestus/geom/rect.rs | 117 +++++++++ src/writer/hephaestus/geom/segment.rs | 108 ++++++++ src/writer/hephaestus/geom/text.rs | 36 +++ src/writer/hephaestus/mod.rs | 292 +++++++-------------- src/writer/hephaestus/scales.rs | 18 +- src/writer/hephaestus/wiring.rs | 359 ++++++++++++++++++++++++++ 13 files changed, 1081 insertions(+), 255 deletions(-) create mode 100644 src/writer/hephaestus/geom/area.rs create mode 100644 src/writer/hephaestus/geom/line.rs create mode 100644 src/writer/hephaestus/geom/polygon.rs create mode 100644 src/writer/hephaestus/geom/rect.rs create mode 100644 src/writer/hephaestus/geom/segment.rs create mode 100644 src/writer/hephaestus/geom/text.rs create mode 100644 src/writer/hephaestus/wiring.rs diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index 5ca53a634..02d4af730 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -287,6 +287,41 @@ axes (continuous + ggsql formatted breaks used for now); `linetype` material aesthetic (line geoms, Phase 3); ggsql's degenerate inferred log domain is worked around here but is worth fixing upstream in ggsql too. +## Phase 3 — status: implemented + +All non-composite geoms render through a per-geom dispatch: +point/line/path/smooth, bar/histogram/tile, area/ribbon/density, polygon, +segment/range/rule, text. Single panel, Cartesian. + +- **Architecture.** `mod.rs` dispatches on `GeomType` to per-geom modules under + `geom/` that each declare a `GeomSpec` (position channels + material table + + raw string/number channels + grouping). `wiring.rs` holds the shared, + builder-generic helpers (`build_and_add`, `wire_positions`, `wire_material`, + scale/axis/legend/group-key logic) lifted out of the Phase-2 `mod.rs`. +- **Grouping.** multi-vertex geoms (line/path/area/ribbon/polygon) derive + hephaestus `keys` from `layer.partition_by` via `channels::build_group_keys` + (concatenated partition-column values) — so e.g. a line colored by category + renders as one line per category. +- **Orientation.** bar/histogram/area/ribbon/range consult + `is_transposed(layer)` and swap the value axis to the pos1-family when + transposed. +- **Positions** bind to one `pos1`/`pos2` scale per axis (domain = union extent + of the family columns used); `RangeKind::Linetype` restored for line dashes. +- Verified: 13 render tests (point/grouped-line/bar/histogram/area/ribbon/ + segment/text/polygon/color/size/log + composite-reject); output eyeballed for + line/bar/area/segment/text; default + 1.86 builds, fmt, clippy clean. + +Known limitations (refinements, not blockers): +- No domain expansion → data points/labels on the domain edge clip at the panel + boundary (same hephaestus gap noted in Phase 2). +- Bars fill the full category band (no inter-bar gap); ggsql's `width` setting + isn't read yet. +- Legend keys are always point glyphs; line/area legends could use line/rect + keys. (hephaestus supports `LegendKeySpec::line()`/`rect()`.) +- Discrete-tile uses band edges best-effort; continuous/binned tile is exact. + +Deferred to Phase 3b: boxplot, violin (composite decomposition). + ## 8. Key source references ggsql: diff --git a/src/writer/hephaestus/channels.rs b/src/writer/hephaestus/channels.rs index 6fcc8e682..889d46660 100644 --- a/src/writer/hephaestus/channels.rs +++ b/src/writer/hephaestus/channels.rs @@ -1,14 +1,14 @@ //! Bridging ggsql aesthetic mappings and DataFrame columns to the typed data //! hephaestus geoms consume. -use arrow::array::{Array, StringArray}; +use arrow::array::{Array, ArrayRef, StringArray}; use arrow::datatypes::DataType; use hephaestus::color::Color; use hephaestus::plot::geom::{BuildableGeom, GeomBuilder}; use super::scales::parse_color; -use crate::array_util::{as_f64, as_str, cast_array}; +use crate::array_util::{as_f64, as_str, cast_array, value_to_string}; use crate::{AestheticValue, DataFrame, Layer, Result}; /// A column extracted in the type hephaestus expects for a channel: numeric @@ -98,6 +98,30 @@ pub fn column_to_strings(df: &DataFrame, name: &str) -> Result> { .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 color column (visual-space literal values) as parsed colors, /// defaulting unparseable entries to black. pub fn column_to_colors(df: &DataFrame, name: &str) -> Result> { diff --git a/src/writer/hephaestus/geom/area.rs b/src/writer/hephaestus/geom/area.rs new file mode 100644 index 000000000..83587b057 --- /dev/null +++ b/src/writer/hephaestus/geom/area.rs @@ -0,0 +1,77 @@ +//! `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 hephaestus::color::rgb8; + +use super::super::scales::RangeKind; +use super::super::wiring::{Ctx, GeomSpec, 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 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)), + ), + MaterialSpec::new("color", "fill", RangeKind::Color, MatDefault::None), + MaterialSpec::new("colour", "fill", RangeKind::Color, MatDefault::None), + MaterialSpec::new("stroke", "stroke", RangeKind::Color, MatDefault::None), + MaterialSpec::new( + "opacity", + "alpha", + 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![], + grouped: true, + } +} diff --git a/src/writer/hephaestus/geom/line.rs b/src/writer/hephaestus/geom/line.rs new file mode 100644 index 000000000..08c8affcb --- /dev/null +++ b/src/writer/hephaestus/geom/line.rs @@ -0,0 +1,57 @@ +//! `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, 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("color", "stroke", RangeKind::Color, MatDefault::None), + MaterialSpec::new("colour", "stroke", RangeKind::Color, MatDefault::None), + 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![], + grouped: true, + } +} diff --git a/src/writer/hephaestus/geom/mod.rs b/src/writer/hephaestus/geom/mod.rs index 5f222536e..91ebf2dc2 100644 --- a/src/writer/hephaestus/geom/mod.rs +++ b/src/writer/hephaestus/geom/mod.rs @@ -1,4 +1,65 @@ -//! ggsql geom → hephaestus geom translation. Phase 1 ships `point` only; -//! further geoms land in later phases (see `src/writer/hephaestus/PLAN.md`). +//! 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) are Phase 3b. -pub mod point; +mod area; +mod line; +mod point; +mod polygon; +mod rect; +mod segment; +mod text; + +use hephaestus::plot::{ + LineGeom, Plot as HPlot, PointGeom, PolygonGeom, RectGeom, RibbonGeom, SegmentGeom, TextGeom, +}; + +use super::wiring::{build_and_add, Ctx, Wiring}; +use crate::plot::layer::geom::GeomType; +use crate::{GgsqlError, Result}; + +/// Build the layer's geom into `plot`, recording its scales/axes/legends in `w`. +pub fn build_into_plot(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { + match ctx.layer.geom.geom_type() { + GeomType::Point => build_and_add::(plot, point::spec(ctx), ctx, w), + GeomType::Line | GeomType::Path | GeomType::Smooth => { + build_and_add::(plot, line::spec(ctx), ctx, w) + } + GeomType::Bar | GeomType::Histogram | GeomType::Tile => { + build_and_add::(plot, rect::spec(ctx), ctx, w) + } + GeomType::Area | GeomType::Ribbon | GeomType::Density => { + build_and_add::(plot, area::spec(ctx), ctx, w) + } + GeomType::Polygon => build_and_add::(plot, polygon::spec(ctx), ctx, w), + GeomType::Segment | GeomType::Range | GeomType::Rule => { + build_and_add::(plot, segment::spec(ctx), ctx, w) + } + GeomType::Text => build_and_add::(plot, text::spec(ctx), ctx, w), + other => Err(GgsqlError::WriterError(format!( + "hephaestus 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 + ) +} diff --git a/src/writer/hephaestus/geom/point.rs b/src/writer/hephaestus/geom/point.rs index de029eb5f..dab0a77f9 100644 --- a/src/writer/hephaestus/geom/point.rs +++ b/src/writer/hephaestus/geom/point.rs @@ -1,61 +1,43 @@ //! `point` geom: one marker per row. -//! -//! Phase 2 wires the point geom generically — the writer iterates [`MATERIAL`] -//! and, per entry, either binds a data-mapped scale or sets a constant. The -//! actual `PointGeom` is assembled in the parent module. + +use hephaestus::color::rgb8; use super::super::scales::RangeKind; +use super::super::wiring::{Ctx, GeomSpec, MatDefault, MaterialSpec, PanelAxis, PositionSpec}; -/// A material aesthetic the point geom supports: the ggsql aesthetic name, the -/// hephaestus channel it drives, and the kind of output it produces. -pub struct Material { - pub aesthetic: &'static str, - pub channel: &'static str, - pub kind: RangeKind, +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("color", "fill", RangeKind::Color, MatDefault::None), + MaterialSpec::new("colour", "fill", RangeKind::Color, MatDefault::None), + 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![], + grouped: false, + } } - -/// Material aesthetics in priority order. `color`/`colour` are aliases for the -/// `fill` channel (point's primary color aesthetic); when several map to the -/// same channel the first present wins. -pub const MATERIAL: &[Material] = &[ - Material { - aesthetic: "fill", - channel: "fill", - kind: RangeKind::Color, - }, - Material { - aesthetic: "color", - channel: "fill", - kind: RangeKind::Color, - }, - Material { - aesthetic: "colour", - channel: "fill", - kind: RangeKind::Color, - }, - Material { - aesthetic: "stroke", - channel: "stroke", - kind: RangeKind::Color, - }, - Material { - aesthetic: "size", - channel: "size", - kind: RangeKind::Number, - }, - Material { - aesthetic: "opacity", - channel: "fill_opacity", - kind: RangeKind::Number, - }, - Material { - aesthetic: "linewidth", - channel: "linewidth", - kind: RangeKind::Number, - }, - Material { - aesthetic: "shape", - channel: "shape", - kind: RangeKind::Shape, - }, -]; diff --git a/src/writer/hephaestus/geom/polygon.rs b/src/writer/hephaestus/geom/polygon.rs new file mode 100644 index 000000000..fac31f40a --- /dev/null +++ b/src/writer/hephaestus/geom/polygon.rs @@ -0,0 +1,48 @@ +//! `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, 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("color", "fill", RangeKind::Color, MatDefault::None), + MaterialSpec::new("colour", "fill", RangeKind::Color, MatDefault::None), + 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![], + grouped: true, + } +} diff --git a/src/writer/hephaestus/geom/rect.rs b/src/writer/hephaestus/geom/rect.rs new file mode 100644 index 000000000..6bf4bf576 --- /dev/null +++ b/src/writer/hephaestus/geom/rect.rs @@ -0,0 +1,117 @@ +//! `bar`, `histogram`, and `tile` geoms → hephaestus `RectGeom`. +//! +//! Bars fill their category band (RectGeom's discrete `x_band` defaults ±0.5); +//! histograms span explicit bin edges; tiles span min/max extents. Bars and +//! histograms are orientation-aware (transposed swaps the value axis to x). + +use hephaestus::color::rgb8; + +use super::super::channels::aesthetic_column_name; +use super::super::scales::RangeKind; +use super::super::wiring::{Ctx, GeomSpec, MatDefault, MaterialSpec, PanelAxis, PositionSpec}; +use crate::plot::layer::geom::GeomType; + +pub fn spec(ctx: &Ctx) -> GeomSpec { + let (positions, raw_numbers) = match ctx.layer.geom.geom_type() { + GeomType::Bar => (bar(ctx.transposed), vec![]), + GeomType::Histogram => (histogram(ctx.transposed), vec![]), + GeomType::Tile => tile(ctx), + _ => (Vec::new(), vec![]), + }; + + GeomSpec { + positions, + material: vec![ + MaterialSpec::new( + "fill", + "fill", + RangeKind::Color, + MatDefault::Color(rgb8(0, 0, 0)), + ), + MaterialSpec::new("color", "fill", RangeKind::Color, MatDefault::None), + MaterialSpec::new("colour", "fill", RangeKind::Color, MatDefault::None), + 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, + ), + ], + raw_strings: &[], + raw_numbers, + grouped: false, + } +} + +/// Categorical bar: the main axis is a band (x and x2 share the category +/// column; band defaults fill the cell); the value axis runs baseline→value. +fn bar(transposed: bool) -> Vec { + if !transposed { + 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), + ] + } else { + 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), + ] + } +} + +/// 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: continuous tiles span min/max extents; discrete tiles fill the +/// category band on both axes. +fn tile(ctx: &Ctx) -> (Vec, Vec<(&'static str, f64)>) { + if aesthetic_column_name(ctx.layer, "pos1min").is_some() { + ( + vec![ + PositionSpec::new("x", "pos1min", PanelAxis::X), + PositionSpec::new("x2", "pos1max", PanelAxis::X), + PositionSpec::new("y", "pos2min", PanelAxis::Y), + PositionSpec::new("y2", "pos2max", PanelAxis::Y), + ], + vec![], + ) + } else { + // Discrete tile: x band defaults (±0.5) fill x; add y bands for y. + ( + vec![ + PositionSpec::new("x", "pos1", PanelAxis::X), + PositionSpec::new("x2", "pos1", PanelAxis::X), + PositionSpec::new("y", "pos2", PanelAxis::Y), + PositionSpec::new("y2", "pos2", PanelAxis::Y), + ], + vec![("y_band", -0.5), ("y2_band", 0.5)], + ) + } +} diff --git a/src/writer/hephaestus/geom/segment.rs b/src/writer/hephaestus/geom/segment.rs new file mode 100644 index 000000000..a24a24690 --- /dev/null +++ b/src/writer/hephaestus/geom/segment.rs @@ -0,0 +1,108 @@ +//! `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 super::super::channels::aesthetic_column_name; +use super::super::scales::RangeKind; +use super::super::wiring::{Ctx, GeomSpec, MatDefault, MaterialSpec, PanelAxis, PositionSpec}; +use crate::plot::layer::geom::GeomType; + +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: vec![ + MaterialSpec::new( + "stroke", + "stroke", + RangeKind::Color, + MatDefault::Color(rgb8(0, 0, 0)), + ), + MaterialSpec::new("color", "stroke", RangeKind::Color, MatDefault::None), + MaterialSpec::new("colour", "stroke", RangeKind::Color, MatDefault::None), + 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, + ), + ], + raw_strings: &[], + raw_numbers, + grouped: false, + } +} + +/// A rule is a reference line spanning the whole panel on its free axis. +/// Best-effort: 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/text.rs b/src/writer/hephaestus/geom/text.rs new file mode 100644 index 000000000..7f7853707 --- /dev/null +++ b/src/writer/hephaestus/geom/text.rs @@ -0,0 +1,36 @@ +//! `text` geom → hephaestus `TextGeom`. The `label` aesthetic carries the +//! string (set raw, unscaled); position + color/size map as usual. + +use hephaestus::color::rgb8; + +use super::super::scales::RangeKind; +use super::super::wiring::{Ctx, GeomSpec, 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("color", "fill", RangeKind::Color, MatDefault::None), + MaterialSpec::new("colour", "fill", RangeKind::Color, MatDefault::None), + MaterialSpec::new("fontsize", "size", RangeKind::Number, MatDefault::None), + MaterialSpec::new( + "opacity", + "fill_opacity", + RangeKind::Number, + MatDefault::None, + ), + ], + raw_strings: &[("text", "label")], + raw_numbers: vec![], + grouped: false, + } +} diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index faf3d9816..e09637458 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -3,11 +3,13 @@ //! Renders a resolved ggsql `Spec` to PNG bytes via the [`hephaestus`] 2D scene //! renderer. //! -//! **Scope** (see `src/writer/hephaestus/PLAN.md`): single-panel, single `point` -//! layer in Cartesian coordinates. All scale types, transforms, and material -//! aesthetics (fill/stroke/size/shape/opacity/linewidth) are supported, with -//! axis titles and legends. Faceting, projections, and other geoms arrive in -//! later phases; unsupported specs are rejected by [`HephaestusWriter::validate`]. +//! **Scope** (see `src/writer/hephaestus/PLAN.md`): single-panel, single-layer, +//! Cartesian plots. All non-composite geoms (point/line/path/area/ribbon/bar/ +//! histogram/tile/polygon/segment/rule/range/text/density/smooth), all scale +//! types/transforms, material aesthetics, axis titles, and legends are +//! supported. Faceting, projections, and composite geoms (boxplot/violin) +//! arrive in later phases; unsupported specs are rejected by +//! [`HephaestusWriter::validate`]. //! //! Rendering uses hephaestus's Vello (GPU) backend, so a working wgpu adapter //! (hardware or software, e.g. lavapipe) is required at render time. @@ -15,39 +17,27 @@ mod channels; mod geom; mod scales; +mod wiring; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use hephaestus::backend::vello::VelloRenderer; use hephaestus::color::{rgb8, Color}; use hephaestus::composition::{Composition, Patch, Span}; use hephaestus::geometry::Size; -use hephaestus::plot::chrome::axis::{Axis, AxisPlacement}; -use hephaestus::plot::chrome::legend::{Legend, LegendKeySpec}; -use hephaestus::plot::geom::Raw; -use hephaestus::plot::scale::Scale as HScale; -use hephaestus::plot::{Plot as HPlot, PlotComposition, PointGeom}; -use hephaestus::scales::chrome::{AxisSide, LegendSide}; +use hephaestus::plot::{Plot as HPlot, PlotComposition}; use hephaestus::shape::ShapeRegistry; use hephaestus::Renderer; -use crate::plot::layer::geom::GeomType; +use crate::plot::layer::is_transposed; use crate::plot::projection::coord::CoordKind; -use crate::plot::ScaleTypeKind; use crate::writer::Writer; -use crate::{AestheticValue, DataFrame, GgsqlError, Layer, Plot, Result}; +use crate::{DataFrame, GgsqlError, Layer, Plot, Result}; -use channels::{ - aesthetic_column_name, column_to_channel, column_to_colors, column_to_f64, column_to_strings, -}; -use scales::{build_scale, RangeKind}; +use wiring::{Ctx, Wiring}; /// Internal patch id for the single panel. const PANEL_ID: &str = "ggsql_panel"; -/// ggsql point geom defaults (mirrors `plot/layer/geom/point.rs`), applied when -/// a channel isn't otherwise set so output matches ggsql. -const DEFAULT_SIZE: f64 = 3.0; -const DEFAULT_OPACITY: f64 = 0.8; /// Writer that renders a ggsql plot to a PNG image via hephaestus. /// @@ -101,9 +91,9 @@ impl Writer for HephaestusWriter { ))); } let geom_type = spec.layers[0].geom.geom_type(); - if geom_type != GeomType::Point { + if !geom::is_supported(geom_type) { return Err(GgsqlError::WriterError(format!( - "hephaestus writer supports only the 'point' geom, got '{geom_type}'" + "hephaestus writer does not support the '{geom_type}' geom yet" ))); } Ok(()) @@ -114,122 +104,31 @@ impl Writer for HephaestusWriter { let layer = &spec.layers[0]; let df = layer_dataframe(layer, data)?; - - let mut builder = PointGeom::builder(); - let mut registered: Vec<(String, HScale)> = Vec::new(); - let mut bindings: Vec<(&'static str, String)> = Vec::new(); - let mut axes: Vec = Vec::new(); - let mut legends: Vec = Vec::new(); - - // ── Positions ──────────────────────────────────────────────────── - for (aesthetic, channel, side) in [ - ("pos1", "x", AxisSide::Bottom), - ("pos2", "y", AxisSide::Left), - ] { - let col = aesthetic_column_name(layer, aesthetic).ok_or_else(|| { - GgsqlError::WriterError(format!("point layer has no {aesthetic} mapping")) - })?; - let data = column_to_channel(df, col)?; - let extent = data.extent(); - let scale = build_scale(spec.find_scale(aesthetic), extent, RangeKind::Position); - data.apply(&mut builder, channel); - registered.push((aesthetic.to_string(), scale)); - bindings.push((channel, aesthetic.to_string())); - - let mut axis = Axis::rail(aesthetic, AxisPlacement::Cartesian(side)); - if let Some(title) = aesthetic_label(spec, layer, aesthetic) { - axis = axis.title(title); - } - axes.push(axis); - } - - // ── Material aesthetics ────────────────────────────────────────── - let mut handled: HashSet<&str> = HashSet::new(); - // Channels driven by the same data source and output kind share one - // scale (e.g. ggsql's `color` → fill + stroke); their legends then - // share a `domain_scale` and hephaestus collapses them. - let mut shared_scales: HashMap<(String, RangeKind), String> = HashMap::new(); - for material in geom::point::MATERIAL { - if handled.contains(material.channel) { - continue; - } - let Some(col) = aesthetic_column_name(layer, material.aesthetic) else { - continue; - }; - handled.insert(material.channel); - - let scale = spec.find_scale(material.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 channel_data = column_to_channel(df, col)?; - let extent = channel_data.extent(); - - let source = aesthetic_source(layer, material.aesthetic); - let scale_name = shared_scales - .entry((source, material.kind)) - .or_insert_with(|| { - let hs = build_scale(scale, extent, material.kind); - registered.push((material.aesthetic.to_string(), hs)); - material.aesthetic.to_string() - }) - .clone(); - - channel_data.apply(&mut builder, material.channel); - bindings.push((material.channel, scale_name.clone())); - legends.push(material_legend( - &scale_name, - material.channel, - material.kind, - type_kind, - aesthetic_label(spec, layer, material.aesthetic), - )); - } else { - // Identity / literal: the column holds visual-space values. - match material.kind { - RangeKind::Color => { - builder.set(material.channel, Raw(column_to_colors(df, col)?)); - } - RangeKind::Shape => { - builder.set(material.channel, Raw(column_to_strings(df, col)?)); - } - _ => { - builder.set(material.channel, Raw(column_to_f64(df, col)?)); - } - } - } - } - - // ── ggsql defaults for unset channels ──────────────────────────── - if !handled.contains("fill") { - builder.set("fill", rgb8(0, 0, 0)); - } - if !handled.contains("size") { - builder.set("size", DEFAULT_SIZE); - } - if !handled.contains("fill_opacity") { - builder.set("fill_opacity", DEFAULT_OPACITY); - } - - // ── Assemble plot + composition ────────────────────────────────── + let ctx = Ctx { + spec, + layer, + df, + transposed: is_transposed(layer), + }; + + // Build the geom (+ its scales/axes/legends) through the shared wiring. let mut plot = HPlot::new(&single_panel(), PANEL_ID).shape_registry(ShapeRegistry::with_builtins()); - for (channel, scale_name) in &bindings { + let mut w = Wiring::default(); + geom::build_into_plot(&mut plot, &ctx, &mut w)?; + + for (channel, scale_name) in &w.bindings { plot.set_binding(*channel, scale_name.clone()); } - plot.add_geom(builder.build()); - for axis in axes { + for axis in w.axes { plot.add_axis(axis); } - for legend in legends { + for legend in w.legends { plot.add_legend(legend); } let mut view = PlotComposition::new(single_panel()); - for (name, scale) in registered { + for (name, scale) in w.registered { view.insert_scale(name, scale); } view.attach_plot(plot); @@ -266,65 +165,6 @@ fn layer_dataframe<'a>( .ok_or_else(|| GgsqlError::WriterError(format!("no data found for layer key '{key}'"))) } -/// Resolve a label for an aesthetic: an explicit `LABEL` wins (`None` -/// suppresses), otherwise the original mapped column name is the default. -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, - } -} - -/// Identify a mapping's underlying data source — the original column name when -/// known, else the internal column name. Lets color-family channels that share -/// a source (ggsql's `color` → fill + stroke) collapse to one scale + legend. -fn aesthetic_source(layer: &Layer, aesthetic: &str) -> String { - match layer.mappings.get(aesthetic) { - Some(AestheticValue::Column { - original_name: Some(name), - .. - }) => name.clone(), - Some(AestheticValue::Column { name, .. }) => name.clone(), - Some(AestheticValue::AnnotationColumn { name }) => name.clone(), - _ => aesthetic.to_string(), - } -} - -/// Build a legend for a data-mapped material scale. Continuous color uses a -/// colorbar; everything else a keyed point legend at the scale's breaks. -fn material_legend( - scale_name: &str, - channel: &str, - kind: RangeKind, - type_kind: Option, - title: Option, -) -> Legend { - 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 { - Legend::new(scale_name) - .side(LegendSide::Right) - .key(LegendKeySpec::point().scaled(channel, scale_name)) - }; - if let Some(title) = title { - legend = legend.title(title); - } - legend -} - /// Render the composition to an RGBA8 buffer and encode it as PNG bytes. fn render_png( view: &mut PlotComposition, @@ -426,12 +266,78 @@ mod tests { } #[test] - fn rejects_unsupported_geom() { + 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_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_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 rejects_composite_geom() { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); let spec = reader .execute( - "SELECT 1 AS x, 2 AS y UNION ALL SELECT 2, 3 \ - VISUALISE x AS x, y AS y DRAW line", + "SELECT 1 AS g, 2 AS y UNION ALL SELECT 1, 5 UNION ALL SELECT 1, 3 \ + VISUALISE g AS x, y AS y DRAW boxplot", ) .unwrap(); let writer = HephaestusWriter::new(320, 240, 96.0); diff --git a/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs index 68af186df..d58a30351 100644 --- a/src/writer/hephaestus/scales.rs +++ b/src/writer/hephaestus/scales.rs @@ -9,8 +9,9 @@ use std::sync::Arc; use hephaestus::color::{rgba, Color}; +use hephaestus::plot::geom::linetype::{dashdot, dashed, dotted, solid}; use hephaestus::plot::scale::{self, Scale as HScale, TransformKind as HTransform}; -use hephaestus::scales::value::Value as HValue; +use hephaestus::scales::value::{LinetypeStep, Value as HValue}; use crate::plot::scale::TransformKind as GTransform; use crate::plot::{ArrayElement, OutputRange, Scale as GScale, ScaleTypeKind}; @@ -27,6 +28,8 @@ pub enum RangeKind { Number, /// Marker shape: names resolved against the plot's `ShapeRegistry`. Shape, + /// Line dash pattern: names → builtin linetype patterns. + Linetype, } /// Build a hephaestus scale from a resolved ggsql scale. @@ -107,10 +110,23 @@ fn apply_output_range(hs: HScale, kind: RangeKind, values: &[ArrayElement]) -> H RangeKind::Shape => { hs.range_strings(values.iter().map(|e| Arc::from(e.to_key_string().as_str()))) } + RangeKind::Linetype => { + hs.range_linetypes(values.iter().map(|e| map_linetype(&e.to_key_string()))) + } RangeKind::Position => hs, } } +/// Map a ggsql linetype name to a hephaestus dash pattern; unknown → solid. +fn map_linetype(name: &str) -> Arc<[LinetypeStep]> { + match name { + "dashed" | "longdash" => dashed(), + "dotted" => dotted(), + "dotdash" | "dashdot" | "twodash" => dashdot(), + _ => solid(), + } +} + /// Feed ggsql's resolved breaks + formatted labels into the hephaestus scale so /// axis/legend ticks match ggsql exactly (including RENAMING overrides). fn apply_breaks(hs: HScale, scale: &GScale, type_kind: Option) -> HScale { diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs new file mode 100644 index 000000000..f9cf803f9 --- /dev/null +++ b/src/writer/hephaestus/wiring.rs @@ -0,0 +1,359 @@ +//! 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::{HashMap, HashSet}; + +use hephaestus::color::Color; +use hephaestus::plot::chrome::axis::{Axis, AxisPlacement}; +use hephaestus::plot::chrome::legend::{Legend, LegendKeySpec}; +use hephaestus::plot::geom::{BuildableGeom, Geom, GeomBuilder, Raw}; +use hephaestus::plot::scale::Scale as HScale; +use hephaestus::plot::Plot as HPlot; +use hephaestus::scales::chrome::{AxisSide, LegendSide}; + +use super::channels::{ + aesthetic_column_name, build_group_keys, column_to_channel, column_to_colors, column_to_f64, + column_to_strings, +}; +use super::scales::{build_scale, RangeKind}; +use crate::plot::ScaleTypeKind; +use crate::{AestheticValue, DataFrame, GgsqlError, Layer, Plot, Result}; + +/// 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, +} + +/// Accumulates everything that attaches to the plot/composition while building +/// a geom: scales to register, channel→scale bindings, axes, and legends. +#[derive(Default)] +pub struct Wiring { + pub registered: Vec<(String, HScale)>, + pub bindings: Vec<(&'static str, String)>, + pub axes: Vec, + pub legends: Vec, + /// Channels sharing a `(data source, output kind)` reuse one scale so their + /// legends collapse (e.g. ggsql's `color` → fill + stroke). + shared_scales: HashMap<(String, RangeKind), String>, +} + +/// Which panel axis a position channel drives. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PanelAxis { + X, + Y, +} + +impl PanelAxis { + fn scale_name(self) -> &'static str { + match self { + PanelAxis::X => "pos1", + PanelAxis::Y => "pos2", + } + } + fn side(self) -> AxisSide { + match self { + PanelAxis::X => AxisSide::Bottom, + PanelAxis::Y => AxisSide::Left, + } + } +} + +/// 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), +} + +/// 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). + pub raw_numbers: Vec<(&'static str, f64)>, + pub grouped: bool, +} + +/// Build a concrete geom from its spec and attach it to the plot, recording its +/// scales/bindings/axes/legends in `w`. +pub fn build_and_add(plot: &mut HPlot, spec: GeomSpec, ctx: &Ctx, w: &mut Wiring) -> 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); + } + } + wire_positions(&mut builder, &spec.positions, ctx, w)?; + 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(*value)); + } + wire_material(&mut builder, &spec.material, ctx, w)?; + plot.add_geom(builder.build()); + Ok(()) +} + +/// Set position channels and register the `pos1`/`pos2` scales + axes. Each +/// axis scale's domain is the union extent of the position columns on that axis. +fn wire_positions( + builder: &mut GeomBuilder, + positions: &[PositionSpec], + ctx: &Ctx, + w: &mut Wiring, +) -> Result<()> { + let mut x_extent: Option<(f64, f64)> = None; + let mut y_extent: Option<(f64, f64)> = None; + + for p in positions { + let col = aesthetic_column_name(ctx.layer, &p.aesthetic).ok_or_else(|| { + GgsqlError::WriterError(format!( + "{} layer has no {} mapping", + ctx.layer.geom.geom_type(), + p.aesthetic + )) + })?; + let data = column_to_channel(ctx.df, col)?; + let extent = data.extent(); + match p.axis { + PanelAxis::X => merge_extent(&mut x_extent, extent), + PanelAxis::Y => merge_extent(&mut y_extent, extent), + } + data.apply(builder, p.channel); + w.bindings + .push((p.channel, p.axis.scale_name().to_string())); + } + + if let Some(extent) = x_extent { + register_axis(ctx, w, PanelAxis::X, extent); + } + if let Some(extent) = y_extent { + register_axis(ctx, w, PanelAxis::Y, extent); + } + Ok(()) +} + +/// Register one panel axis's scale and axis chrome. +fn register_axis(ctx: &Ctx, w: &mut Wiring, axis: PanelAxis, extent: (f64, f64)) { + let name = axis.scale_name(); + let scale = build_scale(ctx.spec.find_scale(name), extent, RangeKind::Position); + w.registered.push((name.to_string(), scale)); + + let mut rail = Axis::rail(name, AxisPlacement::Cartesian(axis.side())); + if let Some(title) = aesthetic_label(ctx.spec, ctx.layer, name) { + rail = rail.title(title); + } + w.axes.push(rail); +} + +/// Set material channels: data-mapped → scale + binding + legend; identity/ +/// literal → `Raw` visual values; unmapped → the spec's default. +fn wire_material( + builder: &mut GeomBuilder, + material: &[MaterialSpec], + ctx: &Ctx, + w: &mut Wiring, +) -> Result<()> { + let mut handled: HashSet<&str> = HashSet::new(); + + for m in material { + if handled.contains(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)?; + let extent = data.extent(); + let scale_name = shared_scale_name(w, ctx, m, extent); + data.apply(builder, m.channel); + w.bindings.push((m.channel, scale_name.clone())); + w.legends.push(material_legend( + &scale_name, + m.channel, + m.kind, + type_kind, + aesthetic_label(ctx.spec, ctx.layer, m.aesthetic), + )); + } else { + match m.kind { + RangeKind::Color => { + builder.set(m.channel, Raw(column_to_colors(ctx.df, col)?)); + } + RangeKind::Shape => { + builder.set(m.channel, Raw(column_to_strings(ctx.df, col)?)); + } + _ => { + builder.set(m.channel, Raw(column_to_f64(ctx.df, col)?)); + } + } + } + } + + // Defaults for channels no spec mapped. + for m in material { + if handled.contains(m.channel) { + continue; + } + match m.default { + MatDefault::Color(c) => { + builder.set(m.channel, c); + handled.insert(m.channel); + } + MatDefault::Number(n) => { + builder.set(m.channel, n); + handled.insert(m.channel); + } + MatDefault::None => {} + } + } + Ok(()) +} + +/// Reuse (or create) the shared scale for a material channel's `(source, kind)`, +/// returning the scale name to bind/legend against. +fn shared_scale_name(w: &mut Wiring, ctx: &Ctx, m: &MaterialSpec, extent: (f64, f64)) -> String { + let key = (aesthetic_source(ctx.layer, m.aesthetic), m.kind); + if let Some(existing) = w.shared_scales.get(&key) { + return existing.clone(); + } + let scale = build_scale(ctx.spec.find_scale(m.aesthetic), extent, m.kind); + w.registered.push((m.aesthetic.to_string(), scale)); + w.shared_scales.insert(key, m.aesthetic.to_string()); + m.aesthetic.to_string() +} + +/// Union of two data extents. +fn merge_extent(slot: &mut Option<(f64, f64)>, e: (f64, f64)) { + *slot = Some(match *slot { + Some((min, max)) => (min.min(e.0), max.max(e.1)), + None => e, + }); +} + +/// 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, + } +} + +/// A mapping's underlying data source — original column name when known, else +/// the internal column name. Lets color-family channels sharing a source +/// collapse to one scale + legend. +fn aesthetic_source(layer: &Layer, aesthetic: &str) -> String { + match layer.mappings.get(aesthetic) { + Some(AestheticValue::Column { + original_name: Some(name), + .. + }) => name.clone(), + Some(AestheticValue::Column { name, .. }) => name.clone(), + Some(AestheticValue::AnnotationColumn { name }) => name.clone(), + _ => aesthetic.to_string(), + } +} + +/// Build a legend for a data-mapped material scale. Continuous color uses a +/// colorbar; everything else a keyed point legend at the scale's breaks. +fn material_legend( + scale_name: &str, + channel: &str, + kind: RangeKind, + type_kind: Option, + title: Option, +) -> Legend { + 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 { + Legend::new(scale_name) + .side(LegendSide::Right) + .key(LegendKeySpec::point().scaled(channel, scale_name)) + }; + if let Some(title) = title { + legend = legend.title(title); + } + legend +} From ee969a484c5206b78146bbf971789616c640302a Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Tue, 23 Jun 2026 16:52:41 +0200 Subject: [PATCH 04/29] complete geoms --- Cargo.lock | 13 +- src/Cargo.toml | 2 +- src/writer/hephaestus/PLAN.md | 177 ++++++++++++++- src/writer/hephaestus/channels.rs | 26 ++- src/writer/hephaestus/geom/area.rs | 6 +- src/writer/hephaestus/geom/boxplot.rs | 163 ++++++++++++++ src/writer/hephaestus/geom/line.rs | 6 +- src/writer/hephaestus/geom/mod.rs | 11 +- src/writer/hephaestus/geom/point.rs | 6 +- src/writer/hephaestus/geom/polygon.rs | 6 +- src/writer/hephaestus/geom/rect.rs | 113 +++++++--- src/writer/hephaestus/geom/segment.rs | 80 ++++++- src/writer/hephaestus/geom/text.rs | 150 ++++++++++--- src/writer/hephaestus/geom/violin.rs | 125 +++++++++++ src/writer/hephaestus/mod.rs | 194 +++++++++++++--- src/writer/hephaestus/scales.rs | 36 ++- src/writer/hephaestus/wiring.rs | 305 ++++++++++++++++++++++++-- 17 files changed, 1264 insertions(+), 155 deletions(-) create mode 100644 src/writer/hephaestus/geom/boxplot.rs create mode 100644 src/writer/hephaestus/geom/violin.rs diff --git a/Cargo.lock b/Cargo.lock index e53d2dcf5..e5a7ff14d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2498,7 +2498,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hephaestus" version = "0.0.1" -source = "git+https://github.com/posit-dev/hephaestus.git?rev=ecce261845751969a12fa59c74c11256e8df2a41#ecce261845751969a12fa59c74c11256e8df2a41" +source = "git+https://github.com/posit-dev/hephaestus.git?rev=79b240debdb09cfeffa77d12cdd52c1c0000cf96#79b240debdb09cfeffa77d12cdd52c1c0000cf96" dependencies = [ "bytemuck", "clipper2-rust", @@ -5793,7 +5793,6 @@ dependencies = [ "js-sys", "log", "naga", - "parking_lot", "portable-atomic", "profiling", "raw-window-handle", @@ -5834,6 +5833,7 @@ dependencies = [ "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", @@ -5858,6 +5858,15 @@ 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" diff --git a/src/Cargo.toml b/src/Cargo.toml index 5d6f6e274..078155745 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -40,7 +40,7 @@ adbc_core = { version = "0.23", optional = true } geozero = { workspace = true, optional = true, features = ["with-wkb", "with-wkt", "with-geojson"] } # Hephaestus raster writer (non-default; gated, excluded from the MSRV 1.86 build) -hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "ecce261845751969a12fa59c74c11256e8df2a41", optional = true, default-features = false, features = ["vello", "png", "text"] } +hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "79b240debdb09cfeffa77d12cdd52c1c0000cf96", optional = true, default-features = false, features = ["vello", "png", "text"] } png = { version = "0.18", optional = true } # Serialization diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index 02d4af730..a906875bf 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -260,17 +260,15 @@ the single-panel point geom. - `channels.rs` extracts columns as typed channel data (text → category strings, numeric → f64) and parses literal colors. - `mod.rs` discovers mapped aesthetics, registers a scale + binds a channel per - data-mapped aesthetic, sets `Raw` constants for identity/literal aesthetics, - applies ggsql defaults otherwise, and adds axis titles + legends. + data-mapped aesthetic, sets constants for `Literal` aesthetics and `Raw` + per-row constants for identity/annotation columns, falls back to ggsql defaults + otherwise, and adds axis titles + legends. - Channels driven by the same `(data source, output kind)` bind to **one** shared scale (ggsql's `color` → fill + stroke); their legends then share a `domain_scale` and **hephaestus auto-collapses them** — no bespoke legend dedup. Generalized via `shared_scales` keyed on `(aesthetic_source, kind)`. -- Continuous domains come from the **data extent** unless the user set `FROM` - explicitly; ggsql's inferred log-scale domain collapses its lower bound to - `f64::MIN_POSITIVE`, which would push all points to one edge. Under a - non-identity transform, hephaestus computes its own (transform-aware) breaks - rather than ggsql's (which were derived from that loose domain). +- Continuous domains come straight from ggsql's resolved `numeric_domain` (see + the Multi-layer section — the writer never computes its own extents). - hephaestus builtin shape names match ggsql's 1:1 (pass-through). - Verified: discrete-color (single collapsed legend), continuous-size, log-scale (correct log spacing), axis-title tests; output eyeballed; default @@ -314,14 +312,175 @@ segment/range/rule, text. Single panel, Cartesian. Known limitations (refinements, not blockers): - No domain expansion → data points/labels on the domain edge clip at the panel boundary (same hephaestus gap noted in Phase 2). -- Bars fill the full category band (no inter-bar gap); ggsql's `width` setting - isn't read yet. - Legend keys are always point glyphs; line/area legends could use line/rect keys. (hephaestus supports `LegendKeySpec::line()`/`rect()`.) - Discrete-tile uses band edges best-effort; continuous/binned tile is exact. Deferred to Phase 3b: boxplot, violin (composite decomposition). +## Phase 3b — status: implemented + +Composite geoms render by decomposing one ggsql layer into several hephaestus +geoms sharing the `pos1`/`pos2` scales. + +- **boxplot** (`geom/boxplot.rs`): the stat's `type`-tagged rows are split by + index into box (`RectGeom`, q1→q3 filling the band), whiskers (`SegmentGeom`, + box edge→fence), median (`SegmentGeom` spanning the band via `Raw` x_band + ±0.5), and outliers (`PointGeom`). `fill` is data-mapped (e.g. fill-by-group → + colored boxes + legend) or a constant white default; stroke is constant. +- **violin** (`geom/violin.rs`): rows grouped by `pos1`, each sorted by `pos2`; + one vertical `RibbonGeom` band per category — right edge `x_band = +offset`, + left edge `x2_band = -offset` (the stat's pre-scaled half-width, a band + fraction), sharing `y = pos2`. One ribbon row per grid sample (no hand-built + mirrored outline). +- Composites dispatch through a dedicated path in `geom/mod.rs::build_into_plot` + (not `build_and_add`); shared helpers `wiring::{register_axis, resolve_fill, + material_legend}` are now `pub`, and `channels::ChannelData::select` subsets + extracted columns by row index (no DataFrame filtering). +- **hephaestus dep bumped** to rev `79b240de`: the `RibbonGeom` band-channel gap + found while planning violin (no `x_band`/`x2_band`) was fixed upstream, so + violin moved from a doubled `PolygonGeom` outline to a `RibbonGeom` band. +- Verified: `renders_boxplot` (with outlier — domain spans it, ggsql breaks), + `renders_boxplot_fill_by_group` (colored + legend), `renders_violin` (2 + categories); all eyeballed. 16 writer tests pass; default + 1.86 builds, fmt, + clippy clean. + +Known limitations: boxplot stroke/linewidth/linetype/opacity are constant (only +`fill` maps); outliers/edge points clip at the panel boundary (standing +no-expansion gap). Composite geoms remain single-panel/Cartesian. + +## Gap-closing — status: implemented + +Closing the audit gaps in implemented geoms, most-visible first. + +- **width + dodge** (bar/histogram, boxplot, violin): the `width` parameter and + `position = dodge` are honored. `wiring::band_half_width` reads + `layer.parameters["width"]` (dodge-narrowed via `Layer::adjusted_width`), and + `wiring::dodge_offsets` reads the `pos1offset`/`pos2offset` columns; bars/boxes + set per-row `x_band`/`x2_band = offset ± half-width` (via the new + `GeomSpec::data_channels`), violins add the dodge offset to their band edges. + Bars now have proper gaps and dodge side-by-side; boxes honor `width`. +- **text aesthetics**: `text` is now a custom builder wiring `fontsize`→size, + `fontweight`→weight (CSS keyword/numeric parse), `italic`→italic, + `typeface`→family, `hjust`→anchor_x, `vjust`→anchor_y (flipped to + hephaestus's top origin), `rotation`→angle (degrees→radians), plus + data-mapped/constant `fill` and `opacity`. (`column_to_bool` added.) +- **domain expansion (pass ggsql's range through)**: ggsql already expands the + resolved input range, so the writer just uses `scale.numeric_domain()` for + untransformed continuous scales (`continuous_domain`, `use_resolved` path) + instead of the raw data extent — edge marks/labels no longer clip, with no + writer-side expansion. Under a non-identity transform ggsql's data-space + expansion can collapse a log lower bound toward `f64::MIN_POSITIVE`, so those + scales use the data extent + hephaestus's transform-aware breaks (so log + axes have no expansion yet — minor; tied to ggsql's log-domain expansion). +- **legend key fidelity**: each geom declares a `LegendKind` (point/line/rect) + threaded through `GeomSpec`/`wire_material`/`resolve_fill`/`material_legend`, + so line legends show a line swatch, bar/area/box/violin a filled rect, points + a point — instead of always a point glyph. +- **boxplot/violin styling**: honor a constant `stroke` (box/whisker/median + outline, both ribbon edges) and `opacity` (box fill / ribbon alpha) via + `wiring::{constant_color, constant_number}`, instead of hardcoded grey. +- **tile linetype**: `linetype` wired on the rect material (dashed tile borders). +- **diagonal rule (abline)**: a rule with a non-zero `slope` (ggsql sets + `parameters["diagonal"] = true`) renders as a single `SegmentGeom` spanning the + position scales' resolved range — `segment::build_diagonal` grabs each pos + domain from `spec.find_scale("pos1"/"pos2").numeric_domain()`, computes + `secondary = slope·primary + intercept` over it (intercept from the `pos2`/`pos1` + literal, slope from the `slope` literal/SETTING), registers both axes from the + endpoints, and binds x/x2→pos1, y/y2→pos2. The user supplies the ranges via + `SCALE x/y FROM (..)`; when a scale is unresolved it falls back to 0..1. No + DRAW/PLACE or multi-layer distinction — the writer just reads the scale ranges. + Required teaching `wiring::{constant_color, constant_number}` to read bare + `Literal` aesthetic values (not only annotation columns), since `slope`, + `stroke`, etc. arrive as `AestheticValue::Literal`. +- **constant (`Literal`) material aesthetics across all geoms**: ggsql delivers + every geom default *and* every `SETTING` constant (`color => 'red'`, + `linetype => 'dashed'`, `size => 8`, …) as `AestheticValue::Literal` in the + layer mappings — **not** as a materialized column. `wire_material` previously + keyed only off `aesthetic_column_name` (columns), so it silently dropped all + literals and substituted its own `MatDefault`; e.g. `SETTING color => 'red'` + rendered black. It now dispatches the three variants exactly like the VL + writer's `build_encoding_channel`: `Literal` → a constant channel value via + `set_literal_channel` (color via `parse_color`, shape name string, linetype via + `map_linetype`→`Value::Linetype`, numbers pass through since hephaestus takes + points directly), `Column` (non-identity scale) → scaled + legend, + `Column`(identity)/`AnnotationColumn` → per-row `Raw`. `MatDefault` is now a + true last-resort fallback. ggsql's own defaults match the old hardcoded ones + (black fill, opacity 0.8, size 3), so plain geoms are unchanged. +- **composite per-group color + outlier/tile fidelity** (mirrors the VL writer's + shared-encoding model): `wiring::resolve_color(aesthetic, channel)` generalizes + the old `resolve_fill` — a data-mapped color registers a scale, binds the + channel, and adds one legend (returning the column for components to select); + otherwise the mapped literal (via `constant_color`) or default. Boxplot and + violin resolve **fill and stroke** this way and apply the same resolved color to + every component (box/whisker/median/outlier; both ribbon edges), so a + `stroke AS group` colors the whole mark per group under one collapsed legend. + Boxplot **outliers** are hollow points (stroke only — matching VL's + `filled = false`) honoring the `size`/`shape` aesthetics (via `constant_number` + / new `constant_string`) instead of a hardcoded dot. Discrete **tile** + `width`/`height` are read from ggsql's per-row band-fraction columns (1.0 = full + band, like VL's `datum.width * bandwidth`) → per-axis band edges at ±fraction/2. + (`opacity`-on-stroke was investigated and is **not** a gap: VL retargets + `opacity → fillOpacity` only for fill-bearing geoms and leaves stroke-only geoms + on `opacity`, which the writer already mirrors.) +- Verified: `renders_dodged_bar`, `renders_text_styled`, `renders_boxplot_styled` + (navy outline), `renders_boxplot_stroke_by_group` (per-group blue/orange incl. + hollow outlier, collapsed legend — eyeballed), `renders_tile_sized` (half-band + tiles eyeballed), `renders_diagonal_rule`, `renders_constant_aesthetics`, + grouped-line/dodged-bar legends, expanded point plot eyeballed; 23 writer tests; + default + 1.86 builds, fmt, clippy clean. + +Remaining geom gaps: text `stroke` (no hephaestus text-outline channel — upstream +deficiency); calendar-native temporal axes (numeric axes with ggsql's formatted +break labels work today; date-native ticks are a larger follow-up, not niche). + +## Multi-layer — status: implemented + +The writer now renders **N layers** into one shared Cartesian panel (`validate` +allows ≥1 layer; FACET and non-Cartesian projections still rejected). `write` +loops over `spec.layers`, building each layer's geom into one `HPlot` via one +shared `Wiring`; geoms draw in DRAW order (= z-order). + +The enabling change is a principle correction: **the writer never computes its +own scale extents — it uses the domain ggsql reports**, exactly as the VL writer +uses `input_range`. ggsql resolves every `Scale` globally over all layers × the +whole position family (`execute/scale.rs::find_columns_for_aesthetic` / +`internal_position_family`), so `numeric_domain()` already spans every layer and +includes pos2end/min/max, fences, tile extents, etc. + +- `scales.rs::continuous_domain` always returns `numeric_domain()` (the prior + data-extent / transformed-scale workaround is **removed**); `feed_breaks` now + feeds ggsql's breaks for transformed scales too. Net: log/sqrt and continuous + material scales render identically to the VL writer. +- `register_axis` is **idempotent** — each position scale + its single axis is + registered once across all layers (hephaestus doesn't dedup `add_axis`). +- Cross-layer legend dedup: `shared_scale_name` reports new-vs-reused so + `wire_material` adds a legend only when it creates the scale; composite + `resolve_color` consults `shared_scales` likewise (and `ColorSource` carries the + scale name so a ribbon's far edge binds to it). Material scales/bindings already + collapse via the persistent `shared_scales`. + +Verified (eyeballed): point+line (shared axes), bar+point overlay (point over +bar, z-order), scatter+abline (line spans the shared resolved domain), two layers +colored by one variable (single collapsed legend, one x/one y axis). Tests +`renders_multilayer_{point_line,overlay,abline,shared_legend}`; 27 writer tests; +default + 1.86 builds, fmt, clippy clean. + +**Confirmed shared with the VL writer (not a regression here):** ggsql's range +expansion runs in linear data space then clips to the transform's valid domain, +so a log scale's domain collapses to `[f64::MIN_POSITIVE, max]` and its breaks +explode. The VL writer emits the same squashed domain + breaks (verified via the +CLI). Fixing this belongs in ggsql core (expand in transform space); it will +improve both writers at once. + +Known issue (orthogonal, shared with the VL writer): a `bar` mapped to a +**numeric** primary axis is resolved by ggsql as a *continuous* `pos1` scale (no +`pos1end`), so the writer's band-fraction bars get no width — and the VL writer +hits the same wall (`bandwidth('x')` is 0 on a continuous scale). A `bar` is +meant to have a discrete primary axis; this looks like ggsql not coercing a +numeric bar axis to discrete. (`histogram` is unaffected — it carries real +`pos1`/`pos1end` bin edges and renders correctly.) + ## 8. Key source references ggsql: diff --git a/src/writer/hephaestus/channels.rs b/src/writer/hephaestus/channels.rs index 889d46660..cfdc79237 100644 --- a/src/writer/hephaestus/channels.rs +++ b/src/writer/hephaestus/channels.rs @@ -8,7 +8,7 @@ use hephaestus::color::Color; use hephaestus::plot::geom::{BuildableGeom, GeomBuilder}; use super::scales::parse_color; -use crate::array_util::{as_f64, as_str, cast_array, value_to_string}; +use crate::array_util::{as_bool, as_f64, as_str, cast_array, value_to_string}; use crate::{AestheticValue, DataFrame, Layer, Result}; /// A column extracted in the type hephaestus expects for a channel: numeric @@ -28,6 +28,16 @@ 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 { @@ -122,6 +132,20 @@ pub fn build_group_keys(df: &DataFrame, partition_by: &[String]) -> Result 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> { diff --git a/src/writer/hephaestus/geom/area.rs b/src/writer/hephaestus/geom/area.rs index 83587b057..208927403 100644 --- a/src/writer/hephaestus/geom/area.rs +++ b/src/writer/hephaestus/geom/area.rs @@ -6,7 +6,9 @@ use hephaestus::color::rgb8; use super::super::scales::RangeKind; -use super::super::wiring::{Ctx, GeomSpec, MatDefault, MaterialSpec, PanelAxis, PositionSpec}; +use super::super::wiring::{ + Ctx, GeomSpec, LegendKind, MatDefault, MaterialSpec, PanelAxis, PositionSpec, +}; use crate::plot::layer::geom::GeomType; pub fn spec(ctx: &Ctx) -> GeomSpec { @@ -72,6 +74,8 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { ], raw_strings: &[], raw_numbers: vec![], + data_channels: vec![], + legend_key: LegendKind::Rect, grouped: true, } } diff --git a/src/writer/hephaestus/geom/boxplot.rs b/src/writer/hephaestus/geom/boxplot.rs new file mode 100644 index 000000000..b18703631 --- /dev/null +++ b/src/writer/hephaestus/geom/boxplot.rs @@ -0,0 +1,163 @@ +//! `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), +//! and outliers (`PointGeom`). All components share the `pos1`/`pos2` scales. + +use hephaestus::color::rgb8; +use hephaestus::plot::{Plot as HPlot, PointGeom, RectGeom, SegmentGeom}; + +use super::super::channels::{ + aesthetic_column_name, column_to_channel, column_to_f64, column_to_strings, +}; +use super::super::wiring::{ + band_half_width, constant_number, constant_string, dodge_offsets, register_axis, resolve_color, + Ctx, LegendKind, PanelAxis, Wiring, +}; +use crate::{GgsqlError, Result}; + +pub fn build(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { + let (layer, df) = (ctx.layer, ctx.df); + let n = df.height(); + + let pos1 = require(layer, "pos1")?; + let type_col = require(layer, "type")?; + let value = require(layer, "pos2")?; + let value2 = require(layer, "pos2end")?; + + let p1 = column_to_channel(df, pos1)?; + let p2 = column_to_f64(df, value)?; + let p2e = column_to_f64(df, value2)?; + 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"); + + // Shared scales: pos1 (category), pos2 (full value range incl. fences). + register_axis(ctx, w, PanelAxis::X, p1.extent()); + register_axis(ctx, w, PanelAxis::Y, finite_extent(&[&p2, &p2e])); + for (channel, scale) in [("x", "pos1"), ("x2", "pos1"), ("y", "pos2"), ("y2", "pos2")] { + w.bindings.push((channel, scale.to_string())); + } + + // 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, + w, + "fill", + "fill", + rgb8(255, 255, 255), + LegendKind::Rect, + )?; + let stroke = resolve_color( + ctx, + w, + "stroke", + "stroke", + rgb8(60, 60, 60), + LegendKind::Rect, + )?; + let alpha = constant_number(ctx, "opacity", 1.0); + // Box width (band fraction, dodge-aware) + per-row dodge offsets. + let offsets = dodge_offsets(df, "pos1offset"); + let half = band_half_width(layer, 0.75); + + // Box: a rect from q1 to q3 occupying `width` of the band (dodge-offset). + if !box_i.is_empty() { + let mut b = RectGeom::builder(); + p1.select(&box_i).apply(&mut b, "x"); + p1.select(&box_i).apply(&mut b, "x2"); + b.set("y", pick(&p2, &box_i)); + b.set("y2", pick(&p2e, &box_i)); + b.set("x_band", shift(&offsets, &box_i, -half)); + b.set("x2_band", shift(&offsets, &box_i, half)); + fill.apply(&mut b, "fill", &box_i); + stroke.apply(&mut b, "stroke", &box_i); + b.set("fill_opacity", alpha); + plot.add_geom(b.build()); + } + + // Whiskers: vertical segments at the band centre, box edge → fence. + if !whisk_i.is_empty() { + let mut b = SegmentGeom::builder(); + p1.select(&whisk_i).apply(&mut b, "x"); + p1.select(&whisk_i).apply(&mut b, "x2"); + b.set("y", pick(&p2, &whisk_i)); + b.set("y2", pick(&p2e, &whisk_i)); + b.set("x_band", shift(&offsets, &whisk_i, 0.0)); + b.set("x2_band", shift(&offsets, &whisk_i, 0.0)); + stroke.apply(&mut b, "stroke", &whisk_i); + plot.add_geom(b.build()); + } + + // Median: a horizontal segment spanning the band at the median value. + if !med_i.is_empty() { + let mut b = SegmentGeom::builder(); + p1.select(&med_i).apply(&mut b, "x"); + p1.select(&med_i).apply(&mut b, "x2"); + b.set("y", pick(&p2, &med_i)); + b.set("y2", pick(&p2, &med_i)); + b.set("x_band", shift(&offsets, &med_i, -half)); + b.set("x2_band", shift(&offsets, &med_i, half)); + stroke.apply(&mut b, "stroke", &med_i); + b.set("linewidth", 1.5_f64); + plot.add_geom(b.build()); + } + + // Outliers: hollow points (stroke only, matching VL's `filled = false`) + // at their value, honoring the `size`/`shape` aesthetics. + if !out_i.is_empty() { + let mut b = PointGeom::builder(); + p1.select(&out_i).apply(&mut b, "x"); + b.set("y", pick(&p2, &out_i)); + b.set("x_band", shift(&offsets, &out_i, 0.0)); + stroke.apply(&mut b, "stroke", &out_i); + b.set("size", constant_number(ctx, "size", 3.0)); + b.set("shape", constant_string(ctx, "shape", "circle")); + plot.add_geom(b.build()); + } + + Ok(()) +} + +fn require<'a>(layer: &'a crate::Layer, aesthetic: &str) -> Result<&'a str> { + aesthetic_column_name(layer, aesthetic) + .ok_or_else(|| GgsqlError::WriterError(format!("boxplot layer has no {aesthetic} mapping"))) +} + +/// 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. ±half +/// the box width for the two edges, 0 for a centered line/point). +fn shift(offsets: &[f64], idx: &[usize], delta: f64) -> Vec { + idx.iter().map(|&i| offsets[i] + delta).collect() +} + +/// Finite (min, max) across several columns, or `(0, 1)` if none are finite. +fn finite_extent(cols: &[&[f64]]) -> (f64, f64) { + let mut min = f64::INFINITY; + let mut max = f64::NEG_INFINITY; + for v in cols { + for &x in v.iter() { + if x.is_finite() { + min = min.min(x); + max = max.max(x); + } + } + } + if min <= max { + (min, max) + } else { + (0.0, 1.0) + } +} diff --git a/src/writer/hephaestus/geom/line.rs b/src/writer/hephaestus/geom/line.rs index 08c8affcb..18ea3510b 100644 --- a/src/writer/hephaestus/geom/line.rs +++ b/src/writer/hephaestus/geom/line.rs @@ -7,7 +7,9 @@ use hephaestus::color::rgb8; use super::super::scales::RangeKind; -use super::super::wiring::{Ctx, GeomSpec, MatDefault, MaterialSpec, PanelAxis, PositionSpec}; +use super::super::wiring::{ + Ctx, GeomSpec, LegendKind, MatDefault, MaterialSpec, PanelAxis, PositionSpec, +}; use crate::plot::layer::geom::GeomType; pub fn spec(ctx: &Ctx) -> GeomSpec { @@ -52,6 +54,8 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { ], 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 index 91ebf2dc2..c11369b50 100644 --- a/src/writer/hephaestus/geom/mod.rs +++ b/src/writer/hephaestus/geom/mod.rs @@ -3,15 +3,17 @@ //! through the shared wiring. Composite geoms (boxplot, violin) are Phase 3b. mod area; +mod boxplot; mod line; mod point; mod polygon; mod rect; mod segment; mod text; +mod violin; use hephaestus::plot::{ - LineGeom, Plot as HPlot, PointGeom, PolygonGeom, RectGeom, RibbonGeom, SegmentGeom, TextGeom, + LineGeom, Plot as HPlot, PointGeom, PolygonGeom, RectGeom, RibbonGeom, SegmentGeom, }; use super::wiring::{build_and_add, Ctx, Wiring}; @@ -32,10 +34,13 @@ pub fn build_into_plot(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<() build_and_add::(plot, area::spec(ctx), ctx, w) } GeomType::Polygon => build_and_add::(plot, polygon::spec(ctx), ctx, w), + GeomType::Rule if segment::is_diagonal(ctx.layer) => segment::build_diagonal(plot, ctx, w), GeomType::Segment | GeomType::Range | GeomType::Rule => { build_and_add::(plot, segment::spec(ctx), ctx, w) } - GeomType::Text => build_and_add::(plot, text::spec(ctx), ctx, w), + GeomType::Text => text::build(plot, ctx, w), + GeomType::Boxplot => boxplot::build(plot, ctx, w), + GeomType::Violin => violin::build(plot, ctx, w), other => Err(GgsqlError::WriterError(format!( "hephaestus writer does not support the '{other}' geom yet" ))), @@ -61,5 +66,7 @@ pub fn is_supported(geom: GeomType) -> bool { | GeomType::Range | GeomType::Rule | GeomType::Text + | GeomType::Boxplot + | GeomType::Violin ) } diff --git a/src/writer/hephaestus/geom/point.rs b/src/writer/hephaestus/geom/point.rs index dab0a77f9..1ab42c1e5 100644 --- a/src/writer/hephaestus/geom/point.rs +++ b/src/writer/hephaestus/geom/point.rs @@ -3,7 +3,9 @@ use hephaestus::color::rgb8; use super::super::scales::RangeKind; -use super::super::wiring::{Ctx, GeomSpec, MatDefault, MaterialSpec, PanelAxis, PositionSpec}; +use super::super::wiring::{ + Ctx, GeomSpec, LegendKind, MatDefault, MaterialSpec, PanelAxis, PositionSpec, +}; pub fn spec(_ctx: &Ctx) -> GeomSpec { GeomSpec { @@ -38,6 +40,8 @@ pub fn spec(_ctx: &Ctx) -> GeomSpec { ], 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 index fac31f40a..7831cc61c 100644 --- a/src/writer/hephaestus/geom/polygon.rs +++ b/src/writer/hephaestus/geom/polygon.rs @@ -4,7 +4,9 @@ use hephaestus::color::rgb8; use super::super::scales::RangeKind; -use super::super::wiring::{Ctx, GeomSpec, MatDefault, MaterialSpec, PanelAxis, PositionSpec}; +use super::super::wiring::{ + Ctx, GeomSpec, LegendKind, MatDefault, MaterialSpec, PanelAxis, PositionSpec, +}; pub fn spec(_ctx: &Ctx) -> GeomSpec { GeomSpec { @@ -43,6 +45,8 @@ pub fn spec(_ctx: &Ctx) -> GeomSpec { ], 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 index 6bf4bf576..a1fcc2c36 100644 --- a/src/writer/hephaestus/geom/rect.rs +++ b/src/writer/hephaestus/geom/rect.rs @@ -1,22 +1,34 @@ //! `bar`, `histogram`, and `tile` geoms → hephaestus `RectGeom`. //! -//! Bars fill their category band (RectGeom's discrete `x_band` defaults ±0.5); -//! histograms span explicit bin edges; tiles span min/max extents. Bars and -//! histograms are orientation-aware (transposed swaps the value axis to x). +//! 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; +use super::super::channels::{aesthetic_column_name, column_to_f64}; use super::super::scales::RangeKind; -use super::super::wiring::{Ctx, GeomSpec, MatDefault, MaterialSpec, PanelAxis, PositionSpec}; +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) = match ctx.layer.geom.geom_type() { - GeomType::Bar => (bar(ctx.transposed), vec![]), - GeomType::Histogram => (histogram(ctx.transposed), vec![]), - GeomType::Tile => tile(ctx), - _ => (Vec::new(), vec![]), + 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 { @@ -43,30 +55,51 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { 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: the main axis is a band (x and x2 share the category -/// column; band defaults fill the cell); the value axis runs baseline→value. -fn bar(transposed: bool) -> Vec { - if !transposed { - 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), - ] +/// 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)>) { + let half = 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 { - 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), - ] + 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)], + ) } } @@ -89,9 +122,11 @@ fn histogram(transposed: bool) -> Vec { } } -/// Tile/heatmap: continuous tiles span min/max extents; discrete tiles fill the -/// category band on both axes. -fn tile(ctx: &Ctx) -> (Vec, Vec<(&'static str, f64)>) { +/// Tile/heatmap: continuous tiles span min/max extents; discrete tiles occupy a +/// `width`/`height` fraction of the category band on each axis. ggsql resolves +/// `width`/`height` into per-row columns (1.0 = full band, like VL's +/// `datum.width * bandwidth`); each axis's band edges sit at ±fraction/2. +fn tile(ctx: &Ctx) -> (Vec, Vec<(&'static str, Vec)>) { if aesthetic_column_name(ctx.layer, "pos1min").is_some() { ( vec![ @@ -103,7 +138,8 @@ fn tile(ctx: &Ctx) -> (Vec, Vec<(&'static str, f64)>) { vec![], ) } else { - // Discrete tile: x band defaults (±0.5) fill x; add y bands for y. + let (x_lo, x_hi) = band_edges(ctx, "width"); + let (y_lo, y_hi) = band_edges(ctx, "height"); ( vec![ PositionSpec::new("x", "pos1", PanelAxis::X), @@ -111,7 +147,22 @@ fn tile(ctx: &Ctx) -> (Vec, Vec<(&'static str, f64)>) { PositionSpec::new("y", "pos2", PanelAxis::Y), PositionSpec::new("y2", "pos2", PanelAxis::Y), ], - vec![("y_band", -0.5), ("y2_band", 0.5)], + vec![ + ("x_band", x_lo), + ("x2_band", x_hi), + ("y_band", y_lo), + ("y2_band", y_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 index a24a24690..a7888d202 100644 --- a/src/writer/hephaestus/geom/segment.rs +++ b/src/writer/hephaestus/geom/segment.rs @@ -7,11 +7,17 @@ //! (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; use super::super::scales::RangeKind; -use super::super::wiring::{Ctx, GeomSpec, MatDefault, MaterialSpec, PanelAxis, PositionSpec}; +use super::super::wiring::{ + constant_color, constant_number, register_axis, Ctx, GeomSpec, LegendKind, MatDefault, + MaterialSpec, PanelAxis, PositionSpec, Wiring, +}; 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() { @@ -78,13 +84,79 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { ], raw_strings: &[], raw_numbers, + data_channels: vec![], + legend_key: LegendKind::Line, grouped: false, } } -/// A rule is a reference line spanning the whole panel on its free axis. -/// Best-effort: the free axis uses raw 0..1 panel fractions, so no scale/axis -/// is created for it. +/// 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): a single line spanning the position scales' +/// resolved range, with `secondary = slope * primary + intercept`. The 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. +pub fn build_diagonal(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { + let slope = slope_value(ctx); + + let (x0, y0, x1, y1, x_extent, y_extent) = if !ctx.transposed { + // y-intercept (`pos2`); x is the spanning axis. + let intercept = constant_number(ctx, "pos2", 0.0); + let (x0, x1) = primary_range(ctx, "pos1"); + let (y0, y1) = (slope * x0 + intercept, slope * x1 + intercept); + (x0, y0, x1, y1, (x0, x1), (y0.min(y1), y0.max(y1))) + } else { + // x-intercept (`pos1`); y is the spanning axis. + let intercept = constant_number(ctx, "pos1", 0.0); + let (y0, y1) = primary_range(ctx, "pos2"); + let (x0, x1) = (slope * y0 + intercept, slope * y1 + intercept); + (x0, y0, x1, y1, (x0.min(x1), x0.max(x1)), (y0, y1)) + }; + + register_axis(ctx, w, PanelAxis::X, x_extent); + register_axis(ctx, w, PanelAxis::Y, y_extent); + for (channel, scale) in [("x", "pos1"), ("x2", "pos1"), ("y", "pos2"), ("y2", "pos2")] { + w.bindings.push((channel, scale.to_string())); + } + + let mut b = SegmentGeom::builder(); + b.set("x", vec![x0]); + b.set("x2", vec![x1]); + b.set("y", vec![y0]); + b.set("y2", vec![y1]); + b.set("stroke", constant_color(ctx, "stroke", rgb8(0, 0, 0))); + b.set("linewidth", constant_number(ctx, "linewidth", 1.0)); + b.set("stroke_opacity", constant_number(ctx, "opacity", 1.0)); + plot.add_geom(b.build()); + Ok(()) +} + +/// 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)) +} + +/// Slope from the `slope` aesthetic (literal/annotation) or the SETTING param. +fn slope_value(ctx: &Ctx) -> f64 { + let param = match ctx.layer.parameters.get("slope") { + Some(ParameterValue::Number(n)) => *n, + _ => 0.0, + }; + constant_number(ctx, "slope", param) +} + +/// 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. diff --git a/src/writer/hephaestus/geom/text.rs b/src/writer/hephaestus/geom/text.rs index 7f7853707..20ae3b4c9 100644 --- a/src/writer/hephaestus/geom/text.rs +++ b/src/writer/hephaestus/geom/text.rs @@ -1,36 +1,124 @@ -//! `text` geom → hephaestus `TextGeom`. The `label` aesthetic carries the -//! string (set raw, unscaled); position + color/size map as usual. +//! `text` geom → hephaestus `TextGeom`. A custom builder (not the generic +//! position/material path) because several text aesthetics need conversion: +//! `vjust` flips for hephaestus's top-origin `anchor_y`, `rotation` is degrees +//! → radians, `fontweight` accepts CSS keywords, and `italic` is boolean. + +use std::f64::consts::PI; 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_bool, column_to_channel, column_to_f64, column_to_strings, +}; +use super::super::wiring::{register_axis, resolve_color, Ctx, LegendKind, PanelAxis, Wiring}; +use crate::{GgsqlError, Result}; + +pub fn build(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { + let (layer, df) = (ctx.layer, ctx.df); + let n = df.height(); + + let pos1 = require(layer, "pos1")?; + let pos2 = require(layer, "pos2")?; + let label = require(layer, "label")?; + + let mut b = TextGeom::builder(); + + // Positions + shared scales/axes. + let p1 = column_to_channel(df, pos1)?; + let p1x = p1.extent(); + let p2 = column_to_channel(df, pos2)?; + let p2y = p2.extent(); + register_axis(ctx, w, PanelAxis::X, p1x); + register_axis(ctx, w, PanelAxis::Y, p2y); + w.bindings.push(("x", "pos1".to_string())); + w.bindings.push(("y", "pos2".to_string())); + p1.apply(&mut b, "x"); + p2.apply(&mut b, "y"); + + // Label string. + b.set("text", Raw(column_to_strings(df, label)?)); + + // Color: data-mapped (color-by-group) or constant black. + resolve_color(ctx, w, "fill", "fill", rgb8(0, 0, 0), LegendKind::Point)?.apply( + &mut b, + "fill", + &(0..n).collect::>(), + ); + + // Scalar styling (unscaled visual values). + b.set("fill_opacity", Raw(numeric_or(ctx, "opacity", 1.0))); + b.set("size", Raw(numeric_or(ctx, "fontsize", 11.0))); + b.set("anchor_x", Raw(numeric_or(ctx, "hjust", 0.5))); + // ggsql vjust: 0 = bottom, 1 = top; hephaestus anchor_y: 0 = top, 1 = bottom. + let anchor_y: Vec = numeric_or(ctx, "vjust", 0.5) + .iter() + .map(|v| 1.0 - v) + .collect(); + b.set("anchor_y", Raw(anchor_y)); + // ggsql rotation is in degrees; hephaestus angle is radians (math CCW). + let angle: Vec = numeric_or(ctx, "rotation", 0.0) + .iter() + .map(|d| d * PI / 180.0) + .collect(); + b.set("angle", Raw(angle)); + b.set("weight", Raw(weights(ctx, n)?)); + b.set("italic", Raw(italics(ctx, n)?)); + if let Some(col) = aesthetic_column_name(layer, "typeface") { + b.set("family", Raw(column_to_strings(df, col)?)); + } + + plot.add_geom(b.build()); + Ok(()) +} + +fn require<'a>(layer: &'a crate::Layer, aesthetic: &str) -> Result<&'a str> { + aesthetic_column_name(layer, aesthetic) + .ok_or_else(|| GgsqlError::WriterError(format!("text layer has no {aesthetic} mapping"))) +} + +/// A per-row numeric aesthetic, or `default` repeated when it isn't mapped. +fn numeric_or(ctx: &Ctx, aesthetic: &str, default: f64) -> Vec { + match aesthetic_column_name(ctx.layer, aesthetic) { + Some(col) => column_to_f64(ctx.df, col).unwrap_or_else(|_| vec![default; ctx.df.height()]), + None => vec![default; ctx.df.height()], + } +} + +/// Font weights as numeric 100–900 (CSS keywords parsed), default 400. +fn weights(ctx: &Ctx, n: usize) -> Result> { + match aesthetic_column_name(ctx.layer, "fontweight") { + Some(col) => Ok(column_to_strings(ctx.df, col)? + .iter() + .map(|s| parse_weight(s)) + .collect()), + None => Ok(vec![400.0; n]), + } +} + +/// Parse a CSS font-weight keyword or numeric string to 100–900. +fn parse_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 + } +} -use super::super::scales::RangeKind; -use super::super::wiring::{Ctx, GeomSpec, 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("color", "fill", RangeKind::Color, MatDefault::None), - MaterialSpec::new("colour", "fill", RangeKind::Color, MatDefault::None), - MaterialSpec::new("fontsize", "size", RangeKind::Number, MatDefault::None), - MaterialSpec::new( - "opacity", - "fill_opacity", - RangeKind::Number, - MatDefault::None, - ), - ], - raw_strings: &[("text", "label")], - raw_numbers: vec![], - grouped: false, +/// Italic flags, default false. +fn italics(ctx: &Ctx, n: usize) -> Result> { + match aesthetic_column_name(ctx.layer, "italic") { + Some(col) => column_to_bool(ctx.df, col), + None => Ok(vec![false; n]), } } diff --git a/src/writer/hephaestus/geom/violin.rs b/src/writer/hephaestus/geom/violin.rs new file mode 100644 index 000000000..daf170f4e --- /dev/null +++ b/src/writer/hephaestus/geom/violin.rs @@ -0,0 +1,125 @@ +//! `violin` composite geom. ggsql's stat emits a KDE grid per category +//! (`pos1` = category, `pos2` = value, `offset` = pre-scaled half-width). We +//! render one vertical `RibbonGeom` band per category: the right edge sits at +//! `+offset` and the left edge at `-offset` of the category band (via the +//! ribbon's per-row `x_band` / `x2_band` channels), sharing `y = pos2`. One row +//! per grid sample — no hand-built outline. + +use std::cmp::Ordering; +use std::collections::HashMap; + +use hephaestus::color::rgb8; +use hephaestus::plot::{Plot as HPlot, RibbonGeom}; + +use super::super::channels::{ + aesthetic_column_name, column_to_channel, column_to_f64, column_to_strings, +}; +use super::super::wiring::{ + constant_number, dodge_offsets, register_axis, resolve_color, Ctx, LegendKind, PanelAxis, + Wiring, +}; +use crate::{GgsqlError, Result}; + +pub fn build(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { + let (layer, df) = (ctx.layer, ctx.df); + + let pos1 = require(layer, "pos1")?; + let pos2 = require(layer, "pos2")?; + let offset = require(layer, "offset")?; + + let p1 = column_to_channel(df, pos1)?; + let cat = column_to_strings(df, pos1)?; // grouping key per row + let p2 = column_to_f64(df, pos2)?; + let off = column_to_f64(df, offset)?; + + // Order rows so each category's band is contiguous and ascending in pos2 + // (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, c) in cat.iter().enumerate() { + let g = *index.entry(c.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); + } + + register_axis(ctx, w, PanelAxis::X, p1.extent()); + register_axis(ctx, w, PanelAxis::Y, finite_extent(&p2)); + for (channel, scale) in [("x", "pos1"), ("x2", "pos1"), ("y", "pos2")] { + w.bindings.push((channel, scale.to_string())); + } + + // One vertical ribbon per category: right edge +offset, left edge -offset, + // both shifted by the dodge offset (zero when not dodged). + let dodge = dodge_offsets(df, "pos1offset"); + let keys: Vec = order.iter().map(|&i| cat[i].clone()).collect(); + let x_band: Vec = order.iter().map(|&i| dodge[i] + off[i]).collect(); + let x2_band: Vec = order.iter().map(|&i| dodge[i] - off[i]).collect(); + let ys: Vec = order.iter().map(|&i| p2[i]).collect(); + + // Resolve fill + stroke once (data-mapped → shared scale/legend, else + // constant), mirroring the VL writer's shared-encoding model. + let fill = resolve_color( + ctx, + w, + "fill", + "fill", + rgb8(255, 255, 255), + LegendKind::Rect, + )?; + let stroke = resolve_color( + ctx, + w, + "stroke", + "stroke", + rgb8(60, 60, 60), + LegendKind::Rect, + )?; + // The ribbon's two edges share the stroke scale (`stroke2` is the far edge). + if let Some(name) = stroke.scale_name() { + w.bindings.push(("stroke2", name.to_string())); + } + + let mut b = RibbonGeom::builder(); + b.keys(keys); + p1.select(&order).apply(&mut b, "x"); + p1.select(&order).apply(&mut b, "x2"); + b.set("x_band", x_band); + b.set("x2_band", x2_band); + b.set("y", ys); + fill.apply(&mut b, "fill", &order); + stroke.apply(&mut b, "stroke", &order); + stroke.apply(&mut b, "stroke2", &order); + b.set("alpha", constant_number(ctx, "opacity", 1.0)); + plot.add_geom(b.build()); + + Ok(()) +} + +fn require<'a>(layer: &'a crate::Layer, aesthetic: &str) -> Result<&'a str> { + aesthetic_column_name(layer, aesthetic) + .ok_or_else(|| GgsqlError::WriterError(format!("violin layer has no {aesthetic} mapping"))) +} + +/// Finite (min, max), or `(0, 1)` if no finite values. +fn finite_extent(v: &[f64]) -> (f64, f64) { + let mut min = f64::INFINITY; + let mut max = f64::NEG_INFINITY; + for &x in v { + if x.is_finite() { + min = min.min(x); + max = max.max(x); + } + } + if min <= max { + (min, max) + } else { + (0.0, 1.0) + } +} diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index e09637458..4a94b58e1 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -4,12 +4,11 @@ //! renderer. //! //! **Scope** (see `src/writer/hephaestus/PLAN.md`): single-panel, single-layer, -//! Cartesian plots. All non-composite geoms (point/line/path/area/ribbon/bar/ -//! histogram/tile/polygon/segment/rule/range/text/density/smooth), all scale +//! Cartesian plots. All geoms (point/line/path/area/ribbon/bar/histogram/tile/ +//! polygon/segment/rule/range/text/density/smooth/boxplot/violin), all scale //! types/transforms, material aesthetics, axis titles, and legends are -//! supported. Faceting, projections, and composite geoms (boxplot/violin) -//! arrive in later phases; unsupported specs are rejected by -//! [`HephaestusWriter::validate`]. +//! supported. Faceting, projections, and multi-layer arrive in later phases; +//! unsupported specs are rejected by [`HephaestusWriter::validate`]. //! //! Rendering uses hephaestus's Vello (GPU) backend, so a working wgpu adapter //! (hardware or software, e.g. lavapipe) is required at render time. @@ -84,17 +83,18 @@ impl Writer for HephaestusWriter { )); } } - if spec.layers.len() != 1 { - return Err(GgsqlError::WriterError(format!( - "hephaestus writer supports exactly one layer, got {}", - spec.layers.len() - ))); + if spec.layers.is_empty() { + return Err(GgsqlError::WriterError( + "hephaestus writer requires at least one layer".into(), + )); } - let geom_type = spec.layers[0].geom.geom_type(); - if !geom::is_supported(geom_type) { - return Err(GgsqlError::WriterError(format!( - "hephaestus writer does not support the '{geom_type}' geom yet" - ))); + for layer in &spec.layers { + let geom_type = layer.geom.geom_type(); + if !geom::is_supported(geom_type) { + return Err(GgsqlError::WriterError(format!( + "hephaestus writer does not support the '{geom_type}' geom yet" + ))); + } } Ok(()) } @@ -102,20 +102,21 @@ impl Writer for HephaestusWriter { fn write(&self, spec: &Plot, data: &HashMap) -> Result { self.validate(spec)?; - let layer = &spec.layers[0]; - let df = layer_dataframe(layer, data)?; - let ctx = Ctx { - spec, - layer, - df, - transposed: is_transposed(layer), - }; - - // Build the geom (+ its scales/axes/legends) through the shared wiring. + // Build every layer's geom into one panel, accumulating shared + // scales/axes/legends. Geoms draw in layer (DRAW) order = z-order. let mut plot = HPlot::new(&single_panel(), PANEL_ID).shape_registry(ShapeRegistry::with_builtins()); let mut w = Wiring::default(); - geom::build_into_plot(&mut plot, &ctx, &mut w)?; + for layer in &spec.layers { + let df = layer_dataframe(layer, data)?; + let ctx = Ctx { + spec, + layer, + df, + transposed: is_transposed(layer), + }; + geom::build_into_plot(&mut plot, &ctx, &mut w)?; + } for (channel, scale_name) in &w.bindings { plot.set_binding(*channel, scale_name.clone()); @@ -282,6 +283,15 @@ mod tests { )); } + #[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( @@ -323,6 +333,16 @@ mod tests { )); } + #[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", + )); + } + #[test] fn renders_polygon() { assert_png_or_skip(render( @@ -332,12 +352,128 @@ mod tests { } #[test] - fn rejects_composite_geom() { + 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)", + )); + } + + #[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_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 rejects_unsupported_geom() { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); let spec = reader .execute( - "SELECT 1 AS g, 2 AS y UNION ALL SELECT 1, 5 UNION ALL SELECT 1, 3 \ - VISUALISE g AS x, y AS y DRAW boxplot", + "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 = HephaestusWriter::new(320, 240, 96.0); diff --git a/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs index d58a30351..6e6bbb6fb 100644 --- a/src/writer/hephaestus/scales.rs +++ b/src/writer/hephaestus/scales.rs @@ -50,9 +50,10 @@ pub fn build_scale(scale: Option<&GScale>, data_extent: (f64, f64), kind: RangeK Some(ScaleTypeKind::Ordinal) => scale::ordinal(domain_values(usable)), // Continuous, Binned, Identity, or unknown: a continuous mapper. _ => { + let h_transform = transform.and_then(map_transform); let (min, max) = continuous_domain(usable, data_extent); let mut c = scale::continuous(min..=max); - if let Some(t) = transform.and_then(map_transform) { + if let Some(t) = h_transform { c = c.with_transform(t); } c @@ -65,31 +66,26 @@ pub fn build_scale(scale: Option<&GScale>, data_extent: (f64, f64), kind: RangeK } } - // Feed ggsql's resolved breaks for categorical scales and for - // identity-transform continuous scales (preserving ggsql's formatting). - // Under a non-identity transform ggsql's breaks are computed in transform - // space against a possibly-loose domain, so let hephaestus derive its own - // transform-aware breaks instead. - let feed_breaks = matches!( - type_kind, - Some(ScaleTypeKind::Discrete) | Some(ScaleTypeKind::Ordinal) | Some(ScaleTypeKind::Binned) - ) || matches!(transform, None | Some(GTransform::Identity)); - if feed_breaks { - if let Some(s) = usable { - hs = apply_breaks(hs, s, type_kind); - } + // 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 now uses, so they line up. `apply_breaks` is a no-op when + // the scale has no resolved breaks. + if let Some(s) = usable { + hs = apply_breaks(hs, s, type_kind); } hs } -/// Domain for a continuous scale. ggsql's resolved domain is authoritative only -/// when the user set it explicitly (`FROM`); inferred domains can be degenerate -/// (e.g. a log scale's lower bound collapses to `f64::MIN_POSITIVE`), so fall -/// back to the actual data extent. +/// 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`. `data_extent` is only a +/// fallback for a scale ggsql left unresolved. fn continuous_domain(scale: Option<&GScale>, data_extent: (f64, f64)) -> (f64, f64) { let domain = scale - .filter(|s| s.explicit_input_range) .and_then(|s| s.numeric_domain()) + .filter(|(min, max)| min.is_finite() && max.is_finite()) .unwrap_or(data_extent); pad_degenerate(domain.0, domain.1) } @@ -118,7 +114,7 @@ fn apply_output_range(hs: HScale, kind: RangeKind, values: &[ArrayElement]) -> H } /// Map a ggsql linetype name to a hephaestus dash pattern; unknown → solid. -fn map_linetype(name: &str) -> Arc<[LinetypeStep]> { +pub fn map_linetype(name: &str) -> Arc<[LinetypeStep]> { match name { "dashed" | "longdash" => dashed(), "dotted" => dotted(), diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index f9cf803f9..aa785826a 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -11,13 +11,14 @@ use hephaestus::plot::geom::{BuildableGeom, Geom, GeomBuilder, Raw}; use hephaestus::plot::scale::Scale as HScale; use hephaestus::plot::Plot as HPlot; use hephaestus::scales::chrome::{AxisSide, LegendSide}; +use hephaestus::scales::value::Value as HValue; use super::channels::{ aesthetic_column_name, build_group_keys, column_to_channel, column_to_colors, column_to_f64, - column_to_strings, + column_to_strings, ChannelData, }; -use super::scales::{build_scale, RangeKind}; -use crate::plot::ScaleTypeKind; +use super::scales::{build_scale, map_linetype, parse_color, RangeKind}; +use crate::plot::{ParameterValue, ScaleTypeKind}; use crate::{AestheticValue, DataFrame, GgsqlError, Layer, Plot, Result}; /// Read-only context for building one layer's geom. @@ -113,6 +114,15 @@ pub enum MatDefault { 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, +} + /// 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 { @@ -124,6 +134,11 @@ pub struct GeomSpec { /// Constant panel-space channel values, scale-bypassing (e.g. a rule's /// 0..1 span, discrete-tile band edges): (hephaestus channel, value). 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, } @@ -148,7 +163,10 @@ where for (channel, value) in &spec.raw_numbers { builder.set(*channel, Raw(*value)); } - wire_material(&mut builder, &spec.material, ctx, w)?; + for (channel, values) in spec.data_channels { + builder.set(channel, values); + } + wire_material(&mut builder, &spec.material, ctx, w, spec.legend_key)?; plot.add_geom(builder.build()); Ok(()) } @@ -192,9 +210,18 @@ fn wire_positions( Ok(()) } -/// Register one panel axis's scale and axis chrome. -fn register_axis(ctx: &Ctx, w: &mut Wiring, axis: PanelAxis, extent: (f64, f64)) { +/// Register one panel axis's scale and axis chrome. Composite geoms call this +/// directly to set up shared `pos1`/`pos2` scales before building components. +/// +/// Idempotent across layers: a position scale is registered (and its axis added) +/// exactly once, since ggsql's resolved domain is global — every layer that uses +/// the axis shares the same scale. `extent` is only a fallback for an unresolved +/// scale, so the first layer's value is authoritative. +pub fn register_axis(ctx: &Ctx, w: &mut Wiring, axis: PanelAxis, extent: (f64, f64)) { let name = axis.scale_name(); + if w.registered.iter().any(|(n, _)| n == name) { + return; + } let scale = build_scale(ctx.spec.find_scale(name), extent, RangeKind::Position); w.registered.push((name.to_string(), scale)); @@ -212,6 +239,7 @@ fn wire_material( material: &[MaterialSpec], ctx: &Ctx, w: &mut Wiring, + legend_kind: LegendKind, ) -> Result<()> { let mut handled: HashSet<&str> = HashSet::new(); @@ -219,6 +247,17 @@ fn wire_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; }; @@ -233,16 +272,22 @@ fn wire_material( if data_mapped { let data = column_to_channel(ctx.df, col)?; let extent = data.extent(); - let scale_name = shared_scale_name(w, ctx, m, extent); + let (scale_name, is_new) = shared_scale_name(w, ctx, m, extent); data.apply(builder, m.channel); w.bindings.push((m.channel, scale_name.clone())); - w.legends.push(material_legend( - &scale_name, - m.channel, - m.kind, - type_kind, - aesthetic_label(ctx.spec, ctx.layer, m.aesthetic), - )); + // One legend per scale: skip when an earlier channel/layer already + // registered it (hephaestus collapses compatible stack legends but + // not colorbars, so duplicates must not be pushed). + if is_new { + w.legends.push(material_legend( + &scale_name, + m.channel, + m.kind, + type_kind, + aesthetic_label(ctx.spec, ctx.layer, m.aesthetic), + legend_kind, + )); + } } else { match m.kind { RangeKind::Color => { @@ -278,17 +323,141 @@ fn wire_material( 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). +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, c); + true + } + None => false, + }, + (RangeKind::Shape, ParameterValue::String(s)) => { + builder.set(channel, s.clone()); + true + } + (RangeKind::Linetype, ParameterValue::String(s)) => { + builder.set(channel, HValue::Linetype(map_linetype(s))); + true + } + (RangeKind::Number, ParameterValue::Number(n)) if n.is_finite() => { + builder.set(channel, *n); + true + } + _ => false, + } +} + /// Reuse (or create) the shared scale for a material channel's `(source, kind)`, -/// returning the scale name to bind/legend against. -fn shared_scale_name(w: &mut Wiring, ctx: &Ctx, m: &MaterialSpec, extent: (f64, f64)) -> String { +/// returning `(scale_name, is_new)` — `is_new` is `true` only when this call +/// registered the scale, so the caller adds its legend exactly once. +fn shared_scale_name( + w: &mut Wiring, + ctx: &Ctx, + m: &MaterialSpec, + extent: (f64, f64), +) -> (String, bool) { let key = (aesthetic_source(ctx.layer, m.aesthetic), m.kind); if let Some(existing) = w.shared_scales.get(&key) { - return existing.clone(); + return (existing.clone(), false); } let scale = build_scale(ctx.spec.find_scale(m.aesthetic), extent, m.kind); w.registered.push((m.aesthetic.to_string(), scale)); w.shared_scales.insert(key, m.aesthetic.to_string()); - m.aesthetic.to_string() + (m.aesthetic.to_string(), true) +} + +/// 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 color from an aesthetic, or `default` when unmapped. Reads an +/// annotation column first, then a bare `Literal` string (e.g. `stroke => +/// 'black'`). Composite geoms use this for uniform styling like a box outline. +pub fn constant_color(ctx: &Ctx, aesthetic: &str, default: Color) -> Color { + if let Some(c) = aesthetic_column_name(ctx.layer, aesthetic) + .and_then(|c| column_to_colors(ctx.df, c).ok()) + .and_then(|v| v.first().copied()) + { + return c; + } + if let Some(AestheticValue::Literal(ParameterValue::String(s))) = + ctx.layer.mappings.aesthetics.get(aesthetic) + { + if let Some(c) = super::scales::parse_color(s) { + return c; + } + } + default +} + +/// 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 constant string from an aesthetic, or `default` when unmapped. Reads an +/// annotation column first, then a bare `Literal` string (e.g. `shape => +/// 'circle'`). Used for constant shape names on composite geom components. +pub fn constant_string(ctx: &Ctx, aesthetic: &str, default: &str) -> String { + if let Some(s) = aesthetic_column_name(ctx.layer, aesthetic) + .and_then(|c| column_to_strings(ctx.df, c).ok()) + .and_then(|v| v.first().cloned()) + { + return s; + } + if let Some(AestheticValue::Literal(ParameterValue::String(s))) = + ctx.layer.mappings.aesthetics.get(aesthetic) + { + return s.clone(); + } + default.to_string() +} + +/// A dodge offset column (per-row band fractions), or zeros when not dodged. +pub fn dodge_offsets(df: &DataFrame, aesthetic: &str) -> Vec { + let name = crate::naming::aesthetic_column(aesthetic); + if df.column(&name).is_ok() { + column_to_f64(df, &name).unwrap_or_else(|_| vec![0.0; df.height()]) + } else { + vec![0.0; df.height()] + } } /// Union of two data extents. @@ -331,14 +500,103 @@ fn aesthetic_source(layer: &Layer, aesthetic: &str) -> String { } } +/// A resolved color aesthetic for a composite geom, mirroring the Vega-Lite +/// writer's shared-encoding model: either a data-mapped color column (scaled +/// through a registered scale that is bound + legended once, carrying that +/// scale's name) or a constant. Components select the rows they cover and apply +/// it to a channel. +pub enum ColorSource { + Data { data: ChannelData, scale: String }, + Constant(Color), +} + +impl ColorSource { + /// 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 { + ColorSource::Data { data, .. } => data.select(idx).apply(builder, channel), + ColorSource::Constant(c) => { + builder.set(channel, *c); + } + } + } + + /// 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 { + ColorSource::Data { scale, .. } => Some(scale), + ColorSource::Constant(_) => None, + } + } +} + +/// Resolve a color aesthetic (`fill`, `stroke`, …) for a composite geom. A +/// data-mapped non-identity scale reuses (or registers) the shared color scale +/// for its `(source, kind)`, binds `channel` to it, and adds one legend on first +/// registration; the full color-domain column is returned for components to +/// select. Otherwise the constant value (the mapped literal, else `default`). +pub fn resolve_color( + ctx: &Ctx, + w: &mut Wiring, + aesthetic: &str, + channel: &'static str, + default: Color, + legend_kind: LegendKind, +) -> Result { + let scale = ctx.spec.find_scale(aesthetic); + let kind = scale + .and_then(|s| s.scale_type.as_ref()) + .map(|st| st.scale_type_kind()); + let col = aesthetic_column_name(ctx.layer, aesthetic); + let data_mapped = col.is_some() && scale.is_some() && kind != Some(ScaleTypeKind::Identity); + if !data_mapped { + return Ok(ColorSource::Constant(constant_color( + ctx, aesthetic, default, + ))); + } + // Reuse the shared color scale for this source (collapsing fill/stroke and + // cross-layer duplicates), registering + legending it only on first sight. + let key = (aesthetic_source(ctx.layer, aesthetic), RangeKind::Color); + let scale_name = match w.shared_scales.get(&key) { + Some(existing) => existing.clone(), + None => { + let hs = build_scale(scale, (0.0, 1.0), RangeKind::Color); + w.registered.push((aesthetic.to_string(), hs)); + w.shared_scales.insert(key, aesthetic.to_string()); + w.legends.push(material_legend( + aesthetic, + channel, + RangeKind::Color, + kind, + aesthetic_label(ctx.spec, ctx.layer, aesthetic), + legend_kind, + )); + aesthetic.to_string() + } + }; + w.bindings.push((channel, scale_name.clone())); + Ok(ColorSource::Data { + data: column_to_channel(ctx.df, col.unwrap())?, + scale: scale_name, + }) +} + /// Build a legend for a data-mapped material scale. Continuous color uses a -/// colorbar; everything else a keyed point legend at the scale's breaks. -fn material_legend( +/// colorbar; everything else a keyed legend (swatch per `legend_kind`) at the +/// scale's breaks. +pub fn material_legend( scale_name: &str, channel: &str, kind: RangeKind, type_kind: Option, title: Option, + legend_kind: LegendKind, ) -> Legend { let continuous_color = kind == RangeKind::Color && matches!( @@ -348,9 +606,14 @@ fn material_legend( 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(), + }; Legend::new(scale_name) .side(LegendSide::Right) - .key(LegendKeySpec::point().scaled(channel, scale_name)) + .key(key.scaled(channel, scale_name)) }; if let Some(title) = title { legend = legend.title(title); From 65d5c757690179f787dd8417b49ef9bd387b2703 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Wed, 22 Jul 2026 22:47:25 +0200 Subject: [PATCH 05/29] Refactor wiring and setup polar --- Cargo.lock | 2 +- ggsql-cli/Cargo.toml | 1 + ggsql-cli/src/main.rs | 137 ++++++++++++----- src/Cargo.toml | 2 +- src/execute/scale.rs | 15 +- src/plot/scale/mod.rs | 14 ++ src/writer/hephaestus/PLAN.md | 83 +++++++--- src/writer/hephaestus/channels.rs | 26 ---- src/writer/hephaestus/geom/boxplot.rs | 35 +---- src/writer/hephaestus/geom/mod.rs | 24 +-- src/writer/hephaestus/geom/segment.rs | 16 +- src/writer/hephaestus/geom/text.rs | 16 +- src/writer/hephaestus/geom/violin.rs | 34 +--- src/writer/hephaestus/mod.rs | 105 +++++++++---- src/writer/hephaestus/projection.rs | 114 ++++++++++++++ src/writer/hephaestus/scales.rs | 46 +++--- src/writer/hephaestus/wiring.rs | 213 +++++++------------------- src/writer/mod.rs | 2 +- 18 files changed, 498 insertions(+), 387 deletions(-) create mode 100644 src/writer/hephaestus/projection.rs diff --git a/Cargo.lock b/Cargo.lock index f1266f4ca..228715002 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2498,7 +2498,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hephaestus" version = "0.0.1" -source = "git+https://github.com/posit-dev/hephaestus.git?rev=79b240debdb09cfeffa77d12cdd52c1c0000cf96#79b240debdb09cfeffa77d12cdd52c1c0000cf96" +source = "git+https://github.com/posit-dev/hephaestus.git?rev=5997cdd03cae3036a870d587d1f47caa2b8b195c#5997cdd03cae3036a870d587d1f47caa2b8b195c" dependencies = [ "bytemuck", "clipper2-rust", diff --git a/ggsql-cli/Cargo.toml b/ggsql-cli/Cargo.toml index a0049b906..1964d3b47 100644 --- a/ggsql-cli/Cargo.toml +++ b/ggsql-cli/Cargo.toml @@ -43,6 +43,7 @@ parquet = ["ggsql/parquet"] sqlite = ["ggsql/sqlite"] odbc = ["ggsql/odbc"] vegalite = ["ggsql/vegalite"] +hephaestus = ["ggsql/hephaestus"] builtin-data = ["ggsql/builtin-data"] all-readers = ["duckdb", "sqlite", "odbc"] diff --git a/ggsql-cli/src/main.rs b/ggsql-cli/src/main.rs index d1b0cb0de..a63ad59b8 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; 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 = "hephaestus")] +use ggsql::writer::{rgba, HephaestusWriter}; mod docs { include!(concat!(env!("OUT_DIR"), "/docs_data.rs")); @@ -27,6 +31,11 @@ pub struct Cli { pub command: Commands, } +enum Output { + Text(String), + Bin(Vec), +} + #[derive(Subcommand)] pub enum Commands { /// Execute a ggsql query @@ -323,47 +332,52 @@ 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 { + "vegalite" => render_vegalite(&spec), + "hephaestus" => render_hephaestus(&spec), + _ => { + eprintln!("\nNote: Writer '{}' not yet implemented", writer); + 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 +724,50 @@ fn cmd_skill(format: Option) { } } } + +fn render_vegalite(spec: &Spec) -> Output { + #[cfg(not(feature = "vegalite"))] + { + eprintln!("VegaLite writer not compiled in. Rebuild with --features vegalite"); + std::process::exit(1) + } + + let json_output; + #[cfg(feature = "vegalite")] + { + // Render + let vl_writer = VegaLiteWriter::new(); + json_output = match vl_writer.render(spec) { + Ok(r) => r, + Err(e) => { + eprintln!("Failed to generate Vega-Lite output: {}", e); + std::process::exit(1); + } + }; + }; + Output::Text(json_output) +} + +fn render_hephaestus(spec: &Spec) -> Output { + #[cfg(not(feature = "hephaestus"))] + { + eprintln!("Hephaestus writer not compiled in. Rebuild with --features hephaestus"); + std::process::exit(1) + } + + let png_output; + #[cfg(feature = "hephaestus")] + { + // Render + let hs_writer = + HephaestusWriter::new(1500, 1000, 300.0).background(rgba(0.0, 0.0, 0.0, 0.0)); + png_output = match hs_writer.render(spec) { + Ok(r) => r, + Err(e) => { + eprintln!("Failed to generate Hephaestus output: {}", e); + std::process::exit(1); + } + }; + }; + Output::Bin(png_output) +} diff --git a/src/Cargo.toml b/src/Cargo.toml index 078155745..55d64c281 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -40,7 +40,7 @@ adbc_core = { version = "0.23", optional = true } geozero = { workspace = true, optional = true, features = ["with-wkb", "with-wkt", "with-geojson"] } # Hephaestus raster writer (non-default; gated, excluded from the MSRV 1.86 build) -hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "79b240debdb09cfeffa77d12cdd52c1c0000cf96", optional = true, default-features = false, features = ["vello", "png", "text"] } +hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "5997cdd03cae3036a870d587d1f47caa2b8b195c", optional = true, default-features = false, features = ["vello", "png", "text"] } png = { version = "0.18", optional = true } # Serialization 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/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/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index a906875bf..11e8d9d7c 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -436,35 +436,72 @@ break labels work today; date-native ticks are a larger follow-up, not niche). ## Multi-layer — status: implemented -The writer now renders **N layers** into one shared Cartesian panel (`validate` -allows ≥1 layer; FACET and non-Cartesian projections still rejected). `write` -loops over `spec.layers`, building each layer's geom into one `HPlot` via one -shared `Wiring`; geoms draw in DRAW order (= z-order). +The writer renders **N layers** into one shared panel (`validate` allows ≥1 +layer; FACET still rejected, non-Cartesian projections now supported — see +below). `write` loops over `spec.layers`, building each layer's geom into one +`HPlot`; geoms draw in DRAW order (= z-order). The enabling change is a principle correction: **the writer never computes its own scale extents — it uses the domain ggsql reports**, exactly as the VL writer uses `input_range`. ggsql resolves every `Scale` globally over all layers × the -whole position family (`execute/scale.rs::find_columns_for_aesthetic` / -`internal_position_family`), so `numeric_domain()` already spans every layer and +whole position family, so `numeric_domain()` already spans every layer and includes pos2end/min/max, fences, tile extents, etc. -- `scales.rs::continuous_domain` always returns `numeric_domain()` (the prior - data-extent / transformed-scale workaround is **removed**); `feed_breaks` now - feeds ggsql's breaks for transformed scales too. Net: log/sqrt and continuous - material scales render identically to the VL writer. -- `register_axis` is **idempotent** — each position scale + its single axis is - registered once across all layers (hephaestus doesn't dedup `add_axis`). -- Cross-layer legend dedup: `shared_scale_name` reports new-vs-reused so - `wire_material` adds a legend only when it creates the scale; composite - `resolve_color` consults `shared_scales` likewise (and `ColorSource` carries the - scale name so a ribbon's far edge binds to it). Material scales/bindings already - collapse via the persistent `shared_scales`. - -Verified (eyeballed): point+line (shared axes), bar+point overlay (point over -bar, z-order), scatter+abline (line spans the shared resolved domain), two layers -colored by one variable (single collapsed legend, one x/one y axis). Tests -`renders_multilayer_{point_line,overlay,abline,shared_legend}`; 27 writer tests; -default + 1.86 builds, fmt, clippy clean. +The wiring was then refactored to make ggsql the single source of scale truth and +the geoms per-geom-stateless (the old `Wiring` accumulator is **gone**): +- **Scales** are registered once, up front, from `spec.scales` into the + `PlotComposition` (`write` → `build_scale(scale, kind)`; `build_scale` returns + `Option`, registering nothing when a scale has no resolved type). No per-geom + scale registration or extent computation. +- **Geoms write directly to `plot`**: `wire_positions`/`wire_material`/ + `resolve_color` and the composites call `plot.set_binding` (idempotent) and + `plot.add_legend` (hephaestus collapses compatible legends). +- **Axes** are created per coordinate system in `projection::apply_projection` + (Cartesian → bottom/left rails; polar → angular + radial rings), so the axis + kind can depend on the coord. + +Supporting ggsql fix: a scale that no layer trains (e.g. a diagonal rule keeps +its position out of training) but that has an explicit `FROM` range is now typed +by `infer_scale_type_from_input_range` (numeric/temporal → continuous, string/ +bool → discrete) in `execute/scale.rs`. Previously it stayed untyped, so the +writer registered no scale and the abline couldn't bind; this benefits the VL +writer too. + +Projections (Phase 5): `projection.rs` dispatches on `CoordKind` — Cartesian +(clip/aspect-ratio + rails), Polar (`HProj::Polar` with start/end/inner + angular/ +radial rings), Map (stub). Polar renders **truthfully**: pos1→radius, pos2→theta +(matching the VL writer), so a stacked bar becomes a correct pie/donut with the +right slice proportions, fills, and angular axis. `start`/`end` are degrees +clockwise from 12 o'clock (`end` defaults to `start + 360°`, so setting only +`start` rotates a full circle); `inner` opens a donut hole. A synthetic dummy +position scale (`__ggsql_stat_dummy` — a pie's radius, or a bar with no x) is +given no axis, in every projection, via the shared `has_real_axis` predicate, +mirroring the VL writer's `AxisInfo::suppress`. + +Two hephaestus fixes were required (in `~/GitHub/hephaestus`, pending push + rev +bump — a temporary path `[patch]` in the root `Cargo.toml` wires the local clone +meanwhile): +1. `angle_channel`/`radius_channel` are now honored by **geom geometry**, not + just chrome: `project_to_panel_px` / `interpolate_segment_with_t` route a + geom's positional `[x, y]` to theta/radius via `PolarProjection::theta_r_from_xy` + (default `x→theta`; ggsql sets `angle_channel="y"` so pos2 drives the angle). +2. RectGeom's zero-size cull now applies only on the linear path — a 180° polar + wedge whose two diagonal corners share a pixel coordinate is no longer dropped. + +Verified (eyeballed): point+line (shared axes), bar+point overlay (z-order), +scatter+abline, two layers colored by one variable, polar pie (180° slice), donut, +rotated + reflex + 4-slice pies. 29 writer tests; fmt, clippy clean. + +Minor cosmetic (deferred): a plain pie shows a tiny centre hole because the dummy +bar occupies a `width` band (radius ~0.05–0.95) rather than the full 0–1 radius; +proportions/angles are unaffected. + +Known issue (deferred): `color AS ` maps **both** fill and stroke to the +variable → two separate scales (`fill`, `stroke`) whose legends no longer merge +(different `domain_scale`), so a filled-dot legend and a hollow-dot legend both +show. The old `Wiring.shared_scales` collapsed these; recovering it needs either +a hephaestus legend-merge across scales with equal domains, or reintroducing a +small shared-source map. **Confirmed shared with the VL writer (not a regression here):** ggsql's range expansion runs in linear data space then clips to the transform's valid domain, diff --git a/src/writer/hephaestus/channels.rs b/src/writer/hephaestus/channels.rs index cfdc79237..6ebba3c0c 100644 --- a/src/writer/hephaestus/channels.rs +++ b/src/writer/hephaestus/channels.rs @@ -19,15 +19,6 @@ pub enum ChannelData { } impl ChannelData { - /// Finite (min, max) of numeric data; `(0, 1)` for categorical (which feeds - /// a discrete scale that ignores the extent). - pub fn extent(&self) -> (f64, f64) { - match self { - ChannelData::Floats(values) => extent(values), - ChannelData::Strings(_) => (0.0, 1.0), - } - } - /// Select a subset of rows by index, preserving the channel's value type. pub fn select(&self, idx: &[usize]) -> ChannelData { match self { @@ -154,20 +145,3 @@ pub fn column_to_colors(df: &DataFrame, name: &str) -> Result> { .map(|s| parse_color(s).unwrap_or(Color::BLACK)) .collect()) } - -/// Finite (min, max) of the data, or `(0, 1)` when there are no finite values. -fn extent(values: &[f64]) -> (f64, f64) { - let mut min = f64::INFINITY; - let mut max = f64::NEG_INFINITY; - for &v in values { - if v.is_finite() { - min = min.min(v); - max = max.max(v); - } - } - if min <= max { - (min, max) - } else { - (0.0, 1.0) - } -} diff --git a/src/writer/hephaestus/geom/boxplot.rs b/src/writer/hephaestus/geom/boxplot.rs index b18703631..3a81b8d7b 100644 --- a/src/writer/hephaestus/geom/boxplot.rs +++ b/src/writer/hephaestus/geom/boxplot.rs @@ -11,12 +11,12 @@ use super::super::channels::{ aesthetic_column_name, column_to_channel, column_to_f64, column_to_strings, }; use super::super::wiring::{ - band_half_width, constant_number, constant_string, dodge_offsets, register_axis, resolve_color, - Ctx, LegendKind, PanelAxis, Wiring, + band_half_width, constant_number, constant_string, dodge_offsets, resolve_color, Ctx, + LegendKind, }; use crate::{GgsqlError, Result}; -pub fn build(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { +pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { let (layer, df) = (ctx.layer, ctx.df); let n = df.height(); @@ -38,11 +38,9 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { .collect(); let out_i = rows_of("outlier"); - // Shared scales: pos1 (category), pos2 (full value range incl. fences). - register_axis(ctx, w, PanelAxis::X, p1.extent()); - register_axis(ctx, w, PanelAxis::Y, finite_extent(&[&p2, &p2e])); + // Bind the shared position channels (scales are registered globally). for (channel, scale) in [("x", "pos1"), ("x2", "pos1"), ("y", "pos2"), ("y2", "pos2")] { - w.bindings.push((channel, scale.to_string())); + plot.set_binding(channel, scale); } // Resolve fill + stroke once (data-mapped → shared scale/legend, else @@ -50,7 +48,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { // component draws with the same resolved fill/stroke. let fill = resolve_color( ctx, - w, + plot, "fill", "fill", rgb8(255, 255, 255), @@ -58,7 +56,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { )?; let stroke = resolve_color( ctx, - w, + plot, "stroke", "stroke", rgb8(60, 60, 60), @@ -142,22 +140,3 @@ fn pick(v: &[f64], idx: &[usize]) -> Vec { fn shift(offsets: &[f64], idx: &[usize], delta: f64) -> Vec { idx.iter().map(|&i| offsets[i] + delta).collect() } - -/// Finite (min, max) across several columns, or `(0, 1)` if none are finite. -fn finite_extent(cols: &[&[f64]]) -> (f64, f64) { - let mut min = f64::INFINITY; - let mut max = f64::NEG_INFINITY; - for v in cols { - for &x in v.iter() { - if x.is_finite() { - min = min.min(x); - max = max.max(x); - } - } - } - if min <= max { - (min, max) - } else { - (0.0, 1.0) - } -} diff --git a/src/writer/hephaestus/geom/mod.rs b/src/writer/hephaestus/geom/mod.rs index c11369b50..3cfc8a6d4 100644 --- a/src/writer/hephaestus/geom/mod.rs +++ b/src/writer/hephaestus/geom/mod.rs @@ -16,31 +16,31 @@ use hephaestus::plot::{ LineGeom, Plot as HPlot, PointGeom, PolygonGeom, RectGeom, RibbonGeom, SegmentGeom, }; -use super::wiring::{build_and_add, Ctx, Wiring}; +use super::wiring::{build_and_add, Ctx}; use crate::plot::layer::geom::GeomType; use crate::{GgsqlError, Result}; /// Build the layer's geom into `plot`, recording its scales/axes/legends in `w`. -pub fn build_into_plot(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { +pub fn build_into_plot(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { match ctx.layer.geom.geom_type() { - GeomType::Point => build_and_add::(plot, point::spec(ctx), ctx, w), + GeomType::Point => build_and_add::(plot, point::spec(ctx), ctx), GeomType::Line | GeomType::Path | GeomType::Smooth => { - build_and_add::(plot, line::spec(ctx), ctx, w) + build_and_add::(plot, line::spec(ctx), ctx) } GeomType::Bar | GeomType::Histogram | GeomType::Tile => { - build_and_add::(plot, rect::spec(ctx), ctx, w) + build_and_add::(plot, rect::spec(ctx), ctx) } GeomType::Area | GeomType::Ribbon | GeomType::Density => { - build_and_add::(plot, area::spec(ctx), ctx, w) + build_and_add::(plot, area::spec(ctx), ctx) } - GeomType::Polygon => build_and_add::(plot, polygon::spec(ctx), ctx, w), - GeomType::Rule if segment::is_diagonal(ctx.layer) => segment::build_diagonal(plot, ctx, w), + GeomType::Polygon => build_and_add::(plot, polygon::spec(ctx), ctx), + GeomType::Rule if segment::is_diagonal(ctx.layer) => segment::build_diagonal(plot, ctx), GeomType::Segment | GeomType::Range | GeomType::Rule => { - build_and_add::(plot, segment::spec(ctx), ctx, w) + build_and_add::(plot, segment::spec(ctx), ctx) } - GeomType::Text => text::build(plot, ctx, w), - GeomType::Boxplot => boxplot::build(plot, ctx, w), - GeomType::Violin => violin::build(plot, ctx, w), + GeomType::Text => text::build(plot, ctx), + GeomType::Boxplot => boxplot::build(plot, ctx), + GeomType::Violin => violin::build(plot, ctx), other => Err(GgsqlError::WriterError(format!( "hephaestus writer does not support the '{other}' geom yet" ))), diff --git a/src/writer/hephaestus/geom/segment.rs b/src/writer/hephaestus/geom/segment.rs index a7888d202..65ac3c262 100644 --- a/src/writer/hephaestus/geom/segment.rs +++ b/src/writer/hephaestus/geom/segment.rs @@ -12,8 +12,8 @@ use hephaestus::plot::{Plot as HPlot, SegmentGeom}; use super::super::channels::aesthetic_column_name; use super::super::scales::RangeKind; use super::super::wiring::{ - constant_color, constant_number, register_axis, Ctx, GeomSpec, LegendKind, MatDefault, - MaterialSpec, PanelAxis, PositionSpec, Wiring, + constant_color, constant_number, Ctx, GeomSpec, LegendKind, MatDefault, MaterialSpec, + PanelAxis, PositionSpec, }; use crate::plot::layer::geom::GeomType; use crate::plot::ParameterValue; @@ -102,27 +102,25 @@ pub fn is_diagonal(layer: &Layer) -> bool { /// resolved range, with `secondary = slope * primary + intercept`. The 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. -pub fn build_diagonal(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { +pub fn build_diagonal(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { let slope = slope_value(ctx); - let (x0, y0, x1, y1, x_extent, y_extent) = if !ctx.transposed { + let (x0, y0, x1, y1) = if !ctx.transposed { // y-intercept (`pos2`); x is the spanning axis. let intercept = constant_number(ctx, "pos2", 0.0); let (x0, x1) = primary_range(ctx, "pos1"); let (y0, y1) = (slope * x0 + intercept, slope * x1 + intercept); - (x0, y0, x1, y1, (x0, x1), (y0.min(y1), y0.max(y1))) + (x0, y0, x1, y1) } else { // x-intercept (`pos1`); y is the spanning axis. let intercept = constant_number(ctx, "pos1", 0.0); let (y0, y1) = primary_range(ctx, "pos2"); let (x0, x1) = (slope * y0 + intercept, slope * y1 + intercept); - (x0, y0, x1, y1, (x0.min(x1), x0.max(x1)), (y0, y1)) + (x0, y0, x1, y1) }; - register_axis(ctx, w, PanelAxis::X, x_extent); - register_axis(ctx, w, PanelAxis::Y, y_extent); for (channel, scale) in [("x", "pos1"), ("x2", "pos1"), ("y", "pos2"), ("y2", "pos2")] { - w.bindings.push((channel, scale.to_string())); + plot.set_binding(channel, scale); } let mut b = SegmentGeom::builder(); diff --git a/src/writer/hephaestus/geom/text.rs b/src/writer/hephaestus/geom/text.rs index 20ae3b4c9..b50afe5f0 100644 --- a/src/writer/hephaestus/geom/text.rs +++ b/src/writer/hephaestus/geom/text.rs @@ -12,10 +12,10 @@ use hephaestus::plot::{Plot as HPlot, TextGeom}; use super::super::channels::{ aesthetic_column_name, column_to_bool, column_to_channel, column_to_f64, column_to_strings, }; -use super::super::wiring::{register_axis, resolve_color, Ctx, LegendKind, PanelAxis, Wiring}; +use super::super::wiring::{resolve_color, Ctx, LegendKind}; use crate::{GgsqlError, Result}; -pub fn build(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { +pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { let (layer, df) = (ctx.layer, ctx.df); let n = df.height(); @@ -25,15 +25,11 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { let mut b = TextGeom::builder(); - // Positions + shared scales/axes. + // Positions: bind to the global pos1/pos2 scales. let p1 = column_to_channel(df, pos1)?; - let p1x = p1.extent(); let p2 = column_to_channel(df, pos2)?; - let p2y = p2.extent(); - register_axis(ctx, w, PanelAxis::X, p1x); - register_axis(ctx, w, PanelAxis::Y, p2y); - w.bindings.push(("x", "pos1".to_string())); - w.bindings.push(("y", "pos2".to_string())); + plot.set_binding("x", "pos1"); + plot.set_binding("y", "pos2"); p1.apply(&mut b, "x"); p2.apply(&mut b, "y"); @@ -41,7 +37,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { b.set("text", Raw(column_to_strings(df, label)?)); // Color: data-mapped (color-by-group) or constant black. - resolve_color(ctx, w, "fill", "fill", rgb8(0, 0, 0), LegendKind::Point)?.apply( + resolve_color(ctx, plot, "fill", "fill", rgb8(0, 0, 0), LegendKind::Point)?.apply( &mut b, "fill", &(0..n).collect::>(), diff --git a/src/writer/hephaestus/geom/violin.rs b/src/writer/hephaestus/geom/violin.rs index daf170f4e..534638eae 100644 --- a/src/writer/hephaestus/geom/violin.rs +++ b/src/writer/hephaestus/geom/violin.rs @@ -14,13 +14,10 @@ use hephaestus::plot::{Plot as HPlot, RibbonGeom}; use super::super::channels::{ aesthetic_column_name, column_to_channel, column_to_f64, column_to_strings, }; -use super::super::wiring::{ - constant_number, dodge_offsets, register_axis, resolve_color, Ctx, LegendKind, PanelAxis, - Wiring, -}; +use super::super::wiring::{constant_number, dodge_offsets, resolve_color, Ctx, LegendKind}; use crate::{GgsqlError, Result}; -pub fn build(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { +pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { let (layer, df) = (ctx.layer, ctx.df); let pos1 = require(layer, "pos1")?; @@ -49,10 +46,8 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { order.extend_from_slice(rows); } - register_axis(ctx, w, PanelAxis::X, p1.extent()); - register_axis(ctx, w, PanelAxis::Y, finite_extent(&p2)); for (channel, scale) in [("x", "pos1"), ("x2", "pos1"), ("y", "pos2")] { - w.bindings.push((channel, scale.to_string())); + plot.set_binding(channel, scale); } // One vertical ribbon per category: right edge +offset, left edge -offset, @@ -67,7 +62,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { // constant), mirroring the VL writer's shared-encoding model. let fill = resolve_color( ctx, - w, + plot, "fill", "fill", rgb8(255, 255, 255), @@ -75,7 +70,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { )?; let stroke = resolve_color( ctx, - w, + plot, "stroke", "stroke", rgb8(60, 60, 60), @@ -83,7 +78,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx, w: &mut Wiring) -> Result<()> { )?; // The ribbon's two edges share the stroke scale (`stroke2` is the far edge). if let Some(name) = stroke.scale_name() { - w.bindings.push(("stroke2", name.to_string())); + plot.set_binding("stroke2", name); } let mut b = RibbonGeom::builder(); @@ -106,20 +101,3 @@ fn require<'a>(layer: &'a crate::Layer, aesthetic: &str) -> Result<&'a str> { aesthetic_column_name(layer, aesthetic) .ok_or_else(|| GgsqlError::WriterError(format!("violin layer has no {aesthetic} mapping"))) } - -/// Finite (min, max), or `(0, 1)` if no finite values. -fn finite_extent(v: &[f64]) -> (f64, f64) { - let mut min = f64::INFINITY; - let mut max = f64::NEG_INFINITY; - for &x in v { - if x.is_finite() { - min = min.min(x); - max = max.max(x); - } - } - if min <= max { - (min, max) - } else { - (0.0, 1.0) - } -} diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index 4a94b58e1..82120be51 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -15,13 +15,14 @@ mod channels; mod geom; +mod projection; mod scales; mod wiring; use std::collections::HashMap; use hephaestus::backend::vello::VelloRenderer; -use hephaestus::color::{rgb8, Color}; +pub use hephaestus::color::{rgba, Color}; use hephaestus::composition::{Composition, Patch, Span}; use hephaestus::geometry::Size; use hephaestus::plot::{Plot as HPlot, PlotComposition}; @@ -29,11 +30,12 @@ use hephaestus::shape::ShapeRegistry; use hephaestus::Renderer; use crate::plot::layer::is_transposed; -use crate::plot::projection::coord::CoordKind; +use crate::writer::hephaestus::projection::apply_projection; +use crate::writer::hephaestus::scales::build_scale; use crate::writer::Writer; use crate::{DataFrame, GgsqlError, Layer, Plot, Result}; -use wiring::{Ctx, Wiring}; +use wiring::Ctx; /// Internal patch id for the single panel. const PANEL_ID: &str = "ggsql_panel"; @@ -56,7 +58,7 @@ impl HephaestusWriter { width, height, dpi, - background: rgb8(255, 255, 255), + background: rgba(1.0, 1.0, 1.0, 1.0), } } @@ -76,13 +78,6 @@ impl Writer for HephaestusWriter { "hephaestus writer does not support FACET yet".into(), )); } - if let Some(projection) = &spec.project { - if projection.coord.coord_kind() != CoordKind::Cartesian { - return Err(GgsqlError::WriterError( - "hephaestus writer supports only Cartesian coordinates".into(), - )); - } - } if spec.layers.is_empty() { return Err(GgsqlError::WriterError( "hephaestus writer requires at least one layer".into(), @@ -101,12 +96,32 @@ impl Writer for HephaestusWriter { fn write(&self, spec: &Plot, data: &HashMap) -> Result { self.validate(spec)?; + let composition = single_panel(); + let mut view = PlotComposition::new(&composition); + for scale in &spec.scales { + let kind = match scale.aesthetic.as_str() { + "fill" | "stroke" | "color" | "colour" => scales::RangeKind::Color, + "shape" => scales::RangeKind::Shape, + "linetype" => scales::RangeKind::Linetype, + _ => { + if scale.aesthetic.starts_with("pos") { + scales::RangeKind::Position + } else { + scales::RangeKind::Number + } + } + }; + if let Some(hs) = build_scale(Some(scale), kind) { + view.insert_scale(scale.aesthetic.clone(), hs); + } + } - // Build every layer's geom into one panel, accumulating shared - // scales/axes/legends. Geoms draw in layer (DRAW) order = z-order. + // Build every layer's geom into one panel; each geom binds its channels + // and adds its legends directly onto `plot`. Geoms draw in layer (DRAW) + // order = z-order. let mut plot = - HPlot::new(&single_panel(), PANEL_ID).shape_registry(ShapeRegistry::with_builtins()); - let mut w = Wiring::default(); + HPlot::new(&composition, PANEL_ID).shape_registry(ShapeRegistry::with_builtins()); + for layer in &spec.layers { let df = layer_dataframe(layer, data)?; let ctx = Ctx { @@ -115,23 +130,12 @@ impl Writer for HephaestusWriter { df, transposed: is_transposed(layer), }; - geom::build_into_plot(&mut plot, &ctx, &mut w)?; + geom::build_into_plot(&mut plot, &ctx)?; } - for (channel, scale_name) in &w.bindings { - plot.set_binding(*channel, scale_name.clone()); - } - for axis in w.axes { - plot.add_axis(axis); - } - for legend in w.legends { - plot.add_legend(legend); - } + // Axes are created per coordinate system (Cartesian rails, polar rings). + plot = apply_projection(plot, spec); - let mut view = PlotComposition::new(single_panel()); - for (name, scale) in w.registered { - view.insert_scale(name, scale); - } view.attach_plot(plot); let issues = view.validate(); @@ -156,6 +160,29 @@ fn single_panel() -> Composition { Composition::empty(1, 1).place(1, 1, Span::cell(), Patch::new(PANEL_ID)) } +// Scaffolding for faceted (grid) compositions — not wired up yet. +#[allow(dead_code)] +fn grid_id(row: usize, col: usize) -> String { + format!("{}x{}", row, col) +} + +/// A `rows`×`cols` grid composition, one patch per cell (faceting; not yet used). +#[allow(dead_code)] +fn panel_grid(rows: usize, cols: usize) -> Composition { + let mut comp = Composition::empty(rows, cols); + for row in 1..(rows + 1) { + for col in 1..(cols + 1) { + comp = comp.place( + row.try_into().unwrap(), + col.try_into().unwrap(), + Span::cell(), + Patch::new(grid_id(row, col)), + ); + } + } + comp +} + /// Look up the DataFrame backing a layer by its execution-assigned data key. fn layer_dataframe<'a>( layer: &Layer, @@ -467,6 +494,26 @@ mod tests { )); } + #[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 rejects_unsupported_geom() { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); diff --git a/src/writer/hephaestus/projection.rs b/src/writer/hephaestus/projection.rs new file mode 100644 index 000000000..0c1bcf1af --- /dev/null +++ b/src/writer/hephaestus/projection.rs @@ -0,0 +1,114 @@ +//! 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::{PolarProjection, Projection as HProj}; +use hephaestus::plot::AspectMode; +use hephaestus::plot::Plot as HPlot; +use hephaestus::scales::chrome::AxisSide; + +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. No `PROJECT` clause is treated as +/// Cartesian. +pub fn apply_projection(plot: HPlot, spec: &Plot) -> 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) + } + Some(CoordKind::Polar) => apply_proj_polar(plot, spec.project.as_ref().unwrap(), spec), + Some(CoordKind::Map) => apply_proj_map(plot, spec.project.as_ref().unwrap()), + } +} + +fn apply_proj_cartesian(mut plot: HPlot, proj: Option<&Projection>, spec: &Plot) -> HPlot { + if let Some(proj) = proj { + if let Some(ParameterValue::Boolean(false)) = proj.properties.get("clip") { + plot = plot.clip(false); + } + if let Some(ParameterValue::Number(ratio)) = proj.properties.get("ratio") { + plot = plot.aspect_ratio(*ratio).aspect_mode(AspectMode::Range); + } + } + add_cartesian_axis(&mut plot, spec, "pos1", AxisSide::Bottom); + add_cartesian_axis(&mut plot, spec, "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 for a position scale, titled from the plot's labels +/// (or the first layer's mapped column). Skipped for absent or dummy scales. +fn add_cartesian_axis(plot: &mut HPlot, spec: &Plot, name: &str, side: AxisSide) { + if !has_real_axis(spec, name) { + return; + } + let mut rail = Axis::rail(name, AxisPlacement::Cartesian(side)); + if let Some(layer) = spec.layers.first() { + if let Some(title) = aesthetic_label(spec, layer, name) { + rail = rail.title(title); + } + } + plot.add_axis(rail); +} + +fn apply_proj_polar(mut plot: HPlot, proj: &Projection, spec: &Plot) -> 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`). + let base = 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(PolarProjection { + angle_channel: "y".into(), + radius_channel: "x".into(), + theta_start: start, + theta_end: end, + inner_radius_frac: inner, + ..base + })); + // 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( + "pos2", + AxisPlacement::PolarAngular(PolarRing::Outer), + )); + } + if has_real_axis(spec, "pos1") { + plot.add_axis(Axis::rail( + "pos1", + AxisPlacement::PolarRadius { theta_frac: start }, + )); + } + plot +} + +fn apply_proj_map(plot: HPlot, _proj: &Projection) -> HPlot { + plot +} diff --git a/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs index 6e6bbb6fb..67557c8a8 100644 --- a/src/writer/hephaestus/scales.rs +++ b/src/writer/hephaestus/scales.rs @@ -36,22 +36,33 @@ pub enum RangeKind { /// /// `data_extent` is the finite (min, max) of the channel's data, used as the /// domain fallback when the ggsql scale carries none (continuous scales only). -pub fn build_scale(scale: Option<&GScale>, data_extent: (f64, f64), kind: RangeKind) -> HScale { - let usable = scale.filter(|s| !s.is_dummy()); - let type_kind = usable +pub fn build_scale(scale: Option<&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 .and_then(|s| s.scale_type.as_ref()) - .map(|st| st.scale_type_kind()); - let transform = usable + .map(|st| st.scale_type_kind())?; + let transform = scale .and_then(|s| s.transform.as_ref()) .map(|t| t.transform_kind()); let mut hs = match type_kind { - Some(ScaleTypeKind::Discrete) => scale::discrete(domain_values(usable)), - Some(ScaleTypeKind::Ordinal) => scale::ordinal(domain_values(usable)), - // Continuous, Binned, Identity, or unknown: a continuous mapper. - _ => { + ScaleTypeKind::Discrete => scale::discrete(domain_values(scale)), + ScaleTypeKind::Ordinal => scale::ordinal(domain_values(scale)), + ScaleTypeKind::Identity => scale::identity(), + ScaleTypeKind::Binned => { let h_transform = transform.and_then(map_transform); - let (min, max) = continuous_domain(usable, data_extent); + let (min, max) = continuous_domain(scale); + let breaks = scale.map(|x| x.numeric_breaks()).unwrap_or(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 h_transform = transform.and_then(map_transform); + let (min, max) = continuous_domain(scale); let mut c = scale::continuous(min..=max); if let Some(t) = h_transform { c = c.with_transform(t); @@ -61,7 +72,7 @@ pub fn build_scale(scale: Option<&GScale>, data_extent: (f64, f64), kind: RangeK }; if kind != RangeKind::Position { - if let Some(OutputRange::Array(values)) = usable.and_then(|s| s.output_range.as_ref()) { + if let Some(OutputRange::Array(values)) = scale.and_then(|s| s.output_range.as_ref()) { hs = apply_output_range(hs, kind, values); } } @@ -71,22 +82,21 @@ pub fn build_scale(scale: Option<&GScale>, data_extent: (f64, f64), kind: RangeK // Vega-Lite writer — exactly. ggsql's breaks pair with the same resolved // domain hephaestus now uses, so they line up. `apply_breaks` is a no-op when // the scale has no resolved breaks. - if let Some(s) = usable { - hs = apply_breaks(hs, s, type_kind); + if let Some(scale) = scale { + hs = apply_breaks(hs, scale, Some(type_kind)); } - hs + Some(hs) } /// 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`. `data_extent` is only a -/// fallback for a scale ggsql left unresolved. -fn continuous_domain(scale: Option<&GScale>, data_extent: (f64, f64)) -> (f64, f64) { +/// 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(data_extent); + .unwrap_or((0.0, 1.0)); pad_degenerate(domain.0, domain.1) } diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index aa785826a..c531e92d9 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -2,22 +2,20 @@ //! group keys. Each geom module declares its channel specs; these helpers do the //! repetitive work, generic over the concrete geom builder. -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use hephaestus::color::Color; -use hephaestus::plot::chrome::axis::{Axis, AxisPlacement}; use hephaestus::plot::chrome::legend::{Legend, LegendKeySpec}; use hephaestus::plot::geom::{BuildableGeom, Geom, GeomBuilder, Raw}; -use hephaestus::plot::scale::Scale as HScale; use hephaestus::plot::Plot as HPlot; -use hephaestus::scales::chrome::{AxisSide, LegendSide}; +use hephaestus::scales::chrome::LegendSide; use hephaestus::scales::value::Value as HValue; use super::channels::{ aesthetic_column_name, build_group_keys, column_to_channel, column_to_colors, column_to_f64, column_to_strings, ChannelData, }; -use super::scales::{build_scale, map_linetype, parse_color, RangeKind}; +use super::scales::{map_linetype, parse_color, RangeKind}; use crate::plot::{ParameterValue, ScaleTypeKind}; use crate::{AestheticValue, DataFrame, GgsqlError, Layer, Plot, Result}; @@ -30,19 +28,6 @@ pub struct Ctx<'a> { pub transposed: bool, } -/// Accumulates everything that attaches to the plot/composition while building -/// a geom: scales to register, channel→scale bindings, axes, and legends. -#[derive(Default)] -pub struct Wiring { - pub registered: Vec<(String, HScale)>, - pub bindings: Vec<(&'static str, String)>, - pub axes: Vec, - pub legends: Vec, - /// Channels sharing a `(data source, output kind)` reuse one scale so their - /// legends collapse (e.g. ggsql's `color` → fill + stroke). - shared_scales: HashMap<(String, RangeKind), String>, -} - /// Which panel axis a position channel drives. #[derive(Clone, Copy, PartialEq, Eq)] pub enum PanelAxis { @@ -51,18 +36,15 @@ pub enum PanelAxis { } impl PanelAxis { - fn scale_name(self) -> &'static str { + /// The ggsql scale name backing this axis (`pos1` for x, `pos2` for y). + /// Scales are registered globally from `spec.scales`; geoms bind their + /// position channels to these names. + pub fn scale_name(self) -> &'static str { match self { PanelAxis::X => "pos1", PanelAxis::Y => "pos2", } } - fn side(self) -> AxisSide { - match self { - PanelAxis::X => AxisSide::Bottom, - PanelAxis::Y => AxisSide::Left, - } - } } /// A position channel: hephaestus `channel` ← ggsql `aesthetic`, on `axis`. @@ -142,9 +124,11 @@ pub struct GeomSpec { pub grouped: bool, } -/// Build a concrete geom from its spec and attach it to the plot, recording its -/// scales/bindings/axes/legends in `w`. -pub fn build_and_add(plot: &mut HPlot, spec: GeomSpec, ctx: &Ctx, w: &mut Wiring) -> Result<()> +/// Build a concrete geom from its spec and attach it to the plot. Bindings and +/// legends are written directly onto `plot`; scales are registered globally from +/// `spec.scales` (see `HephaestusWriter::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, { @@ -154,7 +138,7 @@ where builder.keys(keys); } } - wire_positions(&mut builder, &spec.positions, ctx, w)?; + wire_positions(&mut builder, &spec.positions, plot, ctx)?; 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)?)); @@ -166,22 +150,20 @@ where for (channel, values) in spec.data_channels { builder.set(channel, values); } - wire_material(&mut builder, &spec.material, ctx, w, spec.legend_key)?; + wire_material(&mut builder, &spec.material, plot, ctx, spec.legend_key)?; plot.add_geom(builder.build()); Ok(()) } -/// Set position channels and register the `pos1`/`pos2` scales + axes. Each -/// axis scale's domain is the union extent of the position columns on that axis. +/// 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`. fn wire_positions( builder: &mut GeomBuilder, positions: &[PositionSpec], + plot: &mut HPlot, ctx: &Ctx, - w: &mut Wiring, ) -> Result<()> { - let mut x_extent: Option<(f64, f64)> = None; - let mut y_extent: Option<(f64, f64)> = None; - for p in positions { let col = aesthetic_column_name(ctx.layer, &p.aesthetic).ok_or_else(|| { GgsqlError::WriterError(format!( @@ -191,54 +173,20 @@ fn wire_positions( )) })?; let data = column_to_channel(ctx.df, col)?; - let extent = data.extent(); - match p.axis { - PanelAxis::X => merge_extent(&mut x_extent, extent), - PanelAxis::Y => merge_extent(&mut y_extent, extent), - } data.apply(builder, p.channel); - w.bindings - .push((p.channel, p.axis.scale_name().to_string())); - } - - if let Some(extent) = x_extent { - register_axis(ctx, w, PanelAxis::X, extent); - } - if let Some(extent) = y_extent { - register_axis(ctx, w, PanelAxis::Y, extent); + plot.set_binding(p.channel, p.axis.scale_name()); } Ok(()) } -/// Register one panel axis's scale and axis chrome. Composite geoms call this -/// directly to set up shared `pos1`/`pos2` scales before building components. -/// -/// Idempotent across layers: a position scale is registered (and its axis added) -/// exactly once, since ggsql's resolved domain is global — every layer that uses -/// the axis shares the same scale. `extent` is only a fallback for an unresolved -/// scale, so the first layer's value is authoritative. -pub fn register_axis(ctx: &Ctx, w: &mut Wiring, axis: PanelAxis, extent: (f64, f64)) { - let name = axis.scale_name(); - if w.registered.iter().any(|(n, _)| n == name) { - return; - } - let scale = build_scale(ctx.spec.find_scale(name), extent, RangeKind::Position); - w.registered.push((name.to_string(), scale)); - - let mut rail = Axis::rail(name, AxisPlacement::Cartesian(axis.side())); - if let Some(title) = aesthetic_label(ctx.spec, ctx.layer, name) { - rail = rail.title(title); - } - w.axes.push(rail); -} - -/// Set material channels: data-mapped → scale + binding + legend; identity/ -/// literal → `Raw` visual values; unmapped → the spec's default. +/// Set material channels: data-mapped → bind channel to its (globally +/// registered) scale + add a legend; literal → constant visual value; identity/ +/// annotation → `Raw` per-row values; unmapped → the spec's default. fn wire_material( builder: &mut GeomBuilder, material: &[MaterialSpec], + plot: &mut HPlot, ctx: &Ctx, - w: &mut Wiring, legend_kind: LegendKind, ) -> Result<()> { let mut handled: HashSet<&str> = HashSet::new(); @@ -271,23 +219,19 @@ fn wire_material( if data_mapped { let data = column_to_channel(ctx.df, col)?; - let extent = data.extent(); - let (scale_name, is_new) = shared_scale_name(w, ctx, m, extent); data.apply(builder, m.channel); - w.bindings.push((m.channel, scale_name.clone())); - // One legend per scale: skip when an earlier channel/layer already - // registered it (hephaestus collapses compatible stack legends but - // not colorbars, so duplicates must not be pushed). - if is_new { - w.legends.push(material_legend( - &scale_name, - m.channel, - m.kind, - type_kind, - aesthetic_label(ctx.spec, ctx.layer, m.aesthetic), - legend_kind, - )); - } + // Bind the channel to the aesthetic's scale (registered globally) and + // add a legend. hephaestus collapses compatible legends, so repeated + // adds across layers for the same scale merge. + plot.set_binding(m.channel, m.aesthetic); + plot.add_legend(material_legend( + m.aesthetic, + m.channel, + m.kind, + type_kind, + aesthetic_label(ctx.spec, ctx.layer, m.aesthetic), + legend_kind, + )); } else { match m.kind { RangeKind::Color => { @@ -359,25 +303,6 @@ fn set_literal_channel( } } -/// Reuse (or create) the shared scale for a material channel's `(source, kind)`, -/// returning `(scale_name, is_new)` — `is_new` is `true` only when this call -/// registered the scale, so the caller adds its legend exactly once. -fn shared_scale_name( - w: &mut Wiring, - ctx: &Ctx, - m: &MaterialSpec, - extent: (f64, f64), -) -> (String, bool) { - let key = (aesthetic_source(ctx.layer, m.aesthetic), m.kind); - if let Some(existing) = w.shared_scales.get(&key) { - return (existing.clone(), false); - } - let scale = build_scale(ctx.spec.find_scale(m.aesthetic), extent, m.kind); - w.registered.push((m.aesthetic.to_string(), scale)); - w.shared_scales.insert(key, m.aesthetic.to_string()); - (m.aesthetic.to_string(), true) -} - /// 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 { @@ -460,14 +385,6 @@ pub fn dodge_offsets(df: &DataFrame, aesthetic: &str) -> Vec { } } -/// Union of two data extents. -fn merge_extent(slot: &mut Option<(f64, f64)>, e: (f64, f64)) { - *slot = Some(match *slot { - Some((min, max)) => (min.min(e.0), max.max(e.1)), - None => e, - }); -} - /// 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 { @@ -485,21 +402,6 @@ pub fn aesthetic_label(spec: &Plot, layer: &Layer, aesthetic: &str) -> Option String { - match layer.mappings.get(aesthetic) { - Some(AestheticValue::Column { - original_name: Some(name), - .. - }) => name.clone(), - Some(AestheticValue::Column { name, .. }) => name.clone(), - Some(AestheticValue::AnnotationColumn { name }) => name.clone(), - _ => aesthetic.to_string(), - } -} - /// A resolved color aesthetic for a composite geom, mirroring the Vega-Lite /// writer's shared-encoding model: either a data-mapped color column (scaled /// through a registered scale that is bound + legended once, carrying that @@ -537,14 +439,15 @@ impl ColorSource { } /// Resolve a color aesthetic (`fill`, `stroke`, …) for a composite geom. A -/// data-mapped non-identity scale reuses (or registers) the shared color scale -/// for its `(source, kind)`, binds `channel` to it, and adds one legend on first -/// registration; the full color-domain column is returned for components to -/// select. Otherwise the constant value (the mapped literal, else `default`). +/// data-mapped non-identity scale binds `channel` to the aesthetic's (globally +/// registered) scale and adds a legend; the full color-domain column is returned +/// for components to select. Otherwise the constant value (the mapped literal, +/// else `default`). hephaestus collapses compatible legends, so repeated binds +/// across a geom's components merge. pub fn resolve_color( ctx: &Ctx, - w: &mut Wiring, - aesthetic: &str, + plot: &mut HPlot, + aesthetic: &'static str, channel: &'static str, default: Color, legend_kind: LegendKind, @@ -560,30 +463,18 @@ pub fn resolve_color( ctx, aesthetic, default, ))); } - // Reuse the shared color scale for this source (collapsing fill/stroke and - // cross-layer duplicates), registering + legending it only on first sight. - let key = (aesthetic_source(ctx.layer, aesthetic), RangeKind::Color); - let scale_name = match w.shared_scales.get(&key) { - Some(existing) => existing.clone(), - None => { - let hs = build_scale(scale, (0.0, 1.0), RangeKind::Color); - w.registered.push((aesthetic.to_string(), hs)); - w.shared_scales.insert(key, aesthetic.to_string()); - w.legends.push(material_legend( - aesthetic, - channel, - RangeKind::Color, - kind, - aesthetic_label(ctx.spec, ctx.layer, aesthetic), - legend_kind, - )); - aesthetic.to_string() - } - }; - w.bindings.push((channel, scale_name.clone())); + plot.set_binding(channel, aesthetic); + plot.add_legend(material_legend( + aesthetic, + channel, + RangeKind::Color, + kind, + aesthetic_label(ctx.spec, ctx.layer, aesthetic), + legend_kind, + )); Ok(ColorSource::Data { data: column_to_channel(ctx.df, col.unwrap())?, - scale: scale_name, + scale: aesthetic.to_string(), }) } diff --git a/src/writer/mod.rs b/src/writer/mod.rs index e47edff8e..9b9d76c46 100644 --- a/src/writer/mod.rs +++ b/src/writer/mod.rs @@ -38,7 +38,7 @@ pub use vegalite::VegaLiteWriter; pub mod hephaestus; #[cfg(feature = "hephaestus")] -pub use hephaestus::HephaestusWriter; +pub use hephaestus::{rgba, Color, HephaestusWriter}; /// Trait for visualization output writers /// From aac69829d1a34b4643694234455edf5a03ab5b70 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 3 Aug 2026 11:17:28 +0200 Subject: [PATCH 06/29] Add faceting --- Cargo.lock | 2 +- src/Cargo.toml | 2 +- src/writer/hephaestus/PLAN.md | 83 ++++++- src/writer/hephaestus/facet.rs | 306 ++++++++++++++++++++++++++ src/writer/hephaestus/geom/boxplot.rs | 9 +- src/writer/hephaestus/geom/segment.rs | 7 +- src/writer/hephaestus/geom/text.rs | 6 +- src/writer/hephaestus/geom/violin.rs | 6 +- src/writer/hephaestus/mod.rs | 210 ++++++++++++------ src/writer/hephaestus/projection.rs | 57 +++-- src/writer/hephaestus/scales.rs | 91 ++++++++ src/writer/hephaestus/wiring.rs | 67 ++++-- 12 files changed, 730 insertions(+), 116 deletions(-) create mode 100644 src/writer/hephaestus/facet.rs diff --git a/Cargo.lock b/Cargo.lock index 228715002..5265d721d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2498,7 +2498,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hephaestus" version = "0.0.1" -source = "git+https://github.com/posit-dev/hephaestus.git?rev=5997cdd03cae3036a870d587d1f47caa2b8b195c#5997cdd03cae3036a870d587d1f47caa2b8b195c" +source = "git+https://github.com/posit-dev/hephaestus.git?rev=bac76329ca5e43f305e72d02c8ebc86d707ae1c7#bac76329ca5e43f305e72d02c8ebc86d707ae1c7" dependencies = [ "bytemuck", "clipper2-rust", diff --git a/src/Cargo.toml b/src/Cargo.toml index 55d64c281..ec47d1492 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -40,7 +40,7 @@ adbc_core = { version = "0.23", optional = true } geozero = { workspace = true, optional = true, features = ["with-wkb", "with-wkt", "with-geojson"] } # Hephaestus raster writer (non-default; gated, excluded from the MSRV 1.86 build) -hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "5997cdd03cae3036a870d587d1f47caa2b8b195c", optional = true, default-features = false, features = ["vello", "png", "text"] } +hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "bac76329ca5e43f305e72d02c8ebc86d707ae1c7", optional = true, default-features = false, features = ["vello", "png", "text"] } png = { version = "0.18", optional = true } # Serialization diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index 11e8d9d7c..900a0ca39 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -478,9 +478,8 @@ position scale (`__ggsql_stat_dummy` — a pie's radius, or a bar with no x) is given no axis, in every projection, via the shared `has_real_axis` predicate, mirroring the VL writer's `AxisInfo::suppress`. -Two hephaestus fixes were required (in `~/GitHub/hephaestus`, pending push + rev -bump — a temporary path `[patch]` in the root `Cargo.toml` wires the local clone -meanwhile): +Two hephaestus fixes were required (landed upstream on `main` as `5997cdd`; ggsql +pins that rev in `src/Cargo.toml`): 1. `angle_channel`/`radius_channel` are now honored by **geom geometry**, not just chrome: `project_to_panel_px` / `interpolate_segment_with_t` route a geom's positional `[x, y]` to theta/radius via `PolarProjection::theta_r_from_xy` @@ -518,6 +517,84 @@ meant to have a discrete primary axis; this looks like ggsql not coercing a numeric bar axis to discrete. (`histogram` is unaffected — it carries real `pos1`/`pos1end` bin edges and renders correctly.) +## Faceting (Phase 4) — status: implemented + +`FACET` now renders as small multiples. The writer builds a hephaestus +`Composition` of named panels and attaches one `Plot` per panel to the shared +`PlotComposition`, so all panels resolve through one scale registry (fixed +scales). Works under Cartesian, Polar, and Map projections. + +- **New module `facet.rs`.** ggsql resolves faceting fully (layout, `free` bools, + Wrap `ncol`, per-row `__ggsql_aes_facet1__`/`facet2__`), so the writer only + lays it out. `build_panels` returns a `(Composition, Vec)` — a 1×1 grid + + one `Panel` when unfaceted, else `composition::grid(nrow, ncol, cells)` (Wrap + flows row-major into the resolved `ncol`, padding the partial last row with + `spacer()`; Grid is facet1 rows × facet2 columns). Panel **ordering** mirrors + the VL writer's `resolve_facet_ordering` (facet scale `input_range`, then + `reverse`; numeric-aware ascending otherwise). Per-panel **data slicing** reuses + `DataFrame::take` on the row indices matching the panel's facet value(s); a + layer with no facet column is used whole. Grid cells with no data (e.g. + `missing => 'null'`) are skipped, leaving an empty framed panel. +- **The write loop is now panel-based** (`mod.rs`): unfaceted and faceted share + one path (`Vec`, length 1 when there's no FACET). Fixed scales are + registered once globally; each panel builds one `HPlot`, applies the + projection, and sets strip labels. +- **Strip labels** via hephaestus `Plot::strip`: Wrap/Grid-column headers on + `AxisSide::Top`, Grid-row headers on `AxisSide::Right`. +- **Edge-only axes** (ggplot2 look): for a fixed dimension the x-axis is drawn + only on the bottom-most present panel of each column and the y-axis only on the + left column (`Panel::{first_col,last_row}`, honored in + `projection::apply_proj_cartesian`). +- **Free scales** (`free => 'x' | 'y' | ['x','y']`): a **deliberate, scoped + exception** to "ggsql owns all scale domains" — fixed dimensions still use + `numeric_domain()`, but a free dimension gets a per-panel scale + (`pos1__p{idx}`) whose domain the writer computes from that panel's slices + (`scales::free_position_scale`: family numeric extent for continuous/binned, + per-panel distinct categories for discrete). `PanelScales` carries the + per-panel position scale names; `Ctx` threads them so every geom (generic + + composite) binds positions to the panel scale. A free dimension forces its axis + onto every panel. The clean long-term home is ggsql resolving per-panel domains, + which would let the writer drop this computation. +- Verified (eyeballed): 3×2 wrap (partial last row, correct per-column x-axis), + 2×2 grid (top + right strips), free-x/y (each panel its own domain/axes vs the + squashed fixed comparison), polar facet (one pie per panel, per-panel + proportions). Tests: `renders_wrap_facet`, `renders_grid_facet`, + `renders_faceted_bar_with_color`, `renders_free_scale_facet`, + `renders_polar_facet` (34 writer tests pass); hephaestus-absent build compiles; + fmt + clippy clean. +- **Single shared legend across panels.** hephaestus `bac7632` added a + composition-level legend ring (`PlotComposition::add_legend`, same + `(domain_scale, side, title)` dedup + stack-key merge as the per-plot one), so + legends live on the composition, never on the per-panel plots. Wiring no longer + calls `Plot::add_legend`: `Ctx` carries a `legends` sink (`Option<&RefCell< + Vec>>`) and `wire_material` / `resolve_color` push through + `Ctx::push_legend`. `mod.rs` passes the sink only while building the **first** + panel (every panel produces the same legends — all built from the globally + resolved scales — and each legend reflects the global scale domain regardless of + a panel's data slice), then registers the captured set once on the composition + (which also carries a `shape_registry` for the legend glyphs). This uniformly + covers the single-panel case (a 1×1 composition legend ring) — no faceted/ + non-faceted branch, no register-then-unregister. Verified by eyeballing a + 3-panel wrap colored by a categorical (one legend beside the whole strip) and + the single-panel equivalent (unchanged). + +Known limitations (refinements, for upstream / later): +- **Duplicate fill+stroke legend for `point` colored by a categorical** — a + pre-existing single-panel writer issue (not faceting-specific): the point's + fill and stroke both bind the color scale and each register a legend + (filled-swatch + hollow-swatch), so two identical-titled legends stack. The + faceting path faithfully reproduces whatever the single-panel path produces, so + fixing this in the material wiring fixes both. +- **No title/subtitle/caption wired.** This writer doesn't render plot + title/subtitle/caption yet (true for the single-panel case too). hephaestus + `bac7632` exposes `PlotComposition::{title,subtitle,caption,axis_title}` for the + composition-spanning case, so this is now a straightforward follow-up — read the + ggsql `Labels` and set them on the composition. +- **Strip label formatting** uses the facet value's string form (correct for + discrete). Binned range labels and discrete RENAMING parity with the VL + writer's `build_*_facet_label_expr` are a refinement. +- Free binned dimensions fall back to a plain continuous per-panel scale. + ## 8. Key source references ggsql: diff --git a/src/writer/hephaestus/facet.rs b/src/writer/hephaestus/facet.rs new file mode 100644 index 000000000..f80606ebc --- /dev/null +++ b/src/writer/hephaestus/facet.rs @@ -0,0 +1,306 @@ +//! 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::collections::HashSet; + +use arrow::array::UInt32Array; +use hephaestus::composition::{grid, spacer, Composition, Element, Patch}; + +use super::channels::column_to_strings; +use crate::naming; +use crate::plot::{ArrayElement, FacetLayout, ParameterValue, Scale}; +use crate::{DataFrame, Plot, Result}; + +/// Patch id for the single (unfaceted) panel. +pub const PANEL_ID: &str = "ggsql_panel"; + +/// 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, + } + } +} + +/// 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 { + let comp = grid(1, 1, vec![Element::from(Patch::new(PANEL_ID))]); + return Ok((comp, vec![Panel::single()])); + }; + let layer0 = super::layer_dataframe(&spec.layers[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")?; + let n = levels.len().max(1); + 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.clone()), + facet2: None, + strip_top: Some(level.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")?; + let nrow = rows.len().max(1); + let ncol = cols.len().max(1); + + 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.clone()), + facet2: Some(colv.clone()), + strip_top: (r == 0).then(|| colv.clone()), + strip_right: (c == ncol - 1).then(|| rowv.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), + } +} + +/// Distinct facet levels present in the data, ordered per the facet scale. +fn ordered_levels(spec: &Plot, df: &DataFrame, internal_aes: &str) -> Result> { + let col = naming::aesthetic_column(internal_aes); + let values = column_to_strings(df, &col)?; + let mut seen = HashSet::new(); + let mut distinct: Vec = Vec::new(); + for v in values { + if seen.insert(v.clone()) { + distinct.push(v); + } + } + Ok(order_by_scale(distinct, spec.find_scale(internal_aes))) +} + +/// Order distinct facet values by the scale's `input_range` (then any +/// present-but-unlisted values, sorted), or a numeric-aware ascending sort when +/// there is no scale/range. Reversed when the scale sets `reverse => true`. +fn order_by_scale(mut distinct: Vec, scale: Option<&Scale>) -> Vec { + let reverse = scale + .map(|s| { + matches!( + s.properties.get("reverse"), + Some(ParameterValue::Boolean(true)) + ) + }) + .unwrap_or(false); + + let mut ordered = match scale.and_then(|s| s.input_range.as_ref()) { + Some(range) => { + let order: Vec = range.iter().map(element_to_string).collect(); + let mut ranked: Vec = order + .iter() + .filter(|o| distinct.contains(o)) + .cloned() + .collect(); + let mut extra: Vec = distinct + .into_iter() + .filter(|d| !order.contains(d)) + .collect(); + sort_values(&mut extra); + ranked.extend(extra); + ranked + } + None => { + sort_values(&mut distinct); + distinct + } + }; + if reverse { + ordered.reverse(); + } + ordered +} + +/// Numeric-aware ascending sort: numeric when every value parses as `f64`, +/// otherwise lexical. +fn sort_values(values: &mut [String]) { + if values.iter().all(|s| s.parse::().is_ok()) { + values.sort_by(|a, b| { + a.parse::() + .unwrap() + .partial_cmp(&b.parse::().unwrap()) + .unwrap_or(std::cmp::Ordering::Equal) + }); + } else { + values.sort(); + } +} + +/// Render an `input_range` element to the string form the facet column carries +/// (whole numbers as integers, matching an integer column's cast to text). +fn element_to_string(element: &ArrayElement) -> String { + 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:?}"), + } +} + +/// 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, + } + } +} + +/// 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 = column_to_strings(df, &f1)?; + let c2 = match &panel.facet2 { + Some(_) => Some(column_to_strings(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/boxplot.rs b/src/writer/hephaestus/geom/boxplot.rs index 3a81b8d7b..7bc92bc44 100644 --- a/src/writer/hephaestus/geom/boxplot.rs +++ b/src/writer/hephaestus/geom/boxplot.rs @@ -38,8 +38,13 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { .collect(); let out_i = rows_of("outlier"); - // Bind the shared position channels (scales are registered globally). - for (channel, scale) in [("x", "pos1"), ("x2", "pos1"), ("y", "pos2"), ("y2", "pos2")] { + // Bind the position channels (panel-aware for free facet scales). + 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); } diff --git a/src/writer/hephaestus/geom/segment.rs b/src/writer/hephaestus/geom/segment.rs index 65ac3c262..52d11124d 100644 --- a/src/writer/hephaestus/geom/segment.rs +++ b/src/writer/hephaestus/geom/segment.rs @@ -119,7 +119,12 @@ pub fn build_diagonal(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { (x0, y0, x1, y1) }; - for (channel, scale) in [("x", "pos1"), ("x2", "pos1"), ("y", "pos2"), ("y2", "pos2")] { + 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); } diff --git a/src/writer/hephaestus/geom/text.rs b/src/writer/hephaestus/geom/text.rs index b50afe5f0..68932137b 100644 --- a/src/writer/hephaestus/geom/text.rs +++ b/src/writer/hephaestus/geom/text.rs @@ -25,11 +25,11 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { let mut b = TextGeom::builder(); - // Positions: bind to the global pos1/pos2 scales. + // 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", "pos1"); - plot.set_binding("y", "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"); diff --git a/src/writer/hephaestus/geom/violin.rs b/src/writer/hephaestus/geom/violin.rs index 534638eae..61d12ef3c 100644 --- a/src/writer/hephaestus/geom/violin.rs +++ b/src/writer/hephaestus/geom/violin.rs @@ -46,7 +46,11 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { order.extend_from_slice(rows); } - for (channel, scale) in [("x", "pos1"), ("x2", "pos1"), ("y", "pos2")] { + for (channel, scale) in [ + ("x", ctx.pos1_scale), + ("x2", ctx.pos1_scale), + ("y", ctx.pos2_scale), + ] { plot.set_binding(channel, scale); } diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index 82120be51..cf4298abe 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -3,17 +3,18 @@ //! Renders a resolved ggsql `Spec` to PNG bytes via the [`hephaestus`] 2D scene //! renderer. //! -//! **Scope** (see `src/writer/hephaestus/PLAN.md`): single-panel, single-layer, -//! Cartesian plots. All geoms (point/line/path/area/ribbon/bar/histogram/tile/ -//! polygon/segment/rule/range/text/density/smooth/boxplot/violin), all scale -//! types/transforms, material aesthetics, axis titles, and legends are -//! supported. Faceting, projections, and multi-layer arrive in later phases; -//! unsupported specs are rejected by [`HephaestusWriter::validate`]. +//! **Scope** (see `src/writer/hephaestus/PLAN.md`): multi-layer plots under +//! Cartesian, Polar, and Map projections, with `FACET` faceting (Wrap/Grid, +//! fixed + free scales). All geoms (point/line/path/area/ribbon/bar/histogram/ +//! tile/polygon/segment/rule/range/text/density/smooth/boxplot/violin), all +//! scale types/transforms, material aesthetics, axis titles, and legends are +//! supported. Unsupported geoms are rejected by [`HephaestusWriter::validate`]. //! //! 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; @@ -23,9 +24,9 @@ use std::collections::HashMap; use hephaestus::backend::vello::VelloRenderer; pub use hephaestus::color::{rgba, Color}; -use hephaestus::composition::{Composition, Patch, Span}; use hephaestus::geometry::Size; use hephaestus::plot::{Plot as HPlot, PlotComposition}; +use hephaestus::scales::chrome::AxisSide; use hephaestus::shape::ShapeRegistry; use hephaestus::Renderer; @@ -37,9 +38,6 @@ use crate::{DataFrame, GgsqlError, Layer, Plot, Result}; use wiring::Ctx; -/// Internal patch id for the single panel. -const PANEL_ID: &str = "ggsql_panel"; - /// Writer that renders a ggsql plot to a PNG image via hephaestus. /// /// Configured with a target pixel size and DPI because raster rendering needs @@ -73,11 +71,6 @@ impl Writer for HephaestusWriter { type Output = Vec; fn validate(&self, spec: &Plot) -> Result<()> { - if spec.facet.is_some() { - return Err(GgsqlError::WriterError( - "hephaestus writer does not support FACET yet".into(), - )); - } if spec.layers.is_empty() { return Err(GgsqlError::WriterError( "hephaestus writer requires at least one layer".into(), @@ -96,8 +89,17 @@ impl Writer for HephaestusWriter { fn write(&self, spec: &Plot, data: &HashMap) -> Result { self.validate(spec)?; - let composition = single_panel(); - let mut view = PlotComposition::new(&composition); + + // 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()); + + // 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" | "color" | "colour" => scales::RangeKind::Color, @@ -116,27 +118,89 @@ impl Writer for HephaestusWriter { } } - // Build every layer's geom into one panel; each geom binds its channels - // and adds its legends directly onto `plot`. Geoms draw in layer (DRAW) - // order = z-order. - let mut plot = - HPlot::new(&composition, PANEL_ID).shape_registry(ShapeRegistry::with_builtins()); + // 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. Skip panels with no data in + // any layer (e.g. a Grid cell absent under `missing => 'null'`) so the + // grid cell stays an empty framed panel rather than erroring. + let slices: Vec<(&Layer, DataFrame)> = spec + .layers + .iter() + .map(|layer| { + Ok(( + layer, + facet::panel_dataframe(layer_dataframe(layer, data)?, panel)?, + )) + }) + .collect::>()?; + if slices.iter().all(|(_, df)| df.height() == 0) { + continue; + } - for layer in &spec.layers { - let df = layer_dataframe(layer, data)?; - let ctx = Ctx { - spec, - layer, - df, - transposed: is_transposed(layer), - }; - geom::build_into_plot(&mut plot, &ctx)?; - } + // 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 ps = facet::PanelScales::new(spec, panel); + let layer_dfs: Vec<&DataFrame> = slices.iter().map(|(_, df)| df).collect(); + if ps.free_x { + if let Some(hs) = + scales::free_position_scale(spec.find_scale("pos1"), &layer_dfs, "pos1") + { + view.insert_scale(ps.pos1.clone(), hs); + } + } + if ps.free_y { + if let Some(hs) = + scales::free_position_scale(spec.find_scale("pos2"), &layer_dfs, "pos2") + { + view.insert_scale(ps.pos2.clone(), hs); + } + } - // Axes are created per coordinate system (Cartesian rails, polar rings). - plot = apply_projection(plot, spec); + // 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. + let panel_legends = (!legends_captured).then_some(&legend_sink); + let mut plot = HPlot::new(&composition, panel.id.as_str()) + .shape_registry(ShapeRegistry::with_builtins()); + 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; + + // Axes are created per coordinate system, edge-only for fixed scales. + plot = apply_projection(plot, spec, panel, &ps); + + // 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); + } - 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() { @@ -155,34 +219,6 @@ impl Writer for HephaestusWriter { } } -/// The single-panel composition the writer renders into. -fn single_panel() -> Composition { - Composition::empty(1, 1).place(1, 1, Span::cell(), Patch::new(PANEL_ID)) -} - -// Scaffolding for faceted (grid) compositions — not wired up yet. -#[allow(dead_code)] -fn grid_id(row: usize, col: usize) -> String { - format!("{}x{}", row, col) -} - -/// A `rows`×`cols` grid composition, one patch per cell (faceting; not yet used). -#[allow(dead_code)] -fn panel_grid(rows: usize, cols: usize) -> Composition { - let mut comp = Composition::empty(rows, cols); - for row in 1..(rows + 1) { - for col in 1..(cols + 1) { - comp = comp.place( - row.try_into().unwrap(), - col.try_into().unwrap(), - Span::cell(), - Patch::new(grid_id(row, col)), - ); - } - } - comp -} - /// Look up the DataFrame backing a layer by its execution-assigned data key. fn layer_dataframe<'a>( layer: &Layer, @@ -514,6 +550,54 @@ mod tests { )); } + #[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_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", + )); + } + #[test] fn rejects_unsupported_geom() { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); diff --git a/src/writer/hephaestus/projection.rs b/src/writer/hephaestus/projection.rs index 0c1bcf1af..ef6539673 100644 --- a/src/writer/hephaestus/projection.rs +++ b/src/writer/hephaestus/projection.rs @@ -8,24 +8,31 @@ use hephaestus::plot::AspectMode; use hephaestus::plot::Plot as HPlot; use hephaestus::scales::chrome::AxisSide; +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. No `PROJECT` clause is treated as -/// Cartesian. -pub fn apply_projection(plot: HPlot, spec: &Plot) -> HPlot { +/// 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) + apply_proj_cartesian(plot, spec.project.as_ref(), spec, panel, ps) } - Some(CoordKind::Polar) => apply_proj_polar(plot, spec.project.as_ref().unwrap(), spec), + 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) -> HPlot { +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); @@ -34,8 +41,15 @@ fn apply_proj_cartesian(mut plot: HPlot, proj: Option<&Projection>, spec: &Plot) plot = plot.aspect_ratio(*ratio).aspect_mode(AspectMode::Range); } } - add_cartesian_axis(&mut plot, spec, "pos1", AxisSide::Bottom); - add_cartesian_axis(&mut plot, spec, "pos2", AxisSide::Left); + // 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 } @@ -47,22 +61,31 @@ 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 for a position scale, titled from the plot's labels -/// (or the first layer's mapped column). Skipped for absent or dummy scales. -fn add_cartesian_axis(plot: &mut HPlot, spec: &Plot, name: &str, side: AxisSide) { - if !has_real_axis(spec, name) { +/// Add one bottom/left rail bound to `scale_name`, titled from the plot's labels +/// (or the first layer's mapped column, keyed by `aesthetic`). 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). +fn add_cartesian_axis( + plot: &mut HPlot, + spec: &Plot, + aesthetic: &str, + scale_name: &str, + side: AxisSide, +) { + if !has_real_axis(spec, aesthetic) { return; } - let mut rail = Axis::rail(name, AxisPlacement::Cartesian(side)); + let mut rail = Axis::rail(scale_name, AxisPlacement::Cartesian(side)); if let Some(layer) = spec.layers.first() { - if let Some(title) = aesthetic_label(spec, layer, name) { + if let Some(title) = aesthetic_label(spec, layer, aesthetic) { rail = rail.title(title); } } plot.add_axis(rail); } -fn apply_proj_polar(mut plot: HPlot, proj: &Projection, spec: &Plot) -> HPlot { +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); @@ -96,13 +119,13 @@ fn apply_proj_polar(mut plot: HPlot, proj: &Projection, spec: &Plot) -> HPlot { // radius), same as the Cartesian path. if has_real_axis(spec, "pos2") { plot.add_axis(Axis::rail( - "pos2", + ps.pos2.as_str(), AxisPlacement::PolarAngular(PolarRing::Outer), )); } if has_real_axis(spec, "pos1") { plot.add_axis(Axis::rail( - "pos1", + ps.pos1.as_str(), AxisPlacement::PolarRadius { theta_frac: start }, )); } diff --git a/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs index 67557c8a8..428b470ee 100644 --- a/src/writer/hephaestus/scales.rs +++ b/src/writer/hephaestus/scales.rs @@ -13,8 +13,11 @@ use hephaestus::plot::geom::linetype::{dashdot, dashed, dotted, solid}; use hephaestus::plot::scale::{self, Scale as HScale, TransformKind as HTransform}; use hephaestus::scales::value::{LinetypeStep, Value as HValue}; +use super::channels::{column_to_f64, column_to_strings}; +use crate::naming; use crate::plot::scale::TransformKind as GTransform; use crate::plot::{ArrayElement, OutputRange, 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. @@ -88,6 +91,94 @@ pub fn build_scale(scale: Option<&GScale>, kind: RangeKind) -> Option { Some(hs) } +/// 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/binned 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. +/// ggsql's resolved breaks are for the global domain and don't fit a per-panel +/// one, so ticks are left to hephaestus. +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()); + + match type_kind { + ScaleTypeKind::Discrete | ScaleTypeKind::Ordinal => { + let vals: Vec = panel_categories(dfs, base) + .into_iter() + .map(|s| HValue::String(Arc::from(s.as_str()))) + .collect(); + Some(if matches!(type_kind, ScaleTypeKind::Ordinal) { + scale::ordinal(vals) + } else { + scale::discrete(vals) + }) + } + ScaleTypeKind::Identity => Some(scale::identity()), + ScaleTypeKind::Continuous | ScaleTypeKind::Binned => { + let (min, max) = panel_extent(dfs, base)?; + let (min, max) = pad_degenerate(min, max); + let mut c = scale::continuous(min..=max); + if let Some(t) = transform.and_then(map_transform) { + c = c.with_transform(t); + } + Some(c) + } + } +} + +/// 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 { + for suffix in ["", "min", "max", "end"] { + 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 distinct category values of a position column across the given slices, +/// in first-seen order. +fn panel_categories(dfs: &[&DataFrame], base: &str) -> Vec { + let name = naming::aesthetic_column(base); + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for df in dfs { + if df.column(&name).is_ok() { + if let Ok(values) = column_to_strings(df, &name) { + for v in values { + if seen.insert(v.clone()) { + out.push(v); + } + } + } + } + } + out +} + /// 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, diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index c531e92d9..8a5b712a3 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -26,6 +26,36 @@ pub struct Ctx<'a> { 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. @@ -35,18 +65,6 @@ pub enum PanelAxis { Y, } -impl PanelAxis { - /// The ggsql scale name backing this axis (`pos1` for x, `pos2` for y). - /// Scales are registered globally from `spec.scales`; geoms bind their - /// position channels to these names. - pub fn scale_name(self) -> &'static str { - match self { - PanelAxis::X => "pos1", - PanelAxis::Y => "pos2", - } - } -} - /// A position channel: hephaestus `channel` ← ggsql `aesthetic`, on `axis`. pub struct PositionSpec { pub channel: &'static str, @@ -124,10 +142,11 @@ pub struct GeomSpec { pub grouped: bool, } -/// Build a concrete geom from its spec and attach it to the plot. Bindings and -/// legends are written directly onto `plot`; scales are registered globally from -/// `spec.scales` (see `HephaestusWriter::write`), and axes are created per -/// coordinate system in `projection`. +/// 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 +/// `HephaestusWriter::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, @@ -174,13 +193,13 @@ fn wire_positions( })?; let data = column_to_channel(ctx.df, col)?; data.apply(builder, p.channel); - plot.set_binding(p.channel, p.axis.scale_name()); + plot.set_binding(p.channel, ctx.pos_scale(p.axis)); } Ok(()) } /// Set material channels: data-mapped → bind channel to its (globally -/// registered) scale + add a legend; literal → constant visual value; identity/ +/// registered) scale + record a legend; literal → constant visual value; identity/ /// annotation → `Raw` per-row values; unmapped → the spec's default. fn wire_material( builder: &mut GeomBuilder, @@ -221,10 +240,10 @@ fn wire_material( let data = column_to_channel(ctx.df, col)?; data.apply(builder, m.channel); // Bind the channel to the aesthetic's scale (registered globally) and - // add a legend. hephaestus collapses compatible legends, so repeated - // adds across layers for the same scale merge. + // record a legend. hephaestus collapses compatible legends, so repeated + // records across layers for the same scale merge at registration. plot.set_binding(m.channel, m.aesthetic); - plot.add_legend(material_legend( + ctx.push_legend(material_legend( m.aesthetic, m.channel, m.kind, @@ -440,10 +459,10 @@ impl ColorSource { /// Resolve a color aesthetic (`fill`, `stroke`, …) for a composite geom. A /// data-mapped non-identity scale binds `channel` to the aesthetic's (globally -/// registered) scale and adds a legend; the full color-domain column is returned +/// registered) scale and records a legend; the full color-domain column is returned /// for components to select. Otherwise the constant value (the mapped literal, /// else `default`). hephaestus collapses compatible legends, so repeated binds -/// across a geom's components merge. +/// across a geom's components merge at registration. pub fn resolve_color( ctx: &Ctx, plot: &mut HPlot, @@ -464,7 +483,7 @@ pub fn resolve_color( ))); } plot.set_binding(channel, aesthetic); - plot.add_legend(material_legend( + ctx.push_legend(material_legend( aesthetic, channel, RangeKind::Color, From 818581182f2febda0ce357a2cd59c903eb404b5c Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 3 Aug 2026 12:45:35 +0200 Subject: [PATCH 07/29] Bump hephaestus to get correct legend merging --- Cargo.lock | 2 +- src/Cargo.toml | 2 +- src/writer/hephaestus/PLAN.md | 24 +++++++++++++----------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5265d721d..78c329f7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2498,7 +2498,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hephaestus" version = "0.0.1" -source = "git+https://github.com/posit-dev/hephaestus.git?rev=bac76329ca5e43f305e72d02c8ebc86d707ae1c7#bac76329ca5e43f305e72d02c8ebc86d707ae1c7" +source = "git+https://github.com/posit-dev/hephaestus.git?rev=f133825c0149f214ca0406ac8cf1c816641e2c8a#f133825c0149f214ca0406ac8cf1c816641e2c8a" dependencies = [ "bytemuck", "clipper2-rust", diff --git a/src/Cargo.toml b/src/Cargo.toml index ec47d1492..d202ff5c8 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -40,7 +40,7 @@ adbc_core = { version = "0.23", optional = true } geozero = { workspace = true, optional = true, features = ["with-wkb", "with-wkt", "with-geojson"] } # Hephaestus raster writer (non-default; gated, excluded from the MSRV 1.86 build) -hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "bac76329ca5e43f305e72d02c8ebc86d707ae1c7", optional = true, default-features = false, features = ["vello", "png", "text"] } +hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "f133825c0149f214ca0406ac8cf1c816641e2c8a", optional = true, default-features = false, features = ["vello", "png", "text"] } png = { version = "0.18", optional = true } # Serialization diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index 900a0ca39..11b92a3bc 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -563,10 +563,9 @@ scales). Works under Cartesian, Polar, and Map projections. `renders_polar_facet` (34 writer tests pass); hephaestus-absent build compiles; fmt + clippy clean. - **Single shared legend across panels.** hephaestus `bac7632` added a - composition-level legend ring (`PlotComposition::add_legend`, same - `(domain_scale, side, title)` dedup + stack-key merge as the per-plot one), so - legends live on the composition, never on the per-panel plots. Wiring no longer - calls `Plot::add_legend`: `Ctx` carries a `legends` sink (`Option<&RefCell< + composition-level legend ring (`PlotComposition::add_legend`), so legends live + on the composition, never on the per-panel plots. Wiring no longer calls + `Plot::add_legend`: `Ctx` carries a `legends` sink (`Option<&RefCell< Vec>>`) and `wire_material` / `resolve_color` push through `Ctx::push_legend`. `mod.rs` passes the sink only while building the **first** panel (every panel produces the same legends — all built from the globally @@ -577,17 +576,20 @@ scales). Works under Cartesian, Polar, and Map projections. non-faceted branch, no register-then-unregister. Verified by eyeballing a 3-panel wrap colored by a categorical (one legend beside the whole strip) and the single-panel equivalent (unchanged). +- **Equivalent fill+stroke scales collapse to one legend.** A `point` colored by + a categorical maps `color` onto **two** ggsql scales — `fill` and `stroke` — so + the writer records two legends. hephaestus `f133825`'s `collapse_legends` (run + at render) merges legends whose scales are `legend_equivalent_to` (same + scale_type / transform / input_range / breaks) even when their names differ, and + overlays the merged keys into one swatch. The two `cat` legends therefore render + as a single legend with a filled+outlined swatch per category — no writer code + needed beyond registering both legends (which it already does) with the default + `merge` flag on. Known limitations (refinements, for upstream / later): -- **Duplicate fill+stroke legend for `point` colored by a categorical** — a - pre-existing single-panel writer issue (not faceting-specific): the point's - fill and stroke both bind the color scale and each register a legend - (filled-swatch + hollow-swatch), so two identical-titled legends stack. The - faceting path faithfully reproduces whatever the single-panel path produces, so - fixing this in the material wiring fixes both. - **No title/subtitle/caption wired.** This writer doesn't render plot title/subtitle/caption yet (true for the single-panel case too). hephaestus - `bac7632` exposes `PlotComposition::{title,subtitle,caption,axis_title}` for the + exposes `PlotComposition::{title,subtitle,caption,axis_title}` for the composition-spanning case, so this is now a straightforward follow-up — read the ggsql `Labels` and set them on the composition. - **Strip label formatting** uses the facet value's string form (correct for From 4c6ec8206a9bd28a4e5f551f006e5b651ca647a3 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 3 Aug 2026 16:33:36 +0200 Subject: [PATCH 08/29] Support spatial --- Cargo.lock | 2 +- src/Cargo.toml | 2 +- src/writer/hephaestus/PLAN.md | 60 ++++++++++++ src/writer/hephaestus/channels.rs | 98 ++++++++++++++++++- src/writer/hephaestus/geom/mod.rs | 3 + src/writer/hephaestus/geom/spatial.rs | 86 +++++++++++++++++ src/writer/hephaestus/mod.rs | 132 +++++++++++++++++++++++++- src/writer/hephaestus/projection.rs | 36 ++++++- src/writer/hephaestus/wiring.rs | 6 +- 9 files changed, 416 insertions(+), 9 deletions(-) create mode 100644 src/writer/hephaestus/geom/spatial.rs diff --git a/Cargo.lock b/Cargo.lock index 78c329f7f..176364619 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2498,7 +2498,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hephaestus" version = "0.0.1" -source = "git+https://github.com/posit-dev/hephaestus.git?rev=f133825c0149f214ca0406ac8cf1c816641e2c8a#f133825c0149f214ca0406ac8cf1c816641e2c8a" +source = "git+https://github.com/posit-dev/hephaestus.git?rev=d343138c5d9c5d33e1b225b7bfb5b1c6ffc2bcbf#d343138c5d9c5d33e1b225b7bfb5b1c6ffc2bcbf" dependencies = [ "bytemuck", "clipper2-rust", diff --git a/src/Cargo.toml b/src/Cargo.toml index d202ff5c8..24f71ea6a 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -40,7 +40,7 @@ adbc_core = { version = "0.23", optional = true } geozero = { workspace = true, optional = true, features = ["with-wkb", "with-wkt", "with-geojson"] } # Hephaestus raster writer (non-default; gated, excluded from the MSRV 1.86 build) -hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "f133825c0149f214ca0406ac8cf1c816641e2c8a", optional = true, default-features = false, features = ["vello", "png", "text"] } +hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "d343138c5d9c5d33e1b225b7bfb5b1c6ffc2bcbf", optional = true, default-features = false, features = ["vello", "png", "text", "geom-wkb", "geom-wkt"] } png = { version = "0.18", optional = true } # Serialization diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index 11b92a3bc..ed26eff8c 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -597,6 +597,66 @@ Known limitations (refinements, for upstream / later): writer's `build_*_facet_label_expr` are a refinement. - Free binned dimensions fall back to a plain continuous per-panel scale. +## Spatial / Map (Phase 5b) — status: implemented + +The `spatial` geom and the **Map** coordinate system render, completing the last +coordinate system (Cartesian + Polar were already done). Full parity with the +Vega-Lite writer: geometry marks, the projected panel boundary, and graticules — +for both a `PROJECT map` and a bare `spatial` geom under Cartesian. + +The seam is clean because ggsql's executor does **all** SQL-side projection +renderer-agnostically (`plot/projection/coord/map.rs`, `plot/layer/geom/ +spatial.rs`): by the time the writer runs, geometry is WKB in +`__ggsql_aes_geometry__`, and `Projection.computed` holds `panel_boundary` (WKT), +`bbox` (`[xmin,ymin,xmax,ymax]`), and `graticule_lon`/`graticule_lat` (WKT). The +writer only decodes and frames — no data is recomputed. Mirrors the VL +`MapProjection` (identity projection) and `SpatialRenderer`. + +- **Dep** (`src/Cargo.toml`): hephaestus `features` gains `geom-wkb` (geometry + column) + `geom-wkt` (boundary/graticule strings), and the rev is bumped to + `d343138` — whose `CustomProjection` outline is a `Vec`, so a + multi-part clip boundary passes through whole (`GeometryGeom` already existed). +- **`channels.rs`**: `column_to_geometry` decodes the WKB `Binary`/`LargeBinary` + column (and hex-WKB strings, for ODBC/PostGIS parity) via `Geometry::from_wkb`; + nulls → `Geometry::Empty`. `wkt_to_lines` (graticule polylines) and + `wkt_to_outline` (boundary → `Vec`: a Polygon → one, a MultiPolygon + → all parts, each with holes) feed the map projection. +- **`geom/spatial.rs`**: a custom builder (like `text`/`boxplot`) — `GeometryGeom` + has no x/y channel, so it sets the `geometry` channel and binds x→`pos1`, + y→`pos2` (the geom's draw resolves each coordinate through those scales). + fill/stroke go through `resolve_color` (data-mapped → shared scale + one + collapsed legend, else the mapped literal / ggsql spatial defaults `#747474` + fill, black stroke, opacity 0.8, linewidth 0.2, solid linetype). +- **`mod.rs`**: a spatial layer has no `pos1`/`pos2` in `spec.scales`, so + `write` registers continuous `pos1`/`pos2` scales from ggsql's `computed["bbox"]` + (else the union geometry extent for a bare spatial geom) — the domain still + comes from ggsql. The panel's **aspect ratio is locked to the bbox** + (`aspect_mode(Range)`) so the projection isn't distorted (the raster analog of + VL's uniform projection scale); hephaestus's Cartesian y-flip already puts north + up, so no `reflectY` equivalent. + `opacity`/`linewidth`/`linetype` route through the shared `wire_material` + (made `pub`), so each is honored as the ggsql literal default, a `SETTING` + constant, **or data-mapped** (scale-bound + legend) — full parity with the VL + writer's generic encoding path, not fill/stroke-only. +- **`projection.rs`**: `apply_proj_map` clears the Cartesian rails and builds a + `Projection::Custom` from `computed` — `panel_boundary` → the clip/background + outline (a full **MultiPolygon**: every part with its holes; needs hephaestus + ≥ `d343138`, whose `CustomProjection` outline is `Vec`), + `graticule_lon`/`graticule_lat` → the x/y grid lines. Custom's coordinate math + equals Cartesian (no reprojection), exactly matching the pre-projected data. A + map with no boundary (no CRS/clip) falls back to the bbox-framed Cartesian + identity. +- Verified (eyeballed): orthographic world globe (round — equal aspect held — + with boundary + graticules), Mercator choropleth by continent (single collapsed + categorical legend, projected proportions), two-polygon bare `spatial` geom with + a continuous fill colorbar, and data-mapped `opacity` (per-feature transparency + + legend). Tests `renders_spatial`, `renders_spatial_mapped_opacity`, + `renders_map` (feature `spatial`); 37 writer tests; feature build, default + (hephaestus absent) build, `cargo +1.86` build, fmt, clippy all clean. + +Known limitations (deferred, matching VL/PLAN precedent): +- No writer-side reprojection (by design — ggsql projects in SQL). + ## 8. Key source references ggsql: diff --git a/src/writer/hephaestus/channels.rs b/src/writer/hephaestus/channels.rs index 6ebba3c0c..ccc215347 100644 --- a/src/writer/hephaestus/channels.rs +++ b/src/writer/hephaestus/channels.rs @@ -1,15 +1,16 @@ //! Bridging ggsql aesthetic mappings and DataFrame columns to the typed data //! hephaestus geoms consume. -use arrow::array::{Array, ArrayRef, StringArray}; +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, Layer, Result}; +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. @@ -145,3 +146,96 @@ pub fn column_to_colors(df: &DataFrame, name: &str) -> Result> { .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/geom/mod.rs b/src/writer/hephaestus/geom/mod.rs index 3cfc8a6d4..4f797386c 100644 --- a/src/writer/hephaestus/geom/mod.rs +++ b/src/writer/hephaestus/geom/mod.rs @@ -9,6 +9,7 @@ mod point; mod polygon; mod rect; mod segment; +mod spatial; mod text; mod violin; @@ -39,6 +40,7 @@ pub fn build_into_plot(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { 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!( @@ -66,6 +68,7 @@ pub fn is_supported(geom: GeomType) -> bool { | GeomType::Range | GeomType::Rule | GeomType::Text + | GeomType::Spatial | GeomType::Boxplot | GeomType::Violin ) diff --git a/src/writer/hephaestus/geom/spatial.rs b/src/writer/hephaestus/geom/spatial.rs new file mode 100644 index 000000000..8de06f848 --- /dev/null +++ b/src/writer/hephaestus/geom/spatial.rs @@ -0,0 +1,86 @@ +//! `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::{ + resolve_color, 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; + let n = df.height(); + + // 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 + // `HephaestusWriter::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); + + // fill/stroke: data-mapped (choropleth) → shared scale + legend, else the + // mapped literal or the ggsql spatial defaults. + let all: Vec = (0..n).collect(); + resolve_color( + ctx, + plot, + "fill", + "fill", + rgb8(0x74, 0x74, 0x74), + LegendKind::Rect, + )? + .apply(&mut b, "fill", &all); + resolve_color( + ctx, + plot, + "stroke", + "stroke", + rgb8(0, 0, 0), + LegendKind::Rect, + )? + .apply(&mut b, "stroke", &all); + + // opacity/linewidth/linetype: routed through the shared material path so + // each is honored whether it's the ggsql literal default, a `SETTING` + // constant, or data-mapped (scale-bound + legended). Mirrors the generic + // geoms; ggsql's spatial defaults (opacity 0.8, linewidth 0.2, solid) arrive + // as literals and set the fallback. + let material = [ + 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/mod.rs b/src/writer/hephaestus/mod.rs index cf4298abe..ef301ef58 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -25,12 +25,15 @@ use std::collections::HashMap; use hephaestus::backend::vello::VelloRenderer; pub use hephaestus::color::{rgba, Color}; use hephaestus::geometry::Size; -use hephaestus::plot::{Plot as HPlot, PlotComposition}; +use hephaestus::plot::{scale, AspectMode, Plot as HPlot, PlotComposition}; use hephaestus::scales::chrome::AxisSide; use hephaestus::shape::ShapeRegistry; use hephaestus::Renderer; +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; @@ -118,6 +121,24 @@ impl Writer for HephaestusWriter { } } + // A spatial layer positions marks by geometry, not by `pos1`/`pos2` + // columns, so ggsql resolves no position scales for it. Register + // continuous `pos1`/`pos2` scales spanning the map's bounding box so + // `GeometryGeom`'s coordinates map into the panel. The bbox comes from + // ggsql (`computed["bbox"]` when projected, else the geometry extent), + // keeping the "writer never invents extents" principle. + let spatial_bbox = spatial_bbox(spec, data)?; + if let Some((xmin, ymin, xmax, ymax)) = spatial_bbox { + view.insert_scale( + "pos1".to_string(), + scale::continuous(nice_range(xmin, xmax)), + ); + view.insert_scale( + "pos2".to_string(), + scale::continuous(nice_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 @@ -186,6 +207,16 @@ impl Writer for HephaestusWriter { // Axes are created per coordinate system, edge-only for fixed scales. plot = apply_projection(plot, spec, panel, &ps); + // Lock a spatial panel's aspect to its bounding box so the projected + // geometry keeps its proportions (a globe stays round), the raster + // analog of the Vega-Lite writer's uniform projection scale. + if let Some((xmin, ymin, xmax, ymax)) = spatial_bbox { + let (w, h) = (xmax - xmin, ymax - ymin); + if w > 0.0 && h > 0.0 { + plot = plot.aspect_ratio(h / w).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()); @@ -219,6 +250,67 @@ impl Writer for HephaestusWriter { } } +/// The map bounding box `(xmin, ymin, xmax, ymax)` when the plot has a spatial +/// layer, else `None`. Prefers ggsql's resolved `computed["bbox"]` (set under a +/// `PROJECT map`); falls back to the union extent of the geometry data for a +/// bare `spatial` geom with no projection. +fn spatial_bbox( + spec: &Plot, + data: &HashMap, +) -> Result> { + let is_spatial = |layer: &Layer| layer.geom.geom_type() == GeomType::Spatial; + if !spec.layers.iter().any(is_spatial) { + return Ok(None); + } + + 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 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 layer in spec.layers.iter().filter(|l| is_spatial(l)) { + let df = layer_dataframe(layer, 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 continuous position scale, widening a +/// zero-width or inverted extent so the scale can map it. +fn nice_range(min: f64, max: f64) -> std::ops::RangeInclusive { + if max - min > f64::EPSILON { + min..=max + } else { + (min - 0.5)..=(max + 0.5) + } +} + /// Look up the DataFrame backing a layer by its execution-assigned data key. fn layer_dataframe<'a>( layer: &Layer, @@ -598,6 +690,44 @@ mod tests { )); } + #[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", + )); + } + #[test] fn rejects_unsupported_geom() { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); diff --git a/src/writer/hephaestus/projection.rs b/src/writer/hephaestus/projection.rs index ef6539673..90157130f 100644 --- a/src/writer/hephaestus/projection.rs +++ b/src/writer/hephaestus/projection.rs @@ -3,11 +3,12 @@ //! `PROJECT`) gets bottom/left rails; polar gets angular + radial rings. use hephaestus::plot::chrome::axis::{Axis, AxisPlacement, PolarRing}; -use hephaestus::plot::projection::{PolarProjection, Projection as HProj}; +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}; @@ -132,6 +133,37 @@ fn apply_proj_polar(mut plot: HPlot, proj: &Projection, spec: &Plot, ps: &PanelS plot } -fn apply_proj_map(plot: HPlot, _proj: &Projection) -> HPlot { +/// 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 `HephaestusWriter::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)); + } + plot = plot.projection(HProj::Custom(custom)).clip(true); + } plot } diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index 8a5b712a3..0dd47b2c7 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -200,8 +200,10 @@ fn wire_positions( /// 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. -fn wire_material( +/// 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, From 2eda04bd1c966f120e7083fd11d48787a485881f Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Tue, 4 Aug 2026 22:09:15 +0200 Subject: [PATCH 09/29] A bunch of small fixes --- CHANGELOG.md | 32 +++ Cargo.lock | 20 +- doc/syntax/clause/label.qmd | 2 +- ggsql-cli/src/main.rs | 6 +- src/CLAUDE.md | 8 +- src/Cargo.toml | 2 +- src/writer/hephaestus/PLAN.md | 294 +++++++++++++++++++++--- src/writer/hephaestus/facet.rs | 216 ++++++++++++++---- src/writer/hephaestus/geom/boxplot.rs | 50 +++- src/writer/hephaestus/geom/text.rs | 22 +- src/writer/hephaestus/geom/violin.rs | 47 +++- src/writer/hephaestus/mod.rs | 314 ++++++++++++++++++++++++++ src/writer/hephaestus/scales.rs | 275 ++++++++++++++++++++-- src/writer/hephaestus/wiring.rs | 242 ++++++++++++++++---- 14 files changed, 1362 insertions(+), 168 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9eb1a45c..2d8afba3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ ## [Unreleased] +### Added + +- New `HephaestusWriter` renders a plot to a PNG raster image via + [hephaestus](https://github.com/posit-dev/hephaestus), behind a new + off-by-default `hephaestus` feature (`--writer hephaestus` in the CLI). Covers + every layer type except `arrow`, all scale types and transforms, multi-layer + plots, `FACET` (Wrap/Grid, fixed and free scales), Cartesian/polar/map + projections, spatial geometry, and plot chrome (axes, legends, facet strips, + title/subtitle/caption). Requires a working GPU adapter — hardware or software, + e.g. lavapipe — at render time. +- `LABEL caption => '...'` now renders. It has no Vega-Lite equivalent and is + ignored by that writer, but the hephaestus writer places it below the plot. +- `LABEL title`/`subtitle` are rendered by the hephaestus writer, spanning the + whole figure for faceted plots. + +### Fixed + +- The hephaestus writer's `size`, `shape` and `linetype` legends drew empty + swatches next to their labels; the key glyphs are now painted (in the layer's + constant color, or a neutral grey when the color aesthetic is itself mapped). + +### Changed + +- The hephaestus writer draws a boxplot's median line at the layer's resolved + `linewidth` (default 1.0) instead of a fixed 1.5, matching the Vega-Lite + writer's median tick. `linewidth` and `linetype` now style every part of a + `boxplot` (box, whiskers, median, outlier markers) and both edges of a + `violin`, so `SETTING linewidth => 3, linetype => 'dashed'` is honored. +- The hephaestus writer labels binned facet strips with the bin's range + (`2500 – 3500`, or `≥ 5500` for a squished terminal bin) instead of the raw bin + midpoint, and applies `RENAMING` to discrete facet strips. + ## 0.4.1 - 2026-06-22 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 176364619..9f254f406 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,7 +43,7 @@ dependencies = [ "regex", "toml", "windows-registry", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -149,7 +149,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -160,7 +160,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1868,7 +1868,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2498,7 +2498,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hephaestus" version = "0.0.1" -source = "git+https://github.com/posit-dev/hephaestus.git?rev=d343138c5d9c5d33e1b225b7bfb5b1c6ffc2bcbf#d343138c5d9c5d33e1b225b7bfb5b1c6ffc2bcbf" +source = "git+https://github.com/posit-dev/hephaestus.git?rev=a90dc84b769e5723c4ca675bd9b439063cdc501a#a90dc84b769e5723c4ca675bd9b439063cdc501a" dependencies = [ "bytemuck", "clipper2-rust", @@ -3286,7 +3286,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4390,7 +4390,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4728,7 +4728,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4938,7 +4938,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5976,7 +5976,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/doc/syntax/clause/label.qmd b/doc/syntax/clause/label.qmd index c6b89e5a0..74155a2c8 100644 --- a/doc/syntax/clause/label.qmd +++ b/doc/syntax/clause/label.qmd @@ -17,7 +17,7 @@ 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 hephaestus (raster) writer renders it. ## Automatic labelling logic Axes and legends get an automatic label from the mapping. The logic is as follows: diff --git a/ggsql-cli/src/main.rs b/ggsql-cli/src/main.rs index a63ad59b8..be3b10346 100644 --- a/ggsql-cli/src/main.rs +++ b/ggsql-cli/src/main.rs @@ -47,7 +47,8 @@ pub enum Commands { #[arg(long, default_value = "duckdb://memory")] reader: String, - /// Output format (vegalite) + /// Output format: vegalite (JSON), or hephaestus (PNG; requires the + /// `hephaestus` feature and a GPU adapter) #[arg(long, default_value = "vegalite")] writer: String, @@ -69,7 +70,8 @@ pub enum Commands { #[arg(long, default_value = "duckdb://memory")] reader: String, - /// Output format (vegalite) + /// Output format: vegalite (JSON), or hephaestus (PNG; requires the + /// `hephaestus` feature and a GPU adapter) #[arg(long, default_value = "vegalite")] writer: String, diff --git a/src/CLAUDE.md b/src/CLAUDE.md index b9b68dc91..12c6c483f 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). Two implementations: + +- **Vega-Lite** (`vegalite` feature, default) — emits Vega-Lite JSON. Deep-dive: [`writer/vegalite/CLAUDE.md`](writer/vegalite/CLAUDE.md). +- **hephaestus** (`hephaestus` feature, non-default) — renders PNG bytes via a GPU (wgpu/vello) backend. Design + phase log: [`writer/hephaestus/PLAN.md`](writer/hephaestus/PLAN.md), whose §9 lists deferred work. 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 | +| `hephaestus` | — | 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 24f71ea6a..ffd74d820 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -40,7 +40,7 @@ adbc_core = { version = "0.23", optional = true } geozero = { workspace = true, optional = true, features = ["with-wkb", "with-wkt", "with-geojson"] } # Hephaestus raster writer (non-default; gated, excluded from the MSRV 1.86 build) -hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "d343138c5d9c5d33e1b225b7bfb5b1c6ffc2bcbf", optional = true, default-features = false, features = ["vello", "png", "text", "geom-wkb", "geom-wkt"] } +hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "a90dc84b769e5723c4ca675bd9b439063cdc501a", optional = true, default-features = false, features = ["vello", "png", "text", "geom-wkb", "geom-wkt"] } png = { version = "0.18", optional = true } # Serialization diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index ed26eff8c..6b114c662 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -6,8 +6,11 @@ backend-agnostic 2D scene renderer with a high-level grammar-of-graphics plot API. Intended as the eventual default writer, replacing the Vega-Lite JSON writer. -Status: **planning**. No code written yet. This document is the design of -record; update it as decisions land. +Status: **implemented** behind the non-default `hephaestus` feature — all geoms +except `arrow`, all scale types, multi-layer, faceting (fixed + free), Cartesian +/ Polar / Map projections, spatial, and plot chrome (titles, axes, legends, +strips). This document is the design of record and the phase log; §9 lists the +work deliberately deferred. ## 1. Why this is a good fit @@ -345,9 +348,8 @@ geoms sharing the `pos1`/`pos2` scales. categories); all eyeballed. 16 writer tests pass; default + 1.86 builds, fmt, clippy clean. -Known limitations: boxplot stroke/linewidth/linetype/opacity are constant (only -`fill` maps); outliers/edge points clip at the panel boundary (standing -no-expansion gap). Composite geoms remain single-panel/Cartesian. +Known limitations: outliers/edge points clip at the panel boundary (standing +no-expansion gap). ## Gap-closing — status: implemented @@ -430,15 +432,15 @@ Closing the audit gaps in implemented geoms, most-visible first. grouped-line/dodged-bar legends, expanded point plot eyeballed; 23 writer tests; default + 1.86 builds, fmt, clippy clean. -Remaining geom gaps: text `stroke` (no hephaestus text-outline channel — upstream -deficiency); calendar-native temporal axes (numeric axes with ggsql's formatted -break labels work today; date-native ticks are a larger follow-up, not niche). +Remaining geom gap: calendar-native temporal axes (numeric axes with ggsql's +formatted break labels work today; date-native ticks are a larger follow-up, not +niche). ## Multi-layer — status: implemented The writer renders **N layers** into one shared panel (`validate` allows ≥1 -layer; FACET still rejected, non-Cartesian projections now supported — see -below). `write` loops over `spec.layers`, building each layer's geom into one +layer; non-Cartesian projections supported — see below; faceting landed in the +Phase 4 section). `write` loops over `spec.layers`, building each layer's geom into one `HPlot`; geoms draw in DRAW order (= z-order). The enabling change is a principle correction: **the writer never computes its @@ -469,7 +471,8 @@ writer too. Projections (Phase 5): `projection.rs` dispatches on `CoordKind` — Cartesian (clip/aspect-ratio + rails), Polar (`HProj::Polar` with start/end/inner + angular/ -radial rings), Map (stub). Polar renders **truthfully**: pos1→radius, pos2→theta +radial rings), and Map (implemented in the Phase 5b section below). Polar renders +**truthfully**: pos1→radius, pos2→theta (matching the VL writer), so a stacked bar becomes a correct pie/donut with the right slice proportions, fills, and angular axis. `start`/`end` are degrees clockwise from 12 o'clock (`end` defaults to `start + 360°`, so setting only @@ -495,12 +498,10 @@ Minor cosmetic (deferred): a plain pie shows a tiny centre hole because the dumm bar occupies a `width` band (radius ~0.05–0.95) rather than the full 0–1 radius; proportions/angles are unaffected. -Known issue (deferred): `color AS ` maps **both** fill and stroke to the -variable → two separate scales (`fill`, `stroke`) whose legends no longer merge -(different `domain_scale`), so a filled-dot legend and a hollow-dot legend both -show. The old `Wiring.shared_scales` collapsed these; recovering it needs either -a hephaestus legend-merge across scales with equal domains, or reintroducing a -small shared-source map. +Resolved since: `color AS ` maps **both** fill and stroke to the variable → +two separate scales (`fill`, `stroke`) whose legends used to render twice. This is +now collapsed upstream by hephaestus's `collapse_legends` (see the Faceting +section) — no writer-side shared-source map needed. **Confirmed shared with the VL writer (not a regression here):** ggsql's range expansion runs in linear data space then clips to the transform's valid domain, @@ -586,16 +587,7 @@ scales). Works under Cartesian, Polar, and Map projections. needed beyond registering both legends (which it already does) with the default `merge` flag on. -Known limitations (refinements, for upstream / later): -- **No title/subtitle/caption wired.** This writer doesn't render plot - title/subtitle/caption yet (true for the single-panel case too). hephaestus - exposes `PlotComposition::{title,subtitle,caption,axis_title}` for the - composition-spanning case, so this is now a straightforward follow-up — read the - ggsql `Labels` and set them on the composition. -- **Strip label formatting** uses the facet value's string form (correct for - discrete). Binned range labels and discrete RENAMING parity with the VL - writer's `build_*_facet_label_expr` are a refinement. -- Free binned dimensions fall back to a plain continuous per-panel scale. +Known limitations (refinements, for upstream / later): see §9. ## Spatial / Map (Phase 5b) — status: implemented @@ -657,6 +649,128 @@ writer only decodes and frames — no data is recomputed. Mirrors the VL Known limitations (deferred, matching VL/PLAN precedent): - No writer-side reprojection (by design — ggsql projects in SQL). +## Chrome + composite polish — status: implemented + +Closing the named feature gaps left by the phases above: plot titles, text +outlines, composite outline styling, and facet strip labelling. + +- **Plot title / subtitle / caption** (`mod.rs`, `wiring::plot_label`): read from + the `LABEL` clause and set on the **`PlotComposition`**, not the per-panel plots + — one label spans the whole figure, and the unfaceted 1×1 case needs no branch + (a plot-level title would resolve to the same layout row and be painted over). + `Some(None)` (`LABEL title => NULL`) suppresses; literal `\n` becomes a real + newline, mirroring the VL writer's `split_label_on_newlines`. `caption` is new + capability — the VL writer never implemented it (`doc/syntax/clause/label.qmd` + updated accordingly). +- **Text outlines** (`geom/text.rs`): ggsql's `stroke` → hephaestus + `"text_stroke"`, via the new `wiring::resolve_optional_color` so an unmapped + `stroke` (the ggsql default for text is `Null`) leaves the channel unset and + draws no outline. Outline *width* is hephaestus's theme default (1pt): ggsql's + text geom has no `linewidth` aesthetic and neither does VL's text mark, so this + is exact parity. +- **`MaterialSource`** (`wiring.rs`) generalizes the old `ColorSource` over + `RangeKind::{Color, Number, Linetype}`, dispatching the same three ways as + `wire_material` (Literal / data-mapped column + scale + legend / identity-or- + annotation raw) but returning a value that can be applied to a **row subset** — + which whole-column `wire_material` can't do, and composites need. + `resolve_color` is now a thin wrapper over it. +- **boxplot / violin outline styling**: `linewidth` and `linetype` are resolved + once per layer and applied to every component (box rect, whiskers, median; + both ribbon edges via the `2`-suffixed far-edge channels), matching the VL + writer, which puts `strokeWidth`/`strokeDash` in the boxplot's *shared* + encoding. The median's hardcoded `linewidth = 1.5` is gone — VL's median tick + carries no explicit thickness, so the resolved `linewidth` (default 1.0) is + parity. `PointGeom` has no dash channel, so outliers take width only. + `opacity` stays retargeted to the box's fill, mirroring VL's `opacity` → + `fillOpacity` for a fill-bearing geom. +- **Facet strip labels** (`facet.rs`) now match the VL writer. `ordered_levels` + returns a `Level { key, value, is_null, label }`: `key` is the arrow-cast string + that selects the panel's rows (so `Panel.facet1`/`facet2` and `panel_dataframe` + are unchanged and self-consistent), `label` is the strip text. + - *Discrete*: `label_mapping` (`RENAMING`) applied per + `build_indexed_facet_label_expr` — renamed / suppressed → empty strip / + absent → raw value. A NULL level keys as the literal `"null"`, so + `RENAMING null => 'The rest'` works. The domain element is matched by + data-space string first, then numerically, because `label_mapping` is keyed on + `to_key_string()` (`"5"`) which can differ from the column's cast text (`"5.0"`). + - *Binned*: the facet column carries the **bin centre**, so + `scales::{binned_bins, bin_at_centre}` join it back to its bin and label it + with the bin's range — `"lower – upper"` (en dash), per-edge `RENAMING` + overrides, and `"< upper"` / `"≥ lower"` (or `≤` / `>` when + `closed => 'right'`) for a terminal bin whose outer edge label is suppressed + by `oob => 'squish'`. Verified byte-identical to the VL writer's `labelExpr` + for the same queries. The join is numeric (`column_to_f64`, whose arrow cast + bridges `Date32`/`Timestamp` in exactly `ArrayElement::to_f64`'s units), which + is why the temporal case works here while **VL silently fails it** (its + midpoint-string comparison never matches the serialized form). + - Ordering mirrors VL: a binned facet sorts by bin centre (VL's + `resolve_facet_ordering` early-returns for Binned), everything else by + `input_range` then numeric-aware ascending, then `reverse`. + - A suppressed label is `Some("")`, not `None`, so hephaestus still reserves the + strip slot and sibling panels stay aligned. +- **Free binned facet dimensions** (`scales::free_binned_scale`): a free binned + dimension now gets a real `scale::binned` per panel instead of degrading to + continuous. It keeps **ggsql's** global bin edges, narrowed to the window of bins + the panel's data occupies, labelled with ggsql's own edge labels for that window. + Edges and domain narrow *together* because a hephaestus binned scale keeps its + edges in the output range and derives band width as `1/(edges-1)` — shrinking only + the domain would leave every bar a global bin-width wide, hanging off the panel. +- **Binned axis ticks stay hephaestus's job.** `apply_breaks` hands a binned scale + ggsql's break **edges** with ggsql's edge labels — composite `"lower – upper"` + range labels belong to keyed legends and facet strips, not axes (the same split + the VL writer makes). Placing an edge break correctly is hephaestus's + responsibility, and it now does: `486390b` added `Scale::map_break`, which + positions a binned break linearly in the domain instead of through `binned_map` + (which still sends *data* to bin centres, as it should). Edge labels now sit on + their boundaries with no collision. +- Verified: 62 writer tests (10 exact-text `facet_strips_*` assertions that need no + GPU, 5 `binned_bins`/`bin_at_centre` unit tests, and `renders_*` smoke tests for + titles, text stroke, composite widths/dashes, binned + free-binned facets); + eyeballed titles (1×1 and faceted), red/white text outlines, dashed+thick + boxplot and violin, binned facet strips (`2500 – 3500`, plus a `null` panel), + free binned panels, and the collision-free fixed binned axis. fmt + clippy clean; + feature, default (hephaestus absent) and `cargo +1.86` builds all pass. + +**hephaestus dep bumped** to rev `9c2462e`, which fixes two bugs this work +surfaced (both reported upstream with standalone repros, both verified here): + +1. `486390b` — a binned scale's breaks (its bin **edges**) were positioned through + `binned_map`, so they rendered at bin *centres* and edges sharing a bin collided + (for edges `2500…6500` the axis showed `2500 3500 4500 6500`, losing `5500`). + Fixed by the new `Scale::map_break`, used by axis chrome. +2. `9c2462e` — a `Binned` scale kept its bin edges in the **output range**, so + attaching a palette destroyed them: `range_colors` made every `map` return + `Null` (marks silently vanished — `SCALE BINNED color` drew an empty panel) and + `range_numbers` silently reinterpreted the palette as the edge list. Fixed by + giving bins their own field (`Scale::{with_bins, set_bins, bins}`), so a binned + scale is now usable as a material scale, its palette indexed by bin. + +3. `a90dc84` — `collapse_legends` only merged `LegendBody::Stack` bodies, so two + colorbars never collapsed. ggsql maps `color AS ` onto both `fill` and + `stroke`, so every continuous *or* binned color scale drew **two identical + colorbars**. Now merged when both bodies paint the same gradient + (`colorbar_gradients_agree` + `Scale::visual_equivalent_to`); genuinely distinct + legends (e.g. a `color` colorbar beside a `size` legend) still stay separate. + No writer change needed — collapse runs at render. + +- **Non-color legend keys paint.** A hephaestus legend key only draws what it is + told to draw, so `LegendKeySpec::point().scaled("size", …)` with no color + rendered *empty swatches* beside correct labels — every `size` / `shape` / + `linetype` legend was blank (found while verifying the colorbar merge; it long + predated it). `material_legend` now adds `.fixed("fill", …)` (`"stroke"` for a + line key) whenever the scaled channel isn't itself a color, using + `wiring::key_color`: the layer's constant `fill` (or `stroke`), else a neutral + grey — mirroring hephaestus's own `examples/legends.rs`. + The subtlety worth remembering: a *data-mapped* color aesthetic must be skipped, + not read. Its column holds domain values, and `column_to_colors` maps an + unparseable cell to `Color::BLACK`… except a continuous fill column's first row + parsed as a **transparent** color (alpha 0), so the key stayed invisible in + exactly the colorbar-plus-size case. `wiring::is_data_mapped` (extracted from + `resolve_material`, now shared by both) is the guard. + +Newly found, **not** fixed here (see §9): a data-mapped `linewidth` on a +boxplot/violin is rejected by ggsql itself. + ## 8. Key source references ggsql: @@ -678,7 +792,125 @@ hephaestus (`~/GitHub/hephaestus`): - `src/plot/projection.rs` — `Projection::{Cartesian, Polar, Custom}`. - `src/composition/` — `Composition`, `Patch`, `grid`/`beside`/`stack`, `Slot`. - `src/backend/vello/` — `VelloRenderer`; `src/png.rs` — PNG writer. - - - -[{"question":"How should ggsql depend on hephaestus while both are pre-1.0?","header":"Dependency mode","multiSelect":false,"options":[{"label":"Path dep (../hephaestus)","description":"Easiest for co-development; both crates evolve together locally. Not reproducible on CI/other machines without the sibling checkout."},{"label":"Pinned git rev","description":"Reproducible everywhere, CI-friendly. Requires bumping the rev to pick up hephaestus changes."}]},{"question":"What should the writer's Output type be?","header":"Output type","multiSelect":false,"options":[{"label":"PNG bytes only (Vec)","description":"Simplest; matches the existing text/bytes Writer pattern. Callers decode if they need pixels."},{"label":"Also expose raw RGBA8","description":"A RasterImage { width, height, rgba } struct so wasm/embedders can present without re-decoding a PNG."}]},{"question":"What's the right first deliverable to build after this plan?","header":"First milestone","multiSelect":false,"options":[{"label":"Phase 1 spike only","description":"Single point layer, Cartesian, fixed scale → PNG. Proves the dep/MSRV/GPU path before investing further."},{"label":"Through Phase 3","description":"Full single-panel coverage: all scales, axes, legends, and all geoms. Bigger first chunk, no faceting/projections yet."}]},{"question":"What's the ambition for this effort?","header":"Ambition","multiSelect":false,"options":[{"label":"Land gated alternative, promote later","description":"Ship behind the hephaestus feature, iterate to parity over time, flip the default in a later effort."},{"label":"Drive to parity + flip default","description":"Treat full Vega-Lite parity and becoming the default writer as the goal of this effort."}]}] + +## 9. Deferred + +Everything known to be missing or wrong, deliberately not being worked on. Kept +here so it survives between efforts. + +### Release plumbing (blocks publishing) + +- **`hephaestus` is a pinned git dep** (`src/Cargo.toml`) on an unpublished + `0.0.1` crate. crates.io rejects git dependencies **even when optional**, so + ggsql cannot be published while this dep exists in that form. This is the one + item that blocks a release rather than polish. +- No `src/writer/hephaestus/CLAUDE.md` (planned in §5). This document is doing + that job and is a phase log, not an architecture doc. +- `src/CLAUDE.md` is stale: no `hephaestus` row in the feature table, and the + `writer/` section still says "Only Vega-Lite is implemented today". +- CLI: `--writer` help text only advertises `vegalite`; no output-extension + routing; no flags for width/height/dpi/background, so only one hardcoded size + is reachable. +- `doc/` doesn't mention raster output at all. +- Default-writer switchover criteria still undecided (Decision 4). + +### Correctness risks + +- **Transparent backgrounds are probably wrong.** `HephaestusWriter::background()` + accepts any color, but `render_to_buffer` returns **premultiplied** RGBA and the + PNG encode path has no un-premultiply step. +- **Legends are captured from the first panel only**, assuming every panel yields + identical legends. True under fixed scales; unverified for a free-scale facet + that also maps a material aesthetic. +- **Log scales get no domain expansion** — under a non-identity transform the + writer deliberately falls back to the raw data extent (see the ggsql-core item + below). +- **No axis label thinning or rotation.** hephaestus's `Axis` is + `rail(scale, placement)` + `title` only, with ticks coming solely from the + scale, so long tick labels overlap in narrow facet panels (visible with binned + range labels, and equally with long categorical labels). + +### Feature gaps + +- `arrow` geom — the only unsupported `GeomType` (deliberate). +- Theming: ggsql has no theme concept; the writer uses hephaestus's default and + exposes no selection. +- Calendar-native temporal axes (numeric axes with ggsql's formatted break labels + work today). +- Diagonal rules ignore `linetype` (`geom/segment.rs::build_diagonal` sets only + stroke/linewidth/opacity). +- **`densified` segment/ribbon under `PROJECT`**: ggsql expands a projected + segment into per-vertex rows and remaps `pos1end`→`pos1`, which the VL writer + handles by switching to a `line` mark (`vegalite/layer.rs:1589-1624`). The + hephaestus path would draw zero-length segments; `area`/`ribbon` have the same + hole. +- boxplot `side` and `hinge` parameters are unimplemented (the VL writer has both). +- A `linewidth` aesthetic on ggsql's Text geom would let text outline width be set + (a core + doc change; the outline itself works). + +### Architectural debt — writer doing work ggsql should own + +The principle is "ggsql owns all scale domains; the writer never computes +extents". Two scoped exceptions remain, both of which would disappear if ggsql +resolved per-panel domains and spatial position scales: + +- **Free facet scales**: `scales::{free_position_scale, free_binned_scale}` compute + per-panel domains (and select the per-panel bin window). +- **Spatial `pos1`/`pos2`**: synthesized in `mod.rs` from `computed["bbox"]` (or + the geometry extent) because ggsql resolves no position scales for a spatial + layer. + +### Upstream ggsql-core (each also fixes the Vega-Lite writer) + +- **Range expansion runs in linear data space then clips** to the transform's + valid domain, so a log domain collapses to `[f64::MIN_POSITIVE, max]` and its + breaks explode. Fix: expand in transform space. +- **`bar` on a numeric primary axis stays continuous** (no `pos1end`), so + band-fraction bars get no width; VL hits the same wall (`bandwidth('x')` is 0). +- **A data-mapped `linewidth` on a boxplot/violin is rejected by ggsql**: the stat + drops the column, so `linewidth AS w` fails validation for *both* writers + ("Column `linewidth` … does not exist"). Grouping aesthetics (fill/stroke) + survive; scalar ones don't. +- `Scale::break_labels()` misses `label_mapping` for numeric discrete/ordinal + domains (`to_json()` `"5.0"` vs `to_key_string()` `"5"`). +- VL's `build_discrete_facet_label_expr` is unreachable dead code and iterates a + `HashMap` nondeterministically — deletion candidate. +- VL's DateTime/Time binned facet strips are broken (its midpoint-string + comparison never matches the serialized data); the hephaestus writer computes + these from typed values and is correct. + +### Upstream hephaestus + +- `png::write_png` is file-only — no in-memory encode, so every host + re-implements byte encoding. +- `render_to_buffer` returns premultiplied RGBA (see Correctness risks). +- No scale-level domain expansion / "nice" padding. +- Binned scales keep bin edges in the output range, so they can't also carry a + color/size range (see the binned-material bug above). +- Range labels (`"lower – upper"`) would be the right presentation for a binned + scale driving a **keyed** legend (size / shape), where the writer currently passes + ggsql's edge labels. Not urgent: binned color renders as a colorbar, where edge + labels on the band boundaries are correct. +- Chrome text (titles, axis labels, strip labels) can't be outlined: + `TextElement` has no stroke field; `text_stroke` is a geom channel only. +- The `text` feature's parley shaper is documented as scaffolding "meant to be + replaced by the host". +- `src/scales/` docs still claim transforms are Identity-only — stale. + +### Standing constraints (accepted) + +- **Raster only.** `vello`/wgpu is the sole working backend; `svg`/`pdf`/`blend2d` + are declared placeholders. No vector output. +- **Needs a GPU adapter at render time.** CI installs lavapipe; this operational + footgun isn't documented anywhere a user would find it. +- **MSRV split** (hephaestus 1.88 vs ggsql's CRAN-locked 1.86) — handled by + gating, but it means this writer is not viable for the R/CRAN target and is not + the wasm default. +- hephaestus is pre-1.0; the pinned rev needs periodic bumping. + +### Testing + +The writer's tests are render-succeeds smoke tests plus exact-text assertions for +strip labels and bin labelling, backed by manual eyeballing. §6 planned +**snapshot PNG tests** and they don't exist — there is no automated protection +against visual regression, which matters with a moving pinned rev. diff --git a/src/writer/hephaestus/facet.rs b/src/writer/hephaestus/facet.rs index f80606ebc..c177f18e4 100644 --- a/src/writer/hephaestus/facet.rs +++ b/src/writer/hephaestus/facet.rs @@ -11,14 +11,15 @@ //! 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::UInt32Array; +use arrow::array::{Array, UInt32Array}; use hephaestus::composition::{grid, spacer, Composition, Element, Patch}; -use super::channels::column_to_strings; +use super::channels::{column_to_f64, column_to_strings}; use crate::naming; -use crate::plot::{ArrayElement, FacetLayout, ParameterValue, Scale}; +use crate::plot::{ArrayElement, FacetLayout, ParameterValue, Scale, ScaleTypeKind}; use crate::{DataFrame, Plot, Result}; /// Patch id for the single (unfaceted) panel. @@ -99,9 +100,9 @@ fn build_wrap( panels.push(Panel { id: format!("facet_{idx}"), index: idx, - facet1: Some(level.clone()), + facet1: Some(level.key.clone()), facet2: None, - strip_top: Some(level.clone()), + strip_top: Some(level.label.clone()), strip_right: None, first_col: col == 0, last_row, @@ -137,10 +138,10 @@ fn build_grid(spec: &Plot, layer0: &DataFrame) -> Result<(Composition, Vec usize { } } -/// Distinct facet levels present in the data, ordered per the facet scale. -fn ordered_levels(spec: &Plot, df: &DataFrame, internal_aes: &str) -> Result> { +/// One distinct facet level: the data-space key selecting its rows, the numeric +/// form of the same cell (the bin-join key for a binned facet), whether the cell +/// was NULL, and the strip text to display. +struct Level { + key: String, + value: f64, + is_null: bool, + label: String, +} + +/// 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 values = column_to_strings(df, &col)?; + let keys = column_to_strings(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()]); + // `column_to_strings` renders NULL as "", indistinguishable from a genuine + // empty category, so read the null bitmap directly. + let array = df.column(&col)?; + let mut seen = HashSet::new(); - let mut distinct: Vec = Vec::new(); - for v in values { - if seen.insert(v.clone()) { - distinct.push(v); + 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], + is_null: array.is_null(i), + label: String::new(), + }); } } - Ok(order_by_scale(distinct, spec.find_scale(internal_aes))) + + 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 values by the scale's `input_range` (then any -/// present-but-unlisted values, sorted), or a numeric-aware ascending sort when -/// there is no scale/range. Reversed when the scale sets `reverse => true`. -fn order_by_scale(mut distinct: Vec, scale: Option<&Scale>) -> Vec { +/// 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 = scale .map(|s| { matches!( @@ -187,25 +219,34 @@ fn order_by_scale(mut distinct: Vec, scale: Option<&Scale>) -> Vec { - let order: Vec = range.iter().map(element_to_string).collect(); - let mut ranked: Vec = order - .iter() - .filter(|o| distinct.contains(o)) - .cloned() - .collect(); - let mut extra: Vec = distinct - .into_iter() - .filter(|d| !order.contains(d)) - .collect(); - sort_values(&mut extra); - ranked.extend(extra); - ranked - } - None => { - sort_values(&mut distinct); - distinct + 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_string).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 { @@ -214,18 +255,95 @@ fn order_by_scale(mut distinct: Vec, scale: Option<&Scale>) -> Vec().is_ok()) { - values.sort_by(|a, b| { - a.parse::() - .unwrap() - .partial_cmp(&b.parse::().unwrap()) - .unwrap_or(std::cmp::Ordering::Equal) +fn sort_levels(levels: &mut [Level]) { + if levels.iter().all(|l| l.key.parse::().is_ok()) { + levels.sort_by(|a, b| { + let (a, b) = (a.key.parse::().unwrap(), b.key.parse::().unwrap()); + a.partial_cmp(&b).unwrap_or(Ordering::Equal) }); } else { - values.sort(); + levels.sort_by(|a, b| a.key.cmp(&b.key)); + } +} + +/// 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.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.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.clone(); + }; + let Some(mapping) = scale.label_mapping.as_ref() else { + return level.key.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.clone()); + match mapping.get(&key) { + Some(Some(label)) => label.clone(), + Some(None) => String::new(), + None => level.key.clone(), + } +} + +/// Whether a domain element denotes the same value as this level: by data-space +/// string form 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_string(element) == level.key { + return true; + } + match element.to_f64() { + Some(n) => level.value.is_finite() && n == level.value, + None => false, } } diff --git a/src/writer/hephaestus/geom/boxplot.rs b/src/writer/hephaestus/geom/boxplot.rs index 7bc92bc44..a3cd3cb9a 100644 --- a/src/writer/hephaestus/geom/boxplot.rs +++ b/src/writer/hephaestus/geom/boxplot.rs @@ -5,14 +5,16 @@ //! and outliers (`PointGeom`). All components share the `pos1`/`pos2` scales. use hephaestus::color::rgb8; +use hephaestus::plot::geom::{BuildableGeom, GeomBuilder}; use hephaestus::plot::{Plot as HPlot, PointGeom, RectGeom, SegmentGeom}; use super::super::channels::{ aesthetic_column_name, column_to_channel, column_to_f64, column_to_strings, }; +use super::super::scales::RangeKind; use super::super::wiring::{ - band_half_width, constant_number, constant_string, dodge_offsets, resolve_color, Ctx, - LegendKind, + band_half_width, constant_number, constant_string, dodge_offsets, resolve_color, + resolve_material, Ctx, LegendKind, MaterialSource, }; use crate::{GgsqlError, Result}; @@ -67,6 +69,28 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { rgb8(60, 60, 60), LegendKind::Rect, )?; + // 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, + )?; + let linetype = resolve_material( + ctx, + plot, + "linetype", + "linetype", + RangeKind::Linetype, + LegendKind::Line, + )?; + // `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. let alpha = constant_number(ctx, "opacity", 1.0); // Box width (band fraction, dodge-aware) + per-row dodge offsets. let offsets = dodge_offsets(df, "pos1offset"); @@ -83,6 +107,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { b.set("x2_band", shift(&offsets, &box_i, half)); fill.apply(&mut b, "fill", &box_i); stroke.apply(&mut b, "stroke", &box_i); + outline(&mut b, &linewidth, linetype.as_ref(), &box_i); b.set("fill_opacity", alpha); plot.add_geom(b.build()); } @@ -97,6 +122,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { b.set("x_band", shift(&offsets, &whisk_i, 0.0)); b.set("x2_band", shift(&offsets, &whisk_i, 0.0)); stroke.apply(&mut b, "stroke", &whisk_i); + outline(&mut b, &linewidth, linetype.as_ref(), &whisk_i); plot.add_geom(b.build()); } @@ -110,7 +136,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { b.set("x_band", shift(&offsets, &med_i, -half)); b.set("x2_band", shift(&offsets, &med_i, half)); stroke.apply(&mut b, "stroke", &med_i); - b.set("linewidth", 1.5_f64); + outline(&mut b, &linewidth, linetype.as_ref(), &med_i); plot.add_geom(b.build()); } @@ -122,6 +148,8 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { b.set("y", pick(&p2, &out_i)); b.set("x_band", shift(&offsets, &out_i, 0.0)); stroke.apply(&mut b, "stroke", &out_i); + // `PointGeom` has no dash pattern — a marker outline can't be dashed. + outline(&mut b, &linewidth, None, &out_i); b.set("size", constant_number(ctx, "size", 3.0)); b.set("shape", constant_string(ctx, "shape", "circle")); plot.add_geom(b.build()); @@ -130,6 +158,22 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { 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, + linetype: Option<&MaterialSource>, + idx: &[usize], +) { + if let Some(lw) = linewidth { + lw.apply(b, "linewidth", idx); + } + if let Some(lt) = linetype { + lt.apply(b, "linetype", idx); + } +} + fn require<'a>(layer: &'a crate::Layer, aesthetic: &str) -> Result<&'a str> { aesthetic_column_name(layer, aesthetic) .ok_or_else(|| GgsqlError::WriterError(format!("boxplot layer has no {aesthetic} mapping"))) diff --git a/src/writer/hephaestus/geom/text.rs b/src/writer/hephaestus/geom/text.rs index 68932137b..da7e04953 100644 --- a/src/writer/hephaestus/geom/text.rs +++ b/src/writer/hephaestus/geom/text.rs @@ -12,7 +12,7 @@ use hephaestus::plot::{Plot as HPlot, TextGeom}; use super::super::channels::{ aesthetic_column_name, column_to_bool, column_to_channel, column_to_f64, column_to_strings, }; -use super::super::wiring::{resolve_color, Ctx, LegendKind}; +use super::super::wiring::{resolve_color, resolve_optional_color, Ctx, LegendKind}; use crate::{GgsqlError, Result}; pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { @@ -36,12 +36,22 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { // Label string. b.set("text", Raw(column_to_strings(df, label)?)); + let rows: Vec = (0..n).collect(); + // Color: data-mapped (color-by-group) or constant black. - resolve_color(ctx, plot, "fill", "fill", rgb8(0, 0, 0), LegendKind::Point)?.apply( - &mut b, - "fill", - &(0..n).collect::>(), - ); + resolve_color(ctx, plot, "fill", "fill", rgb8(0, 0, 0), LegendKind::Point)? + .apply(&mut b, "fill", &rows); + + // Glyph outline, drawn under the fill. Only set when `stroke` is actually + // mapped: ggsql's default for a text geom's `stroke` is Null, and hephaestus + // skips the outline pass entirely while `text_stroke` is unset. The outline + // width is hephaestus's theme default — ggsql's text geom has no `linewidth` + // aesthetic, and neither does the Vega-Lite writer's text mark. + if let Some(stroke) = + resolve_optional_color(ctx, plot, "stroke", "text_stroke", LegendKind::Point)? + { + stroke.apply(&mut b, "text_stroke", &rows); + } // Scalar styling (unscaled visual values). b.set("fill_opacity", Raw(numeric_or(ctx, "opacity", 1.0))); diff --git a/src/writer/hephaestus/geom/violin.rs b/src/writer/hephaestus/geom/violin.rs index 61d12ef3c..d3912bf0b 100644 --- a/src/writer/hephaestus/geom/violin.rs +++ b/src/writer/hephaestus/geom/violin.rs @@ -14,7 +14,10 @@ use hephaestus::plot::{Plot as HPlot, RibbonGeom}; use super::super::channels::{ aesthetic_column_name, column_to_channel, column_to_f64, column_to_strings, }; -use super::super::wiring::{constant_number, dodge_offsets, resolve_color, Ctx, LegendKind}; +use super::super::scales::RangeKind; +use super::super::wiring::{ + constant_number, dodge_offsets, resolve_color, resolve_material, Ctx, LegendKind, +}; use crate::{GgsqlError, Result}; pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { @@ -80,9 +83,33 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { rgb8(60, 60, 60), LegendKind::Rect, )?; - // The ribbon's two edges share the stroke scale (`stroke2` is the far edge). - if let Some(name) = stroke.scale_name() { - plot.set_binding("stroke2", name); + // Outline width + dash pattern, applied to both ribbon edges. + let linewidth = resolve_material( + ctx, + plot, + "linewidth", + "linewidth", + RangeKind::Number, + LegendKind::Line, + )?; + let linetype = resolve_material( + ctx, + plot, + "linetype", + "linetype", + RangeKind::Linetype, + LegendKind::Line, + )?; + // 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); + } } let mut b = RibbonGeom::builder(); @@ -95,6 +122,18 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { fill.apply(&mut b, "fill", &order); 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 { + source.apply(&mut b, channel, &order); + } + } + } b.set("alpha", constant_number(ctx, "opacity", 1.0)); plot.add_geom(b.build()); diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index ef301ef58..1f4c68b9b 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -101,6 +101,20 @@ impl Writer for HephaestusWriter { let mut view = PlotComposition::new(&composition).shape_registry(ShapeRegistry::with_builtins()); + // 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); + } + // 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 { @@ -373,6 +387,26 @@ mod tests { HephaestusWriter::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() + } + + /// 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 { @@ -413,6 +447,35 @@ mod tests { )); } + #[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", + )); + } + + #[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( @@ -728,6 +791,257 @@ mod tests { )); } + /// 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 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_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_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_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_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 rejects_unsupported_geom() { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); diff --git a/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs index 428b470ee..7d662e732 100644 --- a/src/writer/hephaestus/scales.rs +++ b/src/writer/hephaestus/scales.rs @@ -16,7 +16,7 @@ use hephaestus::scales::value::{LinetypeStep, Value as HValue}; use super::channels::{column_to_f64, column_to_strings}; use crate::naming; use crate::plot::scale::TransformKind as GTransform; -use crate::plot::{ArrayElement, OutputRange, Scale as GScale, ScaleTypeKind}; +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 @@ -96,11 +96,13 @@ pub fn build_scale(scale: Option<&GScale>, kind: RangeKind) -> Option { /// /// 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/binned 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. -/// ggsql's resolved breaks are for the global domain and don't fit a per-panel -/// one, so ticks are left to hephaestus. +/// 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. pub fn free_position_scale( global: Option<&GScale>, dfs: &[&DataFrame], @@ -127,16 +129,77 @@ pub fn free_position_scale( }) } ScaleTypeKind::Identity => Some(scale::identity()), - ScaleTypeKind::Continuous | ScaleTypeKind::Binned => { - let (min, max) = panel_extent(dfs, base)?; - let (min, max) = pad_degenerate(min, max); - let mut c = scale::continuous(min..=max); - if let Some(t) = transform.and_then(map_transform) { - c = c.with_transform(t); - } - Some(c) - } + 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(dfs, base, transform)), + ScaleTypeKind::Continuous => free_continuous_scale(dfs, base, transform), + } +} + +/// A per-panel continuous position scale over the panel's own data extent. +fn free_continuous_scale( + dfs: &[&DataFrame], + base: &str, + transform: Option, +) -> Option { + let (min, max) = panel_extent(dfs, base)?; + let (min, max) = pad_degenerate(min, max); + let mut c = scale::continuous(min..=max); + if let Some(t) = transform.and_then(map_transform) { + c = c.with_transform(t); } + Some(c) +} + +/// 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 keeps its edges in the output range +/// and derives band width 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. +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. @@ -244,6 +307,11 @@ fn apply_breaks(hs: HScale, scale: &GScale, type_kind: Option) -> .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 (see + // PLAN.md §9 — it currently maps break positions through `binned_map`, which + // sends every value to its bin's centre). Composite "lower – upper" range + // labels belong to keyed legends and facet strips, not axes. _ => hs.with_breaks_labeled( labels .into_iter() @@ -253,6 +321,96 @@ fn apply_breaks(hs: HScale, scale: &GScale, type_kind: Option) -> } } +/// 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, labelled exactly as the Vega-Lite +/// writer's `build_binned_facet_label_expr` does: `"lower – upper"` (en dash), +/// with per-edge `RENAMING` overrides, and the open-ended terminal forms implied +/// by the scale's `closed` side when a terminal edge label is suppressed (which +/// is what `oob => 'squish'` inserts). Empty when the scale has no resolved +/// break array. +pub fn binned_bins(scale: &GScale) -> Vec { + let Some(ParameterValue::Array(breaks)) = scale.properties.get("breaks") else { + return Vec::new(); + }; + if breaks.len() < 2 { + return Vec::new(); + } + let closed_right = matches!( + scale.properties.get("closed"), + Some(ParameterValue::String(s)) if s == "right" + ); + let mapping = scale.label_mapping.as_ref(); + let last = breaks.len() - 2; + + let mut bins = Vec::with_capacity(breaks.len() - 1); + for i in 0..=last { + let (lower, upper) = (&breaks[i], &breaks[i + 1]); + let (Some(lo), Some(hi)) = (lower.to_f64(), upper.to_f64()) else { + continue; + }; + let (lo_key, hi_key) = (lower.to_key_string(), upper.to_key_string()); + // A suppressed terminal edge (`oob => 'squish'`) means the bin is + // open-ended in that direction. + 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) { + format!( + "{} {}", + if closed_right { "≤" } else { "<" }, + label_of(&hi_key) + ) + } else if i == last && suppressed(&hi_key) { + format!( + "{} {}", + if closed_right { ">" } else { "≥" }, + label_of(&lo_key) + ) + } else { + format!("{} – {}", label_of(&lo_key), label_of(&hi_key)) + }; + bins.push(Bin { + lower: lo, + upper: hi, + centre: (lo + hi) / 2.0, + label, + }); + } + bins +} + +/// 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) +} + /// 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). @@ -317,3 +475,90 @@ fn pad_degenerate(min: f64, max: f64) -> (f64, f64) { } (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.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()); + } + + #[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); + } +} diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index 0dd47b2c7..df9da2d0b 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -4,7 +4,7 @@ use std::collections::HashSet; -use hephaestus::color::Color; +use hephaestus::color::{rgb8, Color}; use hephaestus::plot::chrome::legend::{Legend, LegendKeySpec}; use hephaestus::plot::geom::{BuildableGeom, Geom, GeomBuilder, Raw}; use hephaestus::plot::Plot as HPlot; @@ -246,6 +246,7 @@ pub fn wire_material( // records across layers for the same scale merge at registration. plot.set_binding(m.channel, m.aesthetic); ctx.push_legend(material_legend( + ctx, m.aesthetic, m.channel, m.kind, @@ -423,17 +424,27 @@ pub fn aesthetic_label(spec: &Plot, layer: &Layer, aesthetic: &str) -> Option => 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(Color), + Constant(HValue), } -impl ColorSource { +impl MaterialSource { /// Set `channel` for the `idx` rows: the scaled data subset, or the constant. pub fn apply( &self, @@ -442,9 +453,9 @@ impl ColorSource { idx: &[usize], ) { match self { - ColorSource::Data { data, .. } => data.select(idx).apply(builder, channel), - ColorSource::Constant(c) => { - builder.set(channel, *c); + MaterialSource::Data { data, .. } => data.select(idx).apply(builder, channel), + MaterialSource::Constant(v) => { + builder.set(channel, v.clone()); } } } @@ -453,18 +464,14 @@ impl ColorSource { /// e.g. a ribbon's far edge, to the same scale). pub fn scale_name(&self) -> Option<&str> { match self { - ColorSource::Data { scale, .. } => Some(scale), - ColorSource::Constant(_) => None, + MaterialSource::Data { scale, .. } => Some(scale), + MaterialSource::Constant(_) => None, } } } -/// Resolve a color aesthetic (`fill`, `stroke`, …) for a composite geom. A -/// data-mapped non-identity scale binds `channel` to the aesthetic's (globally -/// registered) scale and records a legend; the full color-domain column is returned -/// for components to select. Otherwise the constant value (the mapped literal, -/// else `default`). hephaestus collapses compatible legends, so repeated binds -/// across a geom's components merge at registration. +/// 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, @@ -472,37 +479,149 @@ pub fn resolve_color( channel: &'static str, default: Color, legend_kind: LegendKind, -) -> Result { +) -> Result { + Ok( + resolve_material(ctx, plot, aesthetic, channel, RangeKind::Color, legend_kind)? + .unwrap_or(MaterialSource::Constant(HValue::Color(default))), + ) +} + +/// Like [`resolve_color`] but with no fallback. For aesthetics whose ggsql +/// default is `Null` (e.g. a text geom's `stroke`), where "unmapped" must leave +/// the channel unset rather than substitute a color. +pub fn resolve_optional_color( + ctx: &Ctx, + plot: &mut HPlot, + aesthetic: &'static str, + channel: &'static str, + legend_kind: LegendKind, +) -> Result> { + resolve_material(ctx, plot, aesthetic, channel, RangeKind::Color, legend_kind) +} + +/// 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, +) -> Result> { + let type_kind = ctx + .spec + .find_scale(aesthetic) + .and_then(|s| s.scale_type.as_ref()) + .map(|st| st.scale_type_kind()); + 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, + type_kind, + aesthetic_label(ctx.spec, ctx.layer, aesthetic), + legend_kind, + )); + 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 kind = scale + 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 data_mapped = col.is_some() && scale.is_some() && kind != Some(ScaleTypeKind::Identity); - if !data_mapped { - return Ok(ColorSource::Constant(constant_color( - ctx, aesthetic, default, - ))); + 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 => { + 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::String(name.into())) + } } - plot.set_binding(channel, aesthetic); - ctx.push_legend(material_legend( - aesthetic, - channel, - RangeKind::Color, - kind, - aesthetic_label(ctx.spec, ctx.layer, aesthetic), - legend_kind, - )); - Ok(ColorSource::Data { - data: column_to_channel(ctx.df, col.unwrap())?, - scale: aesthetic.to_string(), - }) } /// 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. pub fn material_legend( + ctx: &Ctx, scale_name: &str, channel: &str, kind: RangeKind, @@ -518,17 +637,50 @@ pub fn material_legend( let mut legend = if continuous_color { Legend::colorbar(scale_name).side(LegendSide::Right) } else { - let key = match legend_kind { + let mut key = match legend_kind { LegendKind::Point => LegendKeySpec::point(), LegendKind::Line => LegendKeySpec::line(), LegendKind::Rect => LegendKeySpec::rect(), - }; - Legend::new(scale_name) - .side(LegendSide::Right) - .key(key.scaled(channel, scale_name)) + } + .scaled(channel, scale_name); + // A key only paints what it is told to paint, so when the scaled channel + // isn't itself a color the glyph needs one — otherwise the swatch is + // invisible next to its label. Use the layer's constant color, matching + // the marks the legend describes. + if kind != RangeKind::Color { + let body = match legend_kind { + LegendKind::Line => "stroke", + LegendKind::Point | LegendKind::Rect => "fill", + }; + key = key.fixed(body, HValue::Color(key_color(ctx, legend_kind))); + } + Legend::new(scale_name).side(LegendSide::Right).key(key) }; if let Some(title) = title { legend = legend.title(title); } legend } + +/// The color a non-color legend key paints its glyph with: the layer's constant +/// color for the aesthetic carrying the glyph's body, the other color aesthetic +/// as a fallback (a stroke-only geom has no fill, and vice versa), else a neutral +/// grey. A data-mapped color aesthetic has no single constant, so it falls through +/// to the grey — that scale gets its own legend anyway. +fn key_color(ctx: &Ctx, legend_kind: LegendKind) -> Color { + let order = match legend_kind { + LegendKind::Line => ["stroke", "fill"], + LegendKind::Point | LegendKind::Rect => ["fill", "stroke"], + }; + for aesthetic in order { + // A data-mapped color has no constant to borrow — its column holds domain + // values, not colors — and it carries its own legend anyway. + if is_data_mapped(ctx, aesthetic) { + continue; + } + if let Some(HValue::Color(c)) = constant_material(ctx, aesthetic, RangeKind::Color) { + return c; + } + } + rgb8(64, 64, 64) +} From 10636a90573e0740f952e1d28424f5f96b91fa62 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Tue, 4 Aug 2026 23:00:53 +0200 Subject: [PATCH 10/29] Fix rendering of densified layers --- CHANGELOG.md | 12 ++ src/writer/hephaestus/PLAN.md | 93 +++++++++++--- src/writer/hephaestus/geom/densified.rs | 46 +++++++ src/writer/hephaestus/geom/mod.rs | 8 ++ src/writer/hephaestus/geom/segment.rs | 156 ++++++++++++++++-------- src/writer/hephaestus/mod.rs | 138 +++++++++++++++++---- src/writer/hephaestus/wiring.rs | 20 --- 7 files changed, 359 insertions(+), 114 deletions(-) create mode 100644 src/writer/hephaestus/geom/densified.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d8afba3a..7bc3cc7ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,18 @@ ### Fixed +- `segment`, `rule`, `ribbon` and `tile` layers under a map `PROJECT` now render + in the hephaestus writer. ggsql expands these into per-vertex rows so the edges + follow the projection's curvature, which the writer ignored — segments came out + zero-length, ribbons zero-height, a rule became a fan of straight lines, and + tiles a box per vertex. A non-spatial map is also framed to the map's bounding + box now, so its marks land on the clip boundary and keep the projection's + proportions, as they already did for `spatial` layers. +- The hephaestus writer drew a diagonal `rule` (abline) as a single solid line: it + ignored `linetype`, and a rule mapping several rows of slopes/intercepts + (`MAPPING slope AS slope, y AS y`) rendered only the first. It now draws one line + per row, honoring per-line `stroke`/`linetype`/`linewidth` — constant or + data-mapped, with a legend — like the Vega-Lite writer. - The hephaestus writer's `size`, `shape` and `linetype` legends drew empty swatches next to their labels; the key glyphs are now painted (in the layer's constant color, or a neutral grey when the color aesthetic is itself mapped). diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index 6b114c662..59d301855 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -381,20 +381,21 @@ Closing the audit gaps in implemented geoms, most-visible first. a point — instead of always a point glyph. - **boxplot/violin styling**: honor a constant `stroke` (box/whisker/median outline, both ribbon edges) and `opacity` (box fill / ribbon alpha) via - `wiring::{constant_color, constant_number}`, instead of hardcoded grey. + `wiring`'s constant-value helpers, instead of hardcoded grey. - **tile linetype**: `linetype` wired on the rect material (dashed tile borders). - **diagonal rule (abline)**: a rule with a non-zero `slope` (ggsql sets - `parameters["diagonal"] = true`) renders as a single `SegmentGeom` spanning the + `parameters["diagonal"] = true`) renders as `SegmentGeom`s spanning the position scales' resolved range — `segment::build_diagonal` grabs each pos domain from `spec.find_scale("pos1"/"pos2").numeric_domain()`, computes `secondary = slope·primary + intercept` over it (intercept from the `pos2`/`pos1` - literal, slope from the `slope` literal/SETTING), registers both axes from the + mapping, slope from the `slope` mapping/SETTING), registers both axes from the endpoints, and binds x/x2→pos1, y/y2→pos2. The user supplies the ranges via `SCALE x/y FROM (..)`; when a scale is unresolved it falls back to 0..1. No DRAW/PLACE or multi-layer distinction — the writer just reads the scale ranges. - Required teaching `wiring::{constant_color, constant_number}` to read bare - `Literal` aesthetic values (not only annotation columns), since `slope`, - `stroke`, etc. arrive as `AestheticValue::Literal`. + Required teaching `wiring::constant_number` to read bare `Literal` aesthetic + values (not only annotation columns), since `slope`, `stroke`, etc. arrive as + `AestheticValue::Literal`. (Per-row slopes/intercepts and full material styling + landed later — see the Chrome + composite polish section.) - **constant (`Literal`) material aesthetics across all geoms**: ggsql delivers every geom default *and* every `SETTING` constant (`color => 'red'`, `linetype => 'dashed'`, `size => 8`, …) as `AestheticValue::Literal` in the @@ -413,7 +414,7 @@ Closing the audit gaps in implemented geoms, most-visible first. shared-encoding model): `wiring::resolve_color(aesthetic, channel)` generalizes the old `resolve_fill` — a data-mapped color registers a scale, binds the channel, and adds one legend (returning the column for components to select); - otherwise the mapped literal (via `constant_color`) or default. Boxplot and + otherwise the mapped literal (via `constant_material`) or default. Boxplot and violin resolve **fill and stroke** this way and apply the same resolved color to every component (box/whisker/median/outlier; both ribbon edges), so a `stroke AS group` colors the whole mark per group under one collapsed legend. @@ -723,6 +724,21 @@ outlines, composite outline styling, and facet strip labelling. positions a binned break linearly in the domain instead of through `binned_map` (which still sends *data* to bin centres, as it should). Edge labels now sit on their boundaries with no collision. +- **Diagonal rules are per-row, and styled like any segment** + (`geom/segment.rs::build_diagonal`). The writer used to collapse an abline to a + *single* segment carrying the first row's slope/intercept and only + stroke/linewidth/opacity, so `linetype` was dropped and `MAPPING slope AS slope, + y AS y` (N lines) drew one. It now mirrors the Vega-Lite writer, whose + `calculate` transforms evaluate `secondary = slope · primary + intercept` **per + row**: `slope_values`/`intercept_values` read the mapped column when there is one + (else the literal / SETTING, repeated), so N rows give N lines, each with its own + slope and intercept. Materials go through the shared `wiring::wire_material` with + the segment family's now-extracted `material()` table, so a data-mapped `stroke` / + `linetype` / `linewidth` is scaled + legended exactly as on a plain segment — + verified byte-for-byte against VL's `strokeDash` scale range for the same query. + Positions are still computed (the spanning range comes from the pos scales' + resolved domain), which is why this keeps its own builder rather than + `build_and_add`. An empty data slice draws nothing, matching VL's zero-row layer. - Verified: 62 writer tests (10 exact-text `facet_strips_*` assertions that need no GPU, 5 `binned_bins`/`bin_at_centre` unit tests, and `renders_*` smoke tests for titles, text stroke, composite widths/dashes, binned + free-binned facets); @@ -771,6 +787,58 @@ surfaced (both reported upstream with standalone repros, both verified here): Newly found, **not** fixed here (see §9): a data-mapped `linewidth` on a boxplot/violin is rejected by ggsql itself. +## Densified geoms under `PROJECT` — status: implemented + +Projecting a straight edge onto a curved surface bends it, so ggsql densifies +the edge **in SQL** rather than leaving each writer to approximate it: each +original row becomes a run of vertex rows along the projected edge, the extent +aesthetics are remapped onto plain `pos1`/`pos2`, `__ggsql_densify_id__` is +appended to the layer's `partition_by` to tie one row's vertices together, and +the layer is flagged `parameters["densified"] = true`. Four geoms do this +(`apply_projection` in `plot/layer/geom/{segment,rule,ribbon,tile}.rs`): + +| geom | expansion | remap | shape | +| --- | --- | --- | --- | +| `segment` | 2 endpoints → N vertices | `pos1end`/`pos2end` → the `pos1`/`pos2` **columns** | open | +| `rule` | spans the clip bbox → N vertices | synthesizes the missing axis; forces `orientation => aligned` | open | +| `ribbon` | upper edge forward + lower edge backward → 2n vertices | `pos2min`/`pos2max` → the `pos2` column | closed | +| `tile` (continuous) | 4 corners → N vertices | drops `pos1min/max`,`pos2min/max`; adds `pos1`/`pos2` | closed | + +Because the remap points the extent aesthetic at the *same* column rather than +deleting it, each geom failed silently in its own way instead of erroring: a +segment drew `x2 == x` (zero-length), a ribbon `y == y2` (zero-height), a rule a +fan of straight full-height lines (one per vertex), and a tile fell into +`rect::tile`'s discrete branch and drew a full-band rect per vertex. (`area` was +listed in §9 as having the same hole; it does not — it has no `apply_projection` +and never densifies.) + +- **`geom/densified.rs`** dispatches ahead of the `GeomType` match in + `geom/mod.rs::build_into_plot` (as the Vega-Lite renderers check `densified` + first, so a densified rule takes this path over the diagonal-abline one). The + vertices are exactly what `line` and `polygon` already draw — same columns, + same `partition_by` grouping, same material tables — so `line::spec` / + `polygon::spec` are reused whole rather than duplicated: `LineGeom` for the + open pair, `PolygonGeom` for the closed pair. The VL writer makes the same + swap, to a `line` mark with `interpolate: linear-closed` when closed. Vertex + order is source order in both writers (VL's `order` is the row index). +- **Map framing is no longer spatial-only** (`mod.rs`): `spatial_bbox` became + `map_bbox`, which takes ggsql's `computed["bbox"]` for **any** map projection + and only falls back to the geometry extent for a bare `spatial` geom. Under a + map every mark, the clip boundary and the graticules share one pre-projected + data space, and hephaestus's `CustomProjection` outline is in *data space* — so + with the old gate a non-spatial map (`DRAW segment … PROJECT TO robinson`) framed + its position scales to the marks' own extent and got no aspect lock: the data + drifted off the boundary and the map was stretched. The VL writer frames from + the bbox unconditionally. +- Verified (eyeballed): curved firebrick segments on Robinson; a filled ribbon + whose edges follow the meridians; dashed curving rule meridians; four + projection-following tile quads under one colorbar; and a `ggsql:world` base map + with route segments landing on the right countries. The pre-existing spatial / + orthographic-globe renders are unchanged. Tests `renders_densified_{segment, + ribbon,rule,tile}` and `renders_map_over_spatial_base` (feature `spatial`); 71 + writer tests; feature, default (hephaestus absent) and `cargo +1.86` builds, + fmt, clippy all clean. + ## 8. Key source references ggsql: @@ -816,9 +884,6 @@ here so it survives between efforts. ### Correctness risks -- **Transparent backgrounds are probably wrong.** `HephaestusWriter::background()` - accepts any color, but `render_to_buffer` returns **premultiplied** RGBA and the - PNG encode path has no un-premultiply step. - **Legends are captured from the first panel only**, assuming every panel yields identical legends. True under fixed scales; unverified for a free-scale facet that also maps a material aesthetic. @@ -837,13 +902,6 @@ here so it survives between efforts. exposes no selection. - Calendar-native temporal axes (numeric axes with ggsql's formatted break labels work today). -- Diagonal rules ignore `linetype` (`geom/segment.rs::build_diagonal` sets only - stroke/linewidth/opacity). -- **`densified` segment/ribbon under `PROJECT`**: ggsql expands a projected - segment into per-vertex rows and remaps `pos1end`→`pos1`, which the VL writer - handles by switching to a `line` mark (`vegalite/layer.rs:1589-1624`). The - hephaestus path would draw zero-length segments; `area`/`ribbon` have the same - hole. - boxplot `side` and `hinge` parameters are unimplemented (the VL writer has both). - A `linewidth` aesthetic on ggsql's Text geom would let text outline width be set (a core + doc change; the outline itself works). @@ -883,7 +941,6 @@ resolved per-panel domains and spatial position scales: - `png::write_png` is file-only — no in-memory encode, so every host re-implements byte encoding. -- `render_to_buffer` returns premultiplied RGBA (see Correctness risks). - No scale-level domain expansion / "nice" padding. - Binned scales keep bin edges in the output range, so they can't also carry a color/size range (see the binned-material bug above). diff --git a/src/writer/hephaestus/geom/densified.rs b/src/writer/hephaestus/geom/densified.rs new file mode 100644 index 000000000..bdc7956b1 --- /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!( + "hephaestus writer cannot draw a densified '{other}' geom" + ))), + } +} diff --git a/src/writer/hephaestus/geom/mod.rs b/src/writer/hephaestus/geom/mod.rs index 4f797386c..01f632129 100644 --- a/src/writer/hephaestus/geom/mod.rs +++ b/src/writer/hephaestus/geom/mod.rs @@ -4,6 +4,7 @@ mod area; mod boxplot; +mod densified; mod line; mod point; mod polygon; @@ -23,6 +24,13 @@ use crate::{GgsqlError, Result}; /// Build the layer's geom into `plot`, recording its scales/axes/legends in `w`. 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 => { diff --git a/src/writer/hephaestus/geom/segment.rs b/src/writer/hephaestus/geom/segment.rs index 52d11124d..f3c125d85 100644 --- a/src/writer/hephaestus/geom/segment.rs +++ b/src/writer/hephaestus/geom/segment.rs @@ -9,11 +9,11 @@ use hephaestus::color::rgb8; use hephaestus::plot::{Plot as HPlot, SegmentGeom}; -use super::super::channels::aesthetic_column_name; +use super::super::channels::{aesthetic_column_name, column_to_f64}; use super::super::scales::RangeKind; use super::super::wiring::{ - constant_color, constant_number, Ctx, GeomSpec, LegendKind, MatDefault, MaterialSpec, - PanelAxis, PositionSpec, + constant_number, wire_material, Ctx, GeomSpec, LegendKind, MatDefault, MaterialSpec, PanelAxis, + PositionSpec, }; use crate::plot::layer::geom::GeomType; use crate::plot::ParameterValue; @@ -54,34 +54,7 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { GeomSpec { positions, - material: vec![ - MaterialSpec::new( - "stroke", - "stroke", - RangeKind::Color, - MatDefault::Color(rgb8(0, 0, 0)), - ), - MaterialSpec::new("color", "stroke", RangeKind::Color, MatDefault::None), - MaterialSpec::new("colour", "stroke", RangeKind::Color, MatDefault::None), - 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, - ), - ], + material: material(), raw_strings: &[], raw_numbers, data_channels: vec![], @@ -90,6 +63,39 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { } } +/// 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("color", "stroke", RangeKind::Color, MatDefault::None), + MaterialSpec::new("colour", "stroke", RangeKind::Color, MatDefault::None), + 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, + ), + ] +} + /// Whether this rule is a diagonal (abline): has a non-zero `slope`. pub fn is_diagonal(layer: &Layer) -> bool { matches!( @@ -98,25 +104,47 @@ pub fn is_diagonal(layer: &Layer) -> bool { ) } -/// A diagonal rule (abline): a single line spanning the position scales' -/// resolved range, with `secondary = slope * primary + intercept`. The 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. +/// 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 slope = slope_value(ctx); + let n = ctx.df.height(); + if n == 0 { + return Ok(()); + } + let slopes = slope_values(ctx, n)?; - let (x0, y0, x1, y1) = if !ctx.transposed { + let (x, x2, y, y2) = if !ctx.transposed { // y-intercept (`pos2`); x is the spanning axis. - let intercept = constant_number(ctx, "pos2", 0.0); + let intercepts = intercept_values(ctx, "pos2", n)?; let (x0, x1) = primary_range(ctx, "pos1"); - let (y0, y1) = (slope * x0 + intercept, slope * x1 + intercept); - (x0, y0, x1, y1) + ( + vec![x0; n], + vec![x1; n], + secondary(&slopes, &intercepts, x0), + secondary(&slopes, &intercepts, x1), + ) } else { // x-intercept (`pos1`); y is the spanning axis. - let intercept = constant_number(ctx, "pos1", 0.0); + let intercepts = intercept_values(ctx, "pos1", n)?; let (y0, y1) = primary_range(ctx, "pos2"); - let (x0, x1) = (slope * y0 + intercept, slope * y1 + intercept); - (x0, y0, x1, y1) + ( + secondary(&slopes, &intercepts, y0), + secondary(&slopes, &intercepts, y1), + vec![y0; n], + vec![y1; n], + ) }; for (channel, scale) in [ @@ -129,17 +157,24 @@ pub fn build_diagonal(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { } let mut b = SegmentGeom::builder(); - b.set("x", vec![x0]); - b.set("x2", vec![x1]); - b.set("y", vec![y0]); - b.set("y2", vec![y1]); - b.set("stroke", constant_color(ctx, "stroke", rgb8(0, 0, 0))); - b.set("linewidth", constant_number(ctx, "linewidth", 1.0)); - b.set("stroke_opacity", constant_number(ctx, "opacity", 1.0)); + 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 @@ -148,13 +183,26 @@ fn primary_range(ctx: &Ctx, aesthetic: &str) -> (f64, f64) { .unwrap_or((0.0, 1.0)) } -/// Slope from the `slope` aesthetic (literal/annotation) or the SETTING param. -fn slope_value(ctx: &Ctx) -> f64 { +/// 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(n)) => *n, + Some(ParameterValue::Number(v)) => *v, _ => 0.0, }; - constant_number(ctx, "slope", param) + 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 diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index 1f4c68b9b..70cbe4eef 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -135,14 +135,17 @@ impl Writer for HephaestusWriter { } } - // A spatial layer positions marks by geometry, not by `pos1`/`pos2` - // columns, so ggsql resolves no position scales for it. Register - // continuous `pos1`/`pos2` scales spanning the map's bounding box so - // `GeometryGeom`'s coordinates map into the panel. The bbox comes from - // ggsql (`computed["bbox"]` when projected, else the geometry extent), - // keeping the "writer never invents extents" principle. - let spatial_bbox = spatial_bbox(spec, data)?; - if let Some((xmin, ymin, xmax, ymax)) = spatial_bbox { + // 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(nice_range(xmin, xmax)), @@ -221,10 +224,10 @@ impl Writer for HephaestusWriter { // Axes are created per coordinate system, edge-only for fixed scales. plot = apply_projection(plot, spec, panel, &ps); - // Lock a spatial panel's aspect to its bounding box so the projected - // geometry keeps its proportions (a globe stays round), the raster - // analog of the Vega-Lite writer's uniform projection scale. - if let Some((xmin, ymin, xmax, ymax)) = spatial_bbox { + // Lock a map panel's aspect to its bounding box so the projection + // keeps its proportions (a globe stays round), the raster analog of + // the Vega-Lite writer's uniform projection scale. + if let Some((xmin, ymin, xmax, ymax)) = map_bbox { let (w, h) = (xmax - xmin, ymax - ymin); if w > 0.0 && h > 0.0 { plot = plot.aspect_ratio(h / w).aspect_mode(AspectMode::Range); @@ -264,19 +267,14 @@ impl Writer for HephaestusWriter { } } -/// The map bounding box `(xmin, ymin, xmax, ymax)` when the plot has a spatial -/// layer, else `None`. Prefers ggsql's resolved `computed["bbox"]` (set under a -/// `PROJECT map`); falls back to the union extent of the geometry data for a -/// bare `spatial` geom with no projection. -fn spatial_bbox( +/// 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> { - let is_spatial = |layer: &Layer| layer.geom.geom_type() == GeomType::Spatial; - if !spec.layers.iter().any(is_spatial) { - return Ok(None); - } - 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(); @@ -288,6 +286,11 @@ fn spatial_bbox( } } + 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, @@ -593,6 +596,32 @@ mod tests { "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] @@ -791,6 +820,71 @@ mod tests { )); } + /// 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)"; diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index df9da2d0b..f2622b075 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -339,26 +339,6 @@ pub fn band_half_width(layer: &Layer, default: f64) -> f64 { layer.adjusted_width.unwrap_or(width).abs() / 2.0 } -/// A constant color from an aesthetic, or `default` when unmapped. Reads an -/// annotation column first, then a bare `Literal` string (e.g. `stroke => -/// 'black'`). Composite geoms use this for uniform styling like a box outline. -pub fn constant_color(ctx: &Ctx, aesthetic: &str, default: Color) -> Color { - if let Some(c) = aesthetic_column_name(ctx.layer, aesthetic) - .and_then(|c| column_to_colors(ctx.df, c).ok()) - .and_then(|v| v.first().copied()) - { - return c; - } - if let Some(AestheticValue::Literal(ParameterValue::String(s))) = - ctx.layer.mappings.aesthetics.get(aesthetic) - { - if let Some(c) = super::scales::parse_color(s) { - return c; - } - } - default -} - /// 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 { From f4de96b38bfaa95039f7c717540cb7036cc7bba7 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 10 Aug 2026 10:42:25 +0200 Subject: [PATCH 11/29] Fix "side" geoms --- CHANGELOG.md | 11 ++ src/writer/hephaestus/PLAN.md | 74 ++++++++++- src/writer/hephaestus/channels.rs | 1 + src/writer/hephaestus/geom/boxplot.rs | 111 ++++++++++------ src/writer/hephaestus/geom/hinge.rs | 60 +++++++++ src/writer/hephaestus/geom/mod.rs | 8 +- src/writer/hephaestus/geom/segment.rs | 46 ++++++- src/writer/hephaestus/geom/violin.rs | 73 +++++++---- src/writer/hephaestus/mod.rs | 113 ++++++++++++++++ src/writer/hephaestus/wiring.rs | 182 +++++++++++++++++++++++++- 10 files changed, 606 insertions(+), 73 deletions(-) create mode 100644 src/writer/hephaestus/geom/hinge.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bc3cc7ba..68c5d4886 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,17 @@ - The hephaestus writer's `size`, `shape` and `linetype` legends drew empty swatches next to their labels; the key glyphs are now painted (in the layer's constant color, or a neutral grey when the color aesthetic is itself mapped). +- The hephaestus writer now honors the `hinge` and `side` settings. `hinge` caps a + `boxplot`'s whiskers and a `range`'s endpoints (10pt by default for a range, so + every range previously lost its hinges), and `side` draws a half `boxplot` or + `violin` on the chosen side of the band, so the two pair up on one category. + Horizontal (transposed) `boxplot` and `violin` layers also render — the first + failed with an internal error and the second drew nothing. +- `position => 'jitter'` and `position => 'dodge'` are honored by every layer type + in the hephaestus writer. Only bars, boxplots, violins and tiles used to move: + jittered points all landed on their category's centre line, and dodged points, + text, lines and ranges overplotted. Together with `side` this makes the + half-boxplot-plus-one-sided-jitter (raincloud) layout render. ### Changed diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index 59d301855..dcc463bd6 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -839,6 +839,73 @@ and never densifies.) writer tests; feature, default (hephaestus absent) and `cargo +1.86` builds, fmt, clippy all clean. +## `side` + `hinge` (banded geoms) — status: implemented + +The two boxplot parameters §9 listed as missing, plus the orientation handling +they turned out to depend on. `hinge` and `side` are both *banded-axis* concepts — +they measure across the axis a box/violin/interval sits on — so the work is shared +in `wiring::BandAxes` and `geom/hinge.rs` rather than per geom. + +- **`BandAxes`** (`wiring.rs`) names the channels of a banded geom by role instead + of by axis: the aesthetic family holding the categories (`pos1`, or `pos2` when + ggsql flipped the layer), the value family, the dodge column, and the + banded-axis position / `_band` / `_offset` channels. Bindings need no swap — a + hephaestus channel always drives the same panel axis; only the column feeding it + moves. This closed a gap §9 never listed: **transposed composites were broken** + — a horizontal `boxplot` errored outright (`no pos2end mapping`) and a horizontal + `violin` silently rendered an empty panel (it read the category column as + numbers). Both now render, caps and all. +- **`hinge`** (`geom/hinge.rs`) draws a `SegmentGeom` cap across the band via the + segment's absolute **pt** offset channels (`x_offset`/`y_offset`), so a cap keeps + its size at any panel width — the raster analog of the Vega-Lite writer's `tick` + of `size` px. `boxplot` caps its two whisker fences (default `null` = no caps); + `range` caps both interval endpoints (default 10pt, `hinge => null` to hide), + which had been silently missing for every range the writer drew. Caps take the + mark's resolved stroke/width/dash, so a per-group `stroke` colors them too. +- **`side`** (`wiring::{side_sign, band_edges}`) halves a mark onto one side of the + band: box, median and caps span centreline → `±half`, while whiskers and + outliers stay centred (matching VL). The violin's ribbon collapses one edge onto + the centreline the same way. hephaestus band offsets are positive-right on x and + positive-up on y, so `'top'`/`'right'` are positive in **either** orientation — + one predicate, where the VL writer flips the sign with orientation because + Vega-Lite's y offsets point down. The visual outcome is the same, including the + documented half-violin + half-boxplot pairing. The full `width` is left to + ggsql's dodge calculation, so a half-box still occupies its dodge slot. +- **Cap geometry is byte-equivalent to VL**, verified against its emitted spec for + `hinge => 20`: VL's both-sides `tick` has `size = 26.67px` (= 20pt × 96/72), + centred, so it spans `±13.33px`; the half-side tick has `size = 13.33px` shifted + by `+6.67px`, so it spans `[0, 13.33px]` — half the length, starting exactly on + the centreline. `band_edges(hinge / 2.0, …)` produces the same two edges + (`±10pt`, or `0 → 10pt`). The only unit caveat is writer-wide, not hinge-specific: + VL bakes points into CSS px at 96/72 while hephaestus converts at the render DPI, + as it does for every absolute size (`linewidth`, point `size`, dash lengths). +- **Position adjustments reach every geom** (`wiring::wire_positions`). ggsql + resolves `dodge` and `jitter` into per-row band fractions in + `__ggsql_aes_pos1offset__`/`pos2offset` — and folds jitter's own `side` in there, + so that third `side` consumer needs no writer logic at all, only delivery. Only + the geoms that read `dodge_offsets` themselves (bar/histogram/tile, boxplot, + violin) were consuming those columns, so a jittered `point` layer drew every + point on the category centreline and a dodged one overplotted — the Vega-Lite + writer has always bound the column to `xOffset`/`yOffset` for any layer carrying + it. `wire_positions` now maps the offsets onto each position's matching `_band` + channel, skipping channels the geom claims in `data_channels` (bar/tile compute + edges that already include the offsets). This is also what the range-dodge gap + needed, so `segment::build_hinges` reads the same offsets and a dodged interval's + caps travel with it. `position => 'stack'` is untouched — it rewrites the value + columns in data and produces no offset column. +- Verified (eyeballed): vertical + horizontal boxplots with caps, `side` left/ + right/top/bottom, dodged boxes with caps, per-group stroke caps, a dummy-axis + (single) boxplot, faceted half-boxes, half-violin, the documented violin/boxplot + pairing, range caps in both orientations plus `hinge => null`, jittered points + (full band and one-sided), dodged points/text/ranges, the raincloud layout, and — + unchanged against a pre-change build — dodged bars, boxes, tiles. Tests + `renders_boxplot_hinge`, `renders_boxplot_side`, `renders_transposed_boxplot`, + `renders_half_violin_with_half_boxplot`, `renders_range_hinges`, + `renders_jittered_points`, `renders_dodged_points`, + `renders_dodged_range_with_hinges`, `renders_jitter_with_half_boxplot`; 80 writer + tests; feature, default (hephaestus absent) and `cargo +1.86` builds, fmt, clippy + clean. + ## 8. Key source references ggsql: @@ -902,9 +969,14 @@ here so it survives between efforts. exposes no selection. - Calendar-native temporal axes (numeric axes with ggsql's formatted break labels work today). -- boxplot `side` and `hinge` parameters are unimplemented (the VL writer has both). - A `linewidth` aesthetic on ggsql's Text geom would let text outline width be set (a core + doc change; the outline itself works). +- **A dodged `violin` merges its groups into one mark.** The ribbon's `keys` come + from the category column alone, so every dodge group in a category forms a single + contour (three islands of one species render as one blob). The fix is to key on + the layer's `partition_by` (as the generic multi-vertex geoms do via + `build_group_keys`) and to order rows within that composite group, not within the + category. Unrelated to the offsets themselves — the group *positions* are correct. ### Architectural debt — writer doing work ggsql should own diff --git a/src/writer/hephaestus/channels.rs b/src/writer/hephaestus/channels.rs index ccc215347..5cff301b8 100644 --- a/src/writer/hephaestus/channels.rs +++ b/src/writer/hephaestus/channels.rs @@ -14,6 +14,7 @@ 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), diff --git a/src/writer/hephaestus/geom/boxplot.rs b/src/writer/hephaestus/geom/boxplot.rs index a3cd3cb9a..8ee444c02 100644 --- a/src/writer/hephaestus/geom/boxplot.rs +++ b/src/writer/hephaestus/geom/boxplot.rs @@ -2,7 +2,12 @@ //! 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), -//! and outliers (`PointGeom`). All components share the `pos1`/`pos2` scales. +//! 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}; @@ -13,23 +18,29 @@ use super::super::channels::{ }; use super::super::scales::RangeKind; use super::super::wiring::{ - band_half_width, constant_number, constant_string, dodge_offsets, resolve_color, - resolve_material, Ctx, LegendKind, MaterialSource, + band_edges, band_half_width, constant_number, constant_string, dodge_offsets, resolve_color, + resolve_material, side_sign, BandAxes, Ctx, LegendKind, MaterialSource, }; +use super::hinge::{caps, hinge_points}; use crate::{GgsqlError, Result}; pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { let (layer, df) = (ctx.layer, ctx.df); let n = df.height(); - let pos1 = require(layer, "pos1")?; + 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(layer, cat_aes)?; let type_col = require(layer, "type")?; - let value = require(layer, "pos2")?; - let value2 = require(layer, "pos2end")?; + let value_col = require(layer, value_aes)?; + let value2_col = require(layer, &value2_aes)?; - let p1 = column_to_channel(df, pos1)?; - let p2 = column_to_f64(df, value)?; - let p2e = column_to_f64(df, value2)?; + 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() }; @@ -40,7 +51,8 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { .collect(); let out_i = rows_of("outlier"); - // Bind the position channels (panel-aware for free facet scales). + // 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), @@ -92,19 +104,26 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { // (`opacity` → `fillOpacity` for a fill-bearing geom); the stroke-only // components have no fill to fade. let alpha = constant_number(ctx, "opacity", 1.0); - // Box width (band fraction, dodge-aware) + per-row dodge offsets. - let offsets = dodge_offsets(df, "pos1offset"); - let half = band_half_width(layer, 0.75); + // 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(); - p1.select(&box_i).apply(&mut b, "x"); - p1.select(&box_i).apply(&mut b, "x2"); - b.set("y", pick(&p2, &box_i)); - b.set("y2", pick(&p2e, &box_i)); - b.set("x_band", shift(&offsets, &box_i, -half)); - b.set("x2_band", shift(&offsets, &box_i, half)); + 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, linetype.as_ref(), &box_i); @@ -112,41 +131,57 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { plot.add_geom(b.build()); } - // Whiskers: vertical segments at the band centre, box edge → fence. + // 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(); - p1.select(&whisk_i).apply(&mut b, "x"); - p1.select(&whisk_i).apply(&mut b, "x2"); - b.set("y", pick(&p2, &whisk_i)); - b.set("y2", pick(&p2e, &whisk_i)); - b.set("x_band", shift(&offsets, &whisk_i, 0.0)); - b.set("x2_band", shift(&offsets, &whisk_i, 0.0)); + 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, linetype.as_ref(), &whisk_i); plot.add_geom(b.build()); } - // Median: a horizontal segment spanning the band at the median value. + // 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(); - p1.select(&med_i).apply(&mut b, "x"); - p1.select(&med_i).apply(&mut b, "x2"); - b.set("y", pick(&p2, &med_i)); - b.set("y2", pick(&p2, &med_i)); - b.set("x_band", shift(&offsets, &med_i, -half)); - b.set("x2_band", shift(&offsets, &med_i, half)); + 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, 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, linetype.as_ref(), &whisk_i); + plot.add_geom(b.build()); + } + // Outliers: hollow points (stroke only, matching VL's `filled = false`) // at their value, honoring the `size`/`shape` aesthetics. if !out_i.is_empty() { let mut b = PointGeom::builder(); - p1.select(&out_i).apply(&mut b, "x"); - b.set("y", pick(&p2, &out_i)); - b.set("x_band", shift(&offsets, &out_i, 0.0)); + 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)); stroke.apply(&mut b, "stroke", &out_i); // `PointGeom` has no dash pattern — a marker outline can't be dashed. outline(&mut b, &linewidth, None, &out_i); @@ -184,8 +219,8 @@ 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. ±half -/// the box width for the two edges, 0 for a centered line/point). +/// 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/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/mod.rs b/src/writer/hephaestus/geom/mod.rs index 01f632129..84eb2ca7d 100644 --- a/src/writer/hephaestus/geom/mod.rs +++ b/src/writer/hephaestus/geom/mod.rs @@ -5,6 +5,7 @@ mod area; mod boxplot; mod densified; +mod hinge; mod line; mod point; mod polygon; @@ -44,7 +45,12 @@ pub fn build_into_plot(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { } GeomType::Polygon => build_and_add::(plot, polygon::spec(ctx), ctx), GeomType::Rule if segment::is_diagonal(ctx.layer) => segment::build_diagonal(plot, ctx), - GeomType::Segment | GeomType::Range | GeomType::Rule => { + // 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), diff --git a/src/writer/hephaestus/geom/segment.rs b/src/writer/hephaestus/geom/segment.rs index f3c125d85..b73c552c9 100644 --- a/src/writer/hephaestus/geom/segment.rs +++ b/src/writer/hephaestus/geom/segment.rs @@ -9,12 +9,13 @@ use hephaestus::color::rgb8; use hephaestus::plot::{Plot as HPlot, SegmentGeom}; -use super::super::channels::{aesthetic_column_name, column_to_f64}; +use super::super::channels::{aesthetic_column_name, column_to_channel, column_to_f64}; use super::super::scales::RangeKind; use super::super::wiring::{ - constant_number, wire_material, Ctx, GeomSpec, LegendKind, MatDefault, MaterialSpec, PanelAxis, - PositionSpec, + 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}; @@ -96,6 +97,45 @@ fn material() -> Vec { ] } +/// `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!( diff --git a/src/writer/hephaestus/geom/violin.rs b/src/writer/hephaestus/geom/violin.rs index d3912bf0b..f7ad0eaa3 100644 --- a/src/writer/hephaestus/geom/violin.rs +++ b/src/writer/hephaestus/geom/violin.rs @@ -1,9 +1,13 @@ //! `violin` composite geom. ggsql's stat emits a KDE grid per category //! (`pos1` = category, `pos2` = value, `offset` = pre-scaled half-width). We -//! render one vertical `RibbonGeom` band per category: the right edge sits at -//! `+offset` and the left edge at `-offset` of the category band (via the -//! ribbon's per-row `x_band` / `x2_band` channels), sharing `y = pos2`. One row -//! per grid sample — no hand-built outline. +//! render one `RibbonGeom` band per category: 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 row per grid sample — 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; @@ -16,24 +20,29 @@ use super::super::channels::{ }; use super::super::scales::RangeKind; use super::super::wiring::{ - constant_number, dodge_offsets, resolve_color, resolve_material, Ctx, LegendKind, + band_edges, constant_number, dodge_offsets, resolve_color, resolve_material, side_sign, + BandAxes, Ctx, LegendKind, }; use crate::{GgsqlError, Result}; pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { let (layer, df) = (ctx.layer, ctx.df); - let pos1 = require(layer, "pos1")?; - let pos2 = require(layer, "pos2")?; + let axes = BandAxes::new(ctx); + let band_aes = axes.band(); + let value_aes = axes.value(); + + let band_col = require(layer, band_aes)?; + let value_col = require(layer, value_aes)?; let offset = require(layer, "offset")?; - let p1 = column_to_channel(df, pos1)?; - let cat = column_to_strings(df, pos1)?; // grouping key per row - let p2 = column_to_f64(df, pos2)?; + let p1 = column_to_channel(df, band_col)?; + let cat = column_to_strings(df, band_col)?; // grouping key per row + let p2 = column_to_f64(df, value_col)?; let off = column_to_f64(df, offset)?; - // Order rows so each category's band is contiguous and ascending in pos2 - // (RibbonGeom connects a mark's rows in source order). + // Order rows so each category'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, c) in cat.iter().enumerate() { @@ -49,21 +58,32 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { 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 vertical ribbon per category: right edge +offset, left edge -offset, - // both shifted by the dodge offset (zero when not dodged). - let dodge = dodge_offsets(df, "pos1offset"); + // 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 keys: Vec = order.iter().map(|&i| cat[i].clone()).collect(); - let x_band: Vec = order.iter().map(|&i| dodge[i] + off[i]).collect(); - let x2_band: Vec = order.iter().map(|&i| dodge[i] - off[i]).collect(); - let ys: Vec = order.iter().map(|&i| p2[i]).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(); // Resolve fill + stroke once (data-mapped → shared scale/legend, else // constant), mirroring the VL writer's shared-encoding model. @@ -112,13 +132,20 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { } } + // 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(keys); - p1.select(&order).apply(&mut b, "x"); - p1.select(&order).apply(&mut b, "x2"); - b.set("x_band", x_band); - b.set("x2_band", x2_band); - b.set("y", ys); + 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); stroke.apply(&mut b, "stroke", &order); stroke.apply(&mut b, "stroke2", &order); diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index 70cbe4eef..c2f964779 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -1070,6 +1070,119 @@ mod tests { )); } + #[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( diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index f2622b075..ab38fee5d 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -157,7 +157,10 @@ where builder.keys(keys); } } - wire_positions(&mut builder, &spec.positions, plot, ctx)?; + // 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)?)); @@ -177,12 +180,20 @@ where /// 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 col = aesthetic_column_name(ctx.layer, &p.aesthetic).ok_or_else(|| { GgsqlError::WriterError(format!( @@ -194,10 +205,42 @@ fn wire_positions( let data = column_to_channel(ctx.df, col)?; 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(()) } +/// 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 @@ -325,6 +368,126 @@ fn set_literal_channel( } } +/// 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 { @@ -377,14 +540,19 @@ pub fn constant_string(ctx: &Ctx, aesthetic: &str, default: &str) -> String { default.to_string() } -/// A dodge offset column (per-row band fractions), or zeros when not dodged. +/// 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); - if df.column(&name).is_ok() { - column_to_f64(df, &name).unwrap_or_else(|_| vec![0.0; df.height()]) - } else { - vec![0.0; df.height()] - } + df.column(&name).ok()?; + column_to_f64(df, &name).ok() } /// Resolve a label for an aesthetic: explicit `LABEL` wins (`None` suppresses), From 62973f8d24150a26c9abd4df2f74103385048b48 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 10 Aug 2026 11:14:19 +0200 Subject: [PATCH 12/29] Fix dodging issue in violin due to dropped partition_by grouping --- CHANGELOG.md | 3 +++ src/writer/hephaestus/PLAN.md | 34 ++++++++++++++++++----- src/writer/hephaestus/geom/violin.rs | 40 +++++++++++++++++++--------- src/writer/hephaestus/mod.rs | 12 +++++++++ 4 files changed, 69 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68c5d4886..744d4939d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ (`MAPPING slope AS slope, y AS y`) rendered only the first. It now draws one line per row, honoring per-line `stroke`/`linetype`/`linewidth` — constant or data-mapped, with a legend — like the Vega-Lite writer. +- A `violin` layer with several groups per category (from a mapped aesthetic or + `PARTITION BY`) merged them into a single contour in the hephaestus writer. Each + group is now its own violin, in both orientations. - The hephaestus writer's `size`, `shape` and `linetype` legends drew empty swatches next to their labels; the key glyphs are now painted (in the layer's constant color, or a neutral grey when the color aesthetic is itself mapped). diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index dcc463bd6..7d8b5f60d 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -906,6 +906,33 @@ in `wiring::BandAxes` and `geom/hinge.rs` rather than per geom. tests; feature, default (hephaestus absent) and `cargo +1.86` builds, fmt, clippy clean. +## Violin grouping — status: implemented + +A violin's `RibbonGeom` keys now come from the **category and the layer's +`partition_by`**, not the category alone (`geom/violin.rs`), and rows are ordered +by the value axis within that composite group. ggsql keeps position aesthetics out +of `partition_by`, so neither key is sufficient by itself: the category alone +merged every group in a category into one contour (three islands of one species +drew as a single blob, in either orientation), while `partition_by` alone would +merge the categories. The Vega-Lite writer composes its `detail` encoding the same +way — `build_detail_encoding(partition_by)` plus the categorical position field. +This covers `PARTITION BY` as well as dodge, which the original report didn't +mention: the group *positions* were always right, only their identity was wrong. + +An audit of the other geoms for the same fault found none. Only marks spanning +several rows can mis-group, which is `LineGeom` (line/path/smooth), `RibbonGeom` +(area/ribbon/density), `PolygonGeom` (polygon) and the densified pair — all of +which take their keys from `partition_by` through `wiring::build_and_add` +(`GeomSpec::grouped`). Every other geom (point, the rect family, segment/range/ +rule, hinge caps, text, spatial, and each boxplot component) draws one mark per +row, where grouping cannot apply. Violin was the only geom building its own keys. + +- Verified (eyeballed): dodged violins vertical + horizontal (one contour per + island, correct fills), `side => 'right'` dodged, `PARTITION BY sex`, faceted + violins, a `color`-mapped violin, and — unchanged — the single-group, dummy-axis, + `intensity`, half-violin and ridgeline (`SCALE ORDINAL y`) renders. Test + `renders_dodged_violin`; 81 writer tests; fmt, clippy clean. + ## 8. Key source references ggsql: @@ -971,13 +998,6 @@ here so it survives between efforts. work today). - A `linewidth` aesthetic on ggsql's Text geom would let text outline width be set (a core + doc change; the outline itself works). -- **A dodged `violin` merges its groups into one mark.** The ribbon's `keys` come - from the category column alone, so every dodge group in a category forms a single - contour (three islands of one species render as one blob). The fix is to key on - the layer's `partition_by` (as the generic multi-vertex geoms do via - `build_group_keys`) and to order rows within that composite group, not within the - category. Unrelated to the offsets themselves — the group *positions* are correct. - ### Architectural debt — writer doing work ggsql should own The principle is "ggsql owns all scale domains; the writer never computes diff --git a/src/writer/hephaestus/geom/violin.rs b/src/writer/hephaestus/geom/violin.rs index f7ad0eaa3..661824ea7 100644 --- a/src/writer/hephaestus/geom/violin.rs +++ b/src/writer/hephaestus/geom/violin.rs @@ -1,9 +1,9 @@ -//! `violin` composite geom. ggsql's stat emits a KDE grid per category +//! `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: 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 row per grid sample — no hand-built -//! outline. +//! 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 row per grid +//! sample — 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 @@ -16,7 +16,7 @@ use hephaestus::color::rgb8; use hephaestus::plot::{Plot as HPlot, RibbonGeom}; use super::super::channels::{ - aesthetic_column_name, column_to_channel, column_to_f64, column_to_strings, + aesthetic_column_name, build_group_keys, column_to_channel, column_to_f64, column_to_strings, }; use super::super::scales::RangeKind; use super::super::wiring::{ @@ -37,16 +37,30 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { let offset = require(layer, "offset")?; let p1 = column_to_channel(df, band_col)?; - let cat = column_to_strings(df, band_col)?; // grouping key per row + let cat = column_to_strings(df, band_col)?; let p2 = column_to_f64(df, value_col)?; let off = column_to_f64(df, offset)?; - // Order rows so each category's band is contiguous and ascending in the - // value axis (RibbonGeom connects a mark's rows in source order). + // 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, c) in cat.iter().enumerate() { - let g = *index.entry(c.as_str()).or_insert_with(|| { + for (i, k) in keys.iter().enumerate() { + let g = *index.entry(k.as_str()).or_insert_with(|| { groups.push(Vec::new()); groups.len() - 1 }); @@ -73,7 +87,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { // offset (zero when not dodged). let dodge = dodge_offsets(df, axes.dodge()); let side = side_sign(layer); - let keys: Vec = order.iter().map(|&i| cat[i].clone()).collect(); + let ordered_keys: Vec = order.iter().map(|&i| keys[i].clone()).collect(); let edges: Vec<(f64, f64)> = order .iter() .map(|&i| { @@ -140,7 +154,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { let (value_ch, _) = axes.value_channels(); let mut b = RibbonGeom::builder(); - b.keys(keys); + 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); diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index c2f964779..2229cbe93 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -1193,6 +1193,18 @@ mod tests { )); } + #[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. From 7d44d21e44d20a8051756a447891172f835be2ab Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 10 Aug 2026 12:01:45 +0200 Subject: [PATCH 13/29] Fix temporal axes --- CHANGELOG.md | 6 ++ src/plot/scale/scale_type/mod.rs | 20 ++++- src/plot/scale/types.rs | 42 +++++++++ src/writer/hephaestus/PLAN.md | 77 +++++++++++++++- src/writer/hephaestus/scales.rs | 145 ++++++++++++++++++++++++++++--- 5 files changed, 268 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 744d4939d..f4c55a852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ ### Fixed +- Temporal axes and legends in the hephaestus writer were labelled with the raw + epoch number behind the date (`1208` for a `DATE`, `106358400000000` for a + `TIMESTAMP`), and a `RENAMING` on a temporal scale was ignored. They now read as + dates, honor `RENAMING`, and match the Vega-Lite writer. Scales are also built + calendar-aware, so ticks the writer doesn't supply itself — those of a free facet + panel — are dates rather than numbers. - `segment`, `rule`, `ribbon` and `tile` layers under a map `PROJECT` now render in the hephaestus writer. ggsql expands these into per-vertex rows so the edges follow the projection's curvature, which the writer ignored — segments came out diff --git a/src/plot/scale/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index 4b2c7df83..e26f6350c 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() } diff --git a/src/plot/scale/types.rs b/src/plot/scale/types.rs index 6cc3289f6..f534fd35f 100644 --- a/src/plot/scale/types.rs +++ b/src/plot/scale/types.rs @@ -352,6 +352,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"]); diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index 7d8b5f60d..eb41c7fab 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -933,6 +933,45 @@ row, where grouping cannot apply. Violin was the only geom building its own keys `intensity`, half-violin and ridgeline (`SCALE ORDINAL y`) renders. Test `renders_dodged_violin`; 81 writer tests; fmt, clippy clean. +## Temporal axes — status: implemented + +Temporal axes and legends were labelled with the epoch integer their position +projects to (`1208`, or `106358400000000` for a timestamp) rather than the date, +and a `RENAMING` on a temporal scale was dropped entirely. Tick *positions* were +always ggsql's, so this was a labelling fault, not a placement one. Two causes: + +- `Scale::break_labels()` (default impl in `plot/scale/scale_type/mod.rs`) built + each label as `format!("{v}")` over `numeric_breaks()`, discarding the break's + `ArrayElement` variant. It now labels each break from the element itself via + `to_key_string()` — which is also how `label_mapping` is keyed, so the label + template and `RENAMING` overrides are found instead of missed. Numeric labels + are unchanged (`format_number` agrees with the old `format!("{v}")` on every + break value), and the discrete/ordinal override is untouched. +- The writer built every continuous scale with `scale::continuous`, so hephaestus + never learned the calendar unit even though the resolved ggsql scale names it + (`transform` is auto-set to `Date`/`DateTime`/`Time` from the column dtype). + `scales::temporal_scale` now builds `scale::temporal` in the unit the transform + names — days / µs since epoch, ns since midnight, matching `ArrayElement` and + therefore what a temporal column projects to f64 as — and `apply_breaks` hands + a continuous temporal scale its breaks as `Value::Date`/`DateTime`/`Time`. + Mapping is unaffected: hephaestus maps `Temporal` exactly as `Continuous`. + +Free temporal facet dimensions keep ggsql's global break labels **narrowed to the +panel** (`free_continuous_scale`), the treatment `free_binned_scale` already gives +bin edges and what the Vega-Lite writer does with a free temporal axis. Letting +hephaestus pick per-panel calendar ticks instead — which it now can — invents +breaks ggsql didn't resolve and puts five full ISO labels in a panel ~130 px wide. +A panel that no global break falls inside keeps hephaestus's own ticks rather than +a bare axis; they read as dates either way, because the scale carries the unit. + +- Verified (eyeballed, against the Vega-Lite render of the same query): a fixed + `Date` axis, the same with `RENAMING * => '{:time %b %d}'`, a `TIMESTAMP` axis, a + `Date`-mapped colourbar, and a free-scale faceted `Date` axis. Tests + `temporal_scale_labels_ggsql_breaks_as_dates`, `temporal_scale_is_calendar_aware`, + `test_temporal_break_labels_are_iso_strings`, + `test_temporal_break_labels_honour_mapping`; full `--features hephaestus` suite; + fmt, clippy clean. + ## 8. Key source references ggsql: @@ -987,15 +1026,27 @@ here so it survives between efforts. - **No axis label thinning or rotation.** hephaestus's `Axis` is `rail(scale, placement)` + `title` only, with ticks coming solely from the scale, so long tick labels overlap in narrow facet panels (visible with binned - range labels, and equally with long categorical labels). + range labels, and equally with long categorical labels). **Now the most visible + gap on a temporal axis:** a *fixed*-scale facet gives every panel the full + global break set, and an ISO date label is ~3× the width of the epoch integer + that used to be drawn there, so six dates collide where six numbers merely + crowded. Vega-Lite doesn't hit this because Vega-Lite's own `labelOverlap` + hides colliding labels. The fix belongs in hephaestus's `Axis`: it needs the + measured text metrics to decide a stride, which the writer doesn't have and + shouldn't guess. Keeping the tick and blanking its label is the presentation to + aim for. ### Feature gaps - `arrow` geom — the only unsupported `GeomType` (deliberate). - Theming: ggsql has no theme concept; the writer uses hephaestus's default and exposes no selection. -- Calendar-native temporal axes (numeric axes with ggsql's formatted break labels - work today). +- A `DateTime` axis is labelled with the full ISO timestamp + (`1973-06-25T00:00:00`), because that is what `ArrayElement::to_key_string()` + yields and hence what ggsql's default label template produces. The Vega-Lite + writer draws the same string from the same mapping, so the two agree — but a + compact default (dropping a time part that is midnight on every break) would + suit both, and belongs in ggsql's label templating rather than in either writer. - A `linewidth` aesthetic on ggsql's Text geom would let text outline width be set (a core + doc change; the outline itself works). ### Architectural debt — writer doing work ggsql should own @@ -1022,7 +1073,21 @@ resolved per-panel domains and spatial position scales: ("Column `linewidth` … does not exist"). Grouping aesthetics (fill/stroke) survive; scalar ones don't. - `Scale::break_labels()` misses `label_mapping` for numeric discrete/ordinal - domains (`to_json()` `"5.0"` vs `to_key_string()` `"5"`). + domains: `categorical_break_labels` still labels a non-string category with + `format!("{}", to_json())` (`"5.0"`) while the mapping is keyed by + `to_key_string()` (`"5"`). The default (continuous/binned) impl no longer has + this fault — see the temporal-axes section. +- **`TIME` columns are broken for both writers.** ggsql's Time convention is + nanoseconds (`casting.rs` targets `Time64(Nanosecond)`, `schema.rs` reads via the + strict `as_time64_ns`), but `needs_cast` treats any `Time64(_)` as already the + target, so DuckDB's `Time64(Microsecond)` is never converted. VL fails hard + (`Internal error: Expected Time64(Nanosecond) array, got Time64(Microsecond)`); + the hephaestus writer renders raw µs against a domain ggsql couldn't resolve. + Fix: treat a unit mismatch as needing a cast. +- **VL pins global `axis.values` on a free facet scale**, so a free temporal panel + shows only whichever global breaks fall inside it (often one). The hephaestus + writer deliberately matches this; both would improve if ggsql resolved per-panel + breaks (see the architectural-debt item above). - VL's `build_discrete_facet_label_expr` is unreachable dead code and iterates a `HashMap` nondeterministically — deletion candidate. - VL's DateTime/Time binned facet strips are broken (its midpoint-string @@ -1044,6 +1109,10 @@ resolved per-panel domains and spatial position scales: `TextElement` has no stroke field; `text_stroke` is a geom channel only. - The `text` feature's parley shaper is documented as scaffolding "meant to be replaced by the host". +- Minor breaks ignore how sparse the supplied majors are: a temporal scale given a + single labelled break still emits sub-unit (daily) minors across the domain, + which reads as a dotted rail in a narrow panel. Deriving the minor interval from + the *supplied* majors, or letting a caller suppress minors, would fix it. - `src/scales/` docs still claim transforms are Identity-only — stale. ### Standing constraints (accepted) diff --git a/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs index 7d662e732..487fedb5d 100644 --- a/src/writer/hephaestus/scales.rs +++ b/src/writer/hephaestus/scales.rs @@ -11,7 +11,9 @@ use std::sync::Arc; use hephaestus::color::{rgba, Color}; use hephaestus::plot::geom::linetype::{dashdot, dashed, dotted, solid}; use hephaestus::plot::scale::{self, Scale as HScale, TransformKind as HTransform}; -use hephaestus::scales::value::{LinetypeStep, Value as HValue}; +use hephaestus::scales::value::{ + Date as HDate, DateTime as HDateTime, LinetypeStep, Time as HTime, Value as HValue, +}; use super::channels::{column_to_f64, column_to_strings}; use crate::naming; @@ -64,13 +66,21 @@ pub fn build_scale(scale: Option<&GScale>, kind: RangeKind) -> Option { c } ScaleTypeKind::Continuous => { - let h_transform = transform.and_then(map_transform); let (min, max) = continuous_domain(scale); - let mut c = scale::continuous(min..=max); - if let Some(t) = h_transform { - c = c.with_transform(t); + // 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 + } } - c } }; @@ -132,19 +142,43 @@ pub fn free_position_scale( 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(dfs, base, transform)), - ScaleTypeKind::Continuous => free_continuous_scale(dfs, base, transform), + .or_else(|| free_continuous_scale(global, dfs, base, transform)), + ScaleTypeKind::Continuous => free_continuous_scale(global, dfs, base, transform), } } /// 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 (there is no label thinning — see PLAN.md §9). 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)?; let (min, max) = pad_degenerate(min, max); + 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(); + return Some(if labels.is_empty() { + hs + } else { + hs.with_breaks_labeled(labels) + }); + } let mut c = scale::continuous(min..=max); if let Some(t) = transform.and_then(map_transform) { c = c.with_transform(t); @@ -312,12 +346,21 @@ fn apply_breaks(hs: HScale, scale: &GScale, type_kind: Option) -> // PLAN.md §9 — it currently maps break positions through `binned_map`, which // sends every value to its bin's centre). Composite "lower – upper" range // labels belong to keyed legends and facet strips, not axes. - _ => hs.with_breaks_labeled( - labels - .into_iter() - .map(|(pos, label)| (HValue::Number(pos), label)) - .collect(), - ), + // + // 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, Some(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(), + ) + } } } @@ -411,6 +454,37 @@ pub fn bin_at_centre(bins: &[Bin], value: f64) -> Option { .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). @@ -548,6 +622,49 @@ mod tests { 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(Some(&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 bin_at_centre_finds_nearest_bin() { let bins = binned_bins(&binned_scale(&[0.0, 10.0, 20.0, 30.0], &[])); From 557de39a455c13f87915c25df88c4a60baba326e Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 10 Aug 2026 14:27:06 +0200 Subject: [PATCH 14/29] Wire up minor breaks --- CHANGELOG.md | 21 ++ Cargo.lock | 3 +- doc/syntax/scale/type/continuous.qmd | 22 ++ doc/vendor/SKILL.md | 3 + src/Cargo.toml | 5 +- src/plot/scale/scale_type/continuous.rs | 13 + src/plot/scale/scale_type/mod.rs | 396 +++++++++++++++++++++--- src/plot/scale/types.rs | 141 ++++++++- src/writer/hephaestus/PLAN.md | 221 +++++++++++-- src/writer/hephaestus/mod.rs | 42 ++- src/writer/hephaestus/scales.rs | 85 ++++- src/writer/hephaestus/wiring.rs | 12 + 12 files changed, 855 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4c55a852..5259b29a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ projections, spatial geometry, and plot chrome (axes, legends, facet strips, title/subtitle/caption). Requires a working GPU adapter — hardware or software, e.g. lavapipe — at render time. +- 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. Like `LABEL caption`, this has no Vega-Lite equivalent and is + ignored by that writer; the hephaestus writer draws them. - `LABEL caption => '...'` now renders. It has no Vega-Lite equivalent and is ignored by that writer, but the hephaestus writer places it below the plot. - `LABEL title`/`subtitle` are rendered by the hephaestus writer, spanning the @@ -17,6 +23,21 @@ ### Fixed +- Minor gridlines in the hephaestus writer were generated from the axis domain + instead of from ggsql's breaks, so an axis with few major breaks — a temporal axis + narrowed to one break in a facet panel — was filled with sub-unit minors that read + as a dotted rail. Minor breaks are now resolved by ggsql alongside the majors and + the writer draws those (see the new `minor_breaks` setting above). +- A binned `size`, `shape` or `linetype` legend in the hephaestus writer drew one + key per bin *edge* — five keys for a four-bin ladder, each sized at an edge value, + implying a category that doesn't exist. It now draws one key per bin, sized at the + bin's midpoint, with the edge labels on a rail between the keys; a binned `color` + legend likewise becomes a stepped bar of one block per bin instead of a smooth + gradient. +- Panels of a `FACET` with free scales were not expanded in the hephaestus writer, so + a mark at a panel's extreme was clipped in half at the panel edge, and + `SCALE ... SETTING expand` stopped applying once that dimension was freed. Free + panels now get the scale's own expansion, like a fixed axis. - Temporal axes and legends in the hephaestus writer were labelled with the raw epoch number behind the date (`1208` for a `DATE`, `106358400000000` for a `TIMESTAMP`), and a `RENAMING` on a temporal scale was ignored. They now read as diff --git a/Cargo.lock b/Cargo.lock index 9f254f406..f3ed72849 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2251,7 +2251,6 @@ dependencies = [ "libloading", "palette", "parquet", - "png", "rand 0.8.6", "regex", "rusqlite", @@ -2498,7 +2497,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hephaestus" version = "0.0.1" -source = "git+https://github.com/posit-dev/hephaestus.git?rev=a90dc84b769e5723c4ca675bd9b439063cdc501a#a90dc84b769e5723c4ca675bd9b439063cdc501a" +source = "git+https://github.com/posit-dev/hephaestus.git?rev=5e9a06042af976b54ace5a0ac135d564a4b12ef2#5e9a06042af976b54ace5a0ac135d564a4b12ef2" dependencies = [ "bytemuck", "clipper2-rust", diff --git a/doc/syntax/scale/type/continuous.qmd b/doc/syntax/scale/type/continuous.qmd index 54b113fce..9f694ab1e 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 hephaestus (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/vendor/SKILL.md b/doc/vendor/SKILL.md index abb27c355..570416ae6 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'` diff --git a/src/Cargo.toml b/src/Cargo.toml index ffd74d820..bc9b9a77e 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -40,8 +40,7 @@ adbc_core = { version = "0.23", optional = true } geozero = { workspace = true, optional = true, features = ["with-wkb", "with-wkt", "with-geojson"] } # Hephaestus raster writer (non-default; gated, excluded from the MSRV 1.86 build) -hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "a90dc84b769e5723c4ca675bd9b439063cdc501a", optional = true, default-features = false, features = ["vello", "png", "text", "geom-wkb", "geom-wkt"] } -png = { version = "0.18", optional = true } +hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "5e9a06042af976b54ace5a0ac135d564a4b12ef2", optional = true, default-features = false, features = ["vello", "png", "text", "geom-wkb", "geom-wkt"] } # Serialization serde.workspace = true @@ -74,6 +73,6 @@ adbc = ["dep:adbc_core"] odbc = ["dep:toml_edit", "dep:libloading"] spatial = ["dep:geozero", "rusqlite?/load_extension"] vegalite = [] -hephaestus = ["dep:hephaestus", "dep:png"] +hephaestus = ["dep:hephaestus"] builtin-data = [] all-readers = ["duckdb", "sqlite", "odbc"] 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 e26f6350c..9ef34efd2 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -915,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)); } } } @@ -972,6 +929,74 @@ 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. Compared as f64 rather than via + // `filter_breaks_to_range`, which only filters `Number` elements and + // so can't constrain a temporal break at all (a gap the majors path + // still has — see PLAN.md §9). + let minors = match extent { + Some((min, max)) => minors + .into_iter() + .filter(|e| e.to_f64().is_some_and(|v| v >= min && v <= max)) + .collect(), + 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 @@ -1732,7 +1757,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") @@ -1771,6 +1795,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 @@ -1880,6 +1953,16 @@ pub(crate) fn resolve_common_steps( user_explicit_expand, ); + // Record the factors that were actually applied, normalised to [mult, add]. + // `context.default_expand` (zero for polar full-circle theta) is only visible + // here, 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` — would otherwise re-derive the wrong factors. + scale.properties.insert( + "expand".to_string(), + ParameterValue::Array(vec![ArrayElement::Number(mult), ArrayElement::Number(add)]), + ); + // Track the original user range to know which values are explicit vs inferred let original_user_range = scale.input_range.clone(); @@ -3234,6 +3317,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 f534fd35f..b1d9718a9 100644 --- a/src/plot/scale/types.rs +++ b/src/plot/scale/types.rs @@ -52,8 +52,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,6 +119,29 @@ 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. @@ -159,6 +183,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) @@ -410,4 +466,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/PLAN.md b/src/writer/hephaestus/PLAN.md index eb41c7fab..b46185766 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -972,6 +972,146 @@ a bare axis; they read as dates either way, because the scale carries the unit. `test_temporal_break_labels_honour_mapping`; full `--features hephaestus` suite; fmt, clippy clean. +## hephaestus bump — status: implemented + +Dep bumped to rev `5aada7c`, which lands four of the deficiencies this writer +reported upstream: + +1. **In-memory PNG encode.** `png::{encode_png, write_png_to}` sit beside the + file-only `write_png`, so `render_png` calls `encode_png` instead of driving + the `png` crate itself — the `png` dependency is gone from `src/Cargo.toml`. + The same change settled the alpha question the Phase 1 notes left open: + `render_to_buffer` hands out **straight** (un-premultiplied) alpha, which is + what PNG stores, so no conversion is needed on a transparent background + either. +2. **Chrome text can be outlined.** `TextElement` gained + `text_stroke` / `text_linewidth_pt`, mirroring the text geoms' channels, and + every chrome path (axis and legend tick labels, titles, polar) draws the + stroke pass behind the fill. Nothing to wire here yet — ggsql has no theme + concept — but the gap is closed upstream. +3. **Binned keyed legends reserve what they draw.** The measure pass now sizes + one row per *bin* at the bin's midpoint, matching the renderer. +4. Stale `src/scales/` docs (transforms "Identity-only") and the `text` module's + "scaffolding" framing of its parley shaper are both corrected. + +**Binned legends now use hephaestus's binned mode.** `material_legend` calls +`.binned()` whenever the scale type is `Binned`, which was the fix §9 had wrong. +The old §9 entry wanted compound `"lower – upper"` range labels from hephaestus; +they aren't needed, because a binned legend puts the edge labels on a tick rail +*between* the keys, so each edge is labelled once at the boundary it names. What +was actually missing was the writer asking for that mode. Before: a keyed legend +drew one key per *edge* — five keys for a four-bin ladder, each sized at an edge +value, implying five categories and a mark of exactly that value. After: four keys +sampled at bin midpoints with `2500 … 6500` on the rail between them; on a +colorbar the same call gives four constant-color blocks instead of a gradient. +Vega-Lite has no between-keys rail, so it takes the other route — `determine_legend_style` +sends non-color binned aesthetics to a **symbol** legend, whose per-key labels +*are* Vega-generated ranges (`"2500 – 3500"`), which is why `encoding.rs` needs +`build_symbol_legend_label_mapping` to re-derive those strings before a `RENAMING` +can match them. hephaestus needs none of that machinery. +`open_lower()` / `open_upper()` remain unused: ggsql marks a suppressed terminal +label as `Some(None)`, which `break_labels()` turns into an empty string, so the +outer boundary renders as a bare tick rather than no tick. + +Verified: full `--features hephaestus` suite (85 tests) passes, including +`renders_binned_size_legend` and `renders_binned_color_legend`; both eyeballed +against the pre-change render. fmt, clippy clean. + +## Free-panel expansion — status: implemented + +A free facet dimension is the one domain the writer computes itself, and it was a +raw data extent: marks at a panel's extremes were drawn *half outside* the panel +(a point at the maximum lost its top half to the clip) while a fixed axis got the +usual 5%. `SETTING expand` also silently stopped applying to a dimension once it +was freed. + +Fixed by reading the policy off the scale instead of padding in the writer: + +- `Scale::expand_range(min, max)` (`plot/scale/types.rs`) applies the scale's + resolved `expand` factors to a caller-supplied range and clips to the transform's + allowed domain — the same two steps, in the same order, as `resolve_common_steps`. + It sits next to `numeric_domain()` / `break_labels()` because it is the same kind + of thing: a resolved fact a consumer reads rather than re-derives. `Scale` was + already the writer's only channel for scale truth, so no new surface was needed + in `scales.rs`. +- `resolve_common_steps` now writes the factors it applied back into + `properties["expand"]`, normalised to `[mult, add]`. Without this the writer would + read the *requested* expansion and miss `ScaleDataContext::default_expand`, which + is visible only during resolution — a polar full-circle theta resolves to zero + expansion, and a free theta panel padded by 5% would open a gap in the pie. This + matches how `properties["breaks"]` already carries a resolved value, and is + idempotent: re-resolving reads back the same factors it wrote. +- `free_continuous_scale` calls `expand_range` on the panel extent. Break labels are + filtered against the *padded* bounds, so a global break just outside a panel's + data extent but inside its panel now draws, as it would on a fixed axis. +- `free_binned_scale` deliberately does **not** expand: a bar's band width is + `1 / (edges - 1)`, which assumes the domain spans exactly the edges, so padding + the domain would desynchronise bar width from bin width. + +Verified: `FACET species SETTING free => 'y'` on penguins, eyeballed before/after — +every extreme mark is now whole and inside its panel, with break labels unchanged. +Four `expand_range` unit tests (default, `SETTING expand` as scalar and as +`[mult, add]`, zero, log clip); full suite 1782 tests + 24 doctests pass; fmt, +clippy clean. + +## Minor breaks — status: implemented + +Break positions are ggsql's to own, **minor as well as major**. The writer supplied +majors and left minors to be generated from the domain, so a sparse major set — a +fixed temporal axis narrowed to one break in a facet panel — got sub-unit minors and +read as a dotted rail. Fixed on both sides of the boundary; dep bumped to `5e9a060` +for the upstream half. + +- **hephaestus** gained a minor-break override: `MinorBreaksSpec` + (`Explicit` / `CountBetween` / `NumericInterval` / `TemporalInterval`) with + `with_minor_breaks` / `with_minor_count` / `with_minor_interval` / + `with_minor_temporal_interval` / `clear_minor_breaks`, independent of + `breaks_spec`, falling back to the automatic algorithm when unset or when the + variant doesn't match the scale type. +- **ggsql** already owned the *algorithms* — + `TransformTrait::calculate_minor_breaks` per transform, plus a + `default_minor_break_count` (1 for identity/sqrt, 8 for the log family, 3 for + temporal) — but nothing resolved them: no callers outside + `plot/scale/transform/` and `plot/scale/breaks.rs`. They had never been reachable, + because Vega-Lite has no minor-tick concept and there was no other writer, so with + one that draws them the whole thing became worth exposing rather than merely + wiring: **`minor_breaks` is now a continuous-scale `SETTING`** mirroring `breaks` — + a count, an array of positions, or a temporal interval string — resolved in place + into an array of positions in step 5b of the default `resolve()` and read back via + `Scale::numeric_minor_breaks()`. Documented in + [`doc/syntax/scale/type/continuous.qmd`](../../../doc/syntax/scale/type/continuous.qmd). + Three details worth keeping: + - **The count is per major interval**, not a target for the whole axis the way + `breaks => n` is. Subdividing an interval shouldn't depend on how many breaks the + scale ended up with. `minor_breaks => 0` means none. + - **`Some(vec![])` and `None` must stay distinct.** The first is "resolved to no + minors", which a writer has to honour; the second is "not resolved", which leaves + a writer free to fall back on its own. Hence `numeric_minor_breaks()` returns an + `Option`, unlike `numeric_breaks()`. + - Minors are filtered to the domain by comparing `to_f64()`, not through + `filter_breaks_to_range`, which only filters `Number` elements and so cannot + constrain a temporal break at all — a gap the majors path still has (see the + ggsql-core list). + + `Binned` overrides `resolve` and has its own settings list, so it neither derives + minors nor accepts the setting — a binned axis's ticks are its bin edges, with + nothing to subdivide. +- **The writer** pins them through `apply_minor_breaks` (fixed scales) and the + narrowed-to-panel list in `free_continuous_scale` (free dimensions), wrapping each + position as the transform's value variant exactly as the majors are. Both go + through `apply_pinned_minors`, which passes `None` straight through (keeping + hephaestus's automatic minors) but pins an empty list as an empty list, so + `minor_breaks => 0` reaches `with_minor_breaks(vec![])` and draws nothing. A free + panel that no global major lands in keeps the whole tick set automatic rather than + mixing ggsql minors with hephaestus majors. + +Verified: a `DATE` line facetted with `free => 'x'`, eyeballed before/after — roughly +ten crowded weekly minors per panel become four on ggsql's own grid, majors unchanged; +plus `SETTING minor_breaks => 3` (three gridlines per interval), `=> 0` (none), and +`=> -1` rejected at validation. Eleven new tests across the accessor, the resolution +of each setting form, and the rejection; full suite 1793 tests + 24 doctests pass; +fmt, clippy clean. + ## 8. Key source references ggsql: @@ -1009,9 +1149,9 @@ here so it survives between efforts. that job and is a phase log, not an architecture doc. - `src/CLAUDE.md` is stale: no `hephaestus` row in the feature table, and the `writer/` section still says "Only Vega-Lite is implemented today". -- CLI: `--writer` help text only advertises `vegalite`; no output-extension - routing; no flags for width/height/dpi/background, so only one hardcoded size - is reachable. +- CLI: no output-extension routing, and no flags for width/height/dpi/background, + so only the one hardcoded `HephaestusWriter::new(1500, 1000, 300.0)` with a + transparent background is reachable. - `doc/` doesn't mention raster output at all. - Default-writer switchover criteria still undecided (Decision 4). @@ -1020,9 +1160,19 @@ here so it survives between efforts. - **Legends are captured from the first panel only**, assuming every panel yields identical legends. True under fixed scales; unverified for a free-scale facet that also maps a material aesthetic. -- **Log scales get no domain expansion** — under a non-identity transform the - writer deliberately falls back to the raw data extent (see the ggsql-core item - below). +- **A log scale whose expanded lower bound crosses zero renders blank.** Not a + writer fault and not "log scales get no expansion" — expansion works whenever it + stays positive (`body_mass VIA log` resolves `[2520, 6480]`, a real 5% pad). The + trigger is `min - mult·span - add ≤ 0`, i.e. data spanning decades: `(1, 10, 100, + 1000) VIA log` resolves `[2.2250738585072014e-308, 1049.95]` because ggsql expands + in linear space and then clips to the transform's allowed domain (the ggsql-core + item below). The data then occupies the top ~1% of a 311-decade axis, and the + breaks land at `5e-308 … 1000`. **Both writers fail, differently**: VL emits a + 2498-character `axis.labelExpr` of denormal decimal literals and crushes every + point against the top of the panel; hephaestus renders an essentially *empty* + figure — chrome consumes the layout and only the `y` title survives. Fixing + expansion in ggsql fixes both; hephaestus-side expansion would only be a + fallback. - **No axis label thinning or rotation.** hephaestus's `Axis` is `rail(scale, placement)` + `title` only, with ticks coming solely from the scale, so long tick labels overlap in narrow facet panels (visible with binned @@ -1056,16 +1206,26 @@ extents". Two scoped exceptions remain, both of which would disappear if ggsql resolved per-panel domains and spatial position scales: - **Free facet scales**: `scales::{free_position_scale, free_binned_scale}` compute - per-panel domains (and select the per-panel bin window). + per-panel domains (and select the per-panel bin window). Narrowed: the *extent* + is still the writer's, but the padding around it is ggsql's via + `Scale::expand_range`. - **Spatial `pos1`/`pos2`**: synthesized in `mod.rs` from `computed["bbox"]` (or the geometry extent) because ggsql resolves no position scales for a spatial layer. ### Upstream ggsql-core (each also fixes the Vega-Lite writer) -- **Range expansion runs in linear data space then clips** to the transform's - valid domain, so a log domain collapses to `[f64::MIN_POSITIVE, max]` and its - breaks explode. Fix: expand in transform space. +- **Range expansion runs in linear data space then clips** to the transform's valid + domain (`resolve_common_steps` → `expand_numeric_range_selective`, then + `clip_to_transform_domain`), so a log domain whose padded minimum crosses zero + collapses to `[f64::MIN_POSITIVE, max]` and its breaks explode. Fix: expand in + transform space. This is the single worst open bug for *either* writer — see the + measured symptoms under "Correctness risks". +- `filter_breaks_to_range` only filters `ArrayElement::Number` and only when both + range endpoints are numbers, so it cannot constrain a **temporal** break: a + calendar-aligned major outside the resolved domain survives and both writers place + it off-panel. The minors path sidesteps this by filtering on `to_f64()`; the majors + path should do the same. - **`bar` on a numeric primary axis stays continuous** (no `pos1end`), so band-fraction bars get no width; VL hits the same wall (`bandwidth('x')` is 0). - **A data-mapped `linewidth` on a boxplot/violin is rejected by ggsql**: the stat @@ -1096,24 +1256,29 @@ resolved per-panel domains and spatial position scales: ### Upstream hephaestus -- `png::write_png` is file-only — no in-memory encode, so every host - re-implements byte encoding. -- No scale-level domain expansion / "nice" padding. -- Binned scales keep bin edges in the output range, so they can't also carry a - color/size range (see the binned-material bug above). -- Range labels (`"lower – upper"`) would be the right presentation for a binned - scale driving a **keyed** legend (size / shape), where the writer currently passes - ggsql's edge labels. Not urgent: binned color renders as a colorbar, where edge - labels on the band boundaries are correct. -- Chrome text (titles, axis labels, strip labels) can't be outlined: - `TextElement` has no stroke field; `text_stroke` is a geom channel only. -- The `text` feature's parley shaper is documented as scaffolding "meant to be - replaced by the host". -- Minor breaks ignore how sparse the supplied majors are: a temporal scale given a - single labelled break still emits sub-unit (daily) minors across the domain, - which reads as a dotted rail in a narrow panel. Deriving the minor interval from - the *supplied* majors, or letting a caller suppress minors, would fix it. -- `src/scales/` docs still claim transforms are Identity-only — stale. +Pinned at rev `5e9a060`. One item left, and it is a fallback rather than a gap the +writer can reach. The shape of everything that got resolved here: the writer's job +is to pass resolved values through, so wherever hephaestus had to compute something +itself, the fix was a missing *setter*, not a better algorithm. + +**No scale-level domain expansion / "nice" padding.** Not a gap in practice, and +no longer one anywhere the writer can reach. ggsql owns expansion: +`resolve_common_steps` applies `SETTING expand` via +`expand_numeric_range_selective` while resolving the scale, so `numeric_domain()` +is already padded before either writer sees it, and both pass it through verbatim +(`continuous_domain` → `scale::continuous(min..=max)`; VL's `build_scale_object` → +`scale.domain`). Neither writer uses its host's own padding — VL never emits `nice` +or `padding` either — so the two agree exactly on a fixed scale. + +The one place it used to cost something, **a free facet dimension**, is fixed: see +the expansion section below. VL solves the same problem by *delegating* — +`build_scale_object` skips `domain` when `is_free(...)` and the spec sets +`resolve.scale: independent`, so Vega derives each panel's domain and pads it — +whereas the hephaestus writer now asks ggsql for the factors and applies them to +the extent it computed. Both end up padded; ggsql's route additionally honours an +explicit `SETTING expand` per panel, which Vega's own padding would ignore. +What is left upstream is only the *fallback* case: a host with no resolved scale +at all still gets no padding from hephaestus. ### Standing constraints (accepted) diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index 2229cbe93..5392e54a8 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -26,6 +26,7 @@ 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; @@ -359,24 +360,10 @@ fn render_png( .render_to_buffer(width, height, background, &mut pixels) .map_err(|e| GgsqlError::WriterError(format!("hephaestus 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) -} - -/// Encode a premultiplied RGBA8 buffer as PNG bytes. -fn encode_png(width: u32, height: u32, rgba: &[u8]) -> Result> { - let mut buf = Vec::new(); - { - let mut encoder = png::Encoder::new(&mut buf, width, height); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - let mut header = encoder - .write_header() - .map_err(|e| GgsqlError::WriterError(format!("PNG header write failed: {e}")))?; - header - .write_image_data(rgba) - .map_err(|e| GgsqlError::WriterError(format!("PNG data write failed: {e}")))?; - } - Ok(buf) + .map_err(|e| GgsqlError::WriterError(format!("PNG encode failed: {e}"))) } #[cfg(all(test, feature = "duckdb"))] @@ -1050,6 +1037,27 @@ mod tests { )); } + #[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 diff --git a/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs index 487fedb5d..72fa7352d 100644 --- a/src/writer/hephaestus/scales.rs +++ b/src/writer/hephaestus/scales.rs @@ -113,6 +113,11 @@ pub fn build_scale(scale: Option<&GScale>, kind: RangeKind) -> Option { /// 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], @@ -164,7 +169,29 @@ fn free_continuous_scale( 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()) @@ -173,17 +200,42 @@ fn free_continuous_scale( .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 { - hs.with_breaks_labeled(labels) + 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(c) + 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, @@ -191,10 +243,14 @@ fn free_continuous_scale( /// /// 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 keeps its edges in the output range -/// and derives band width 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. +/// 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() { @@ -323,7 +379,11 @@ pub fn map_linetype(name: &str) -> Arc<[LinetypeStep]> { /// 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: Option) -> HScale { + let hs = apply_minor_breaks(hs, scale, type_kind); let labels = scale.break_labels(); if labels.is_empty() { return hs; @@ -364,6 +424,19 @@ fn apply_breaks(hs: HScale, scale: &GScale, type_kind: Option) -> } } +/// 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: Option) -> 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, Some(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. diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index ab38fee5d..2dbf8dca7 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -768,6 +768,15 @@ fn constant_material(ctx: &Ctx, aesthetic: &str, kind: RangeKind) -> Option Date: Mon, 10 Aug 2026 17:18:57 +0200 Subject: [PATCH 15/29] Update docs --- CHANGELOG.md | 92 ++------ CLAUDE.md | 9 +- src/CLAUDE.md | 2 +- src/writer/hephaestus/CLAUDE.md | 334 +++++++++++++++++++++++++++ src/writer/hephaestus/PLAN.md | 14 +- src/writer/hephaestus/geom/mod.rs | 6 +- src/writer/hephaestus/geom/violin.rs | 4 +- src/writer/hephaestus/mod.rs | 15 +- src/writer/hephaestus/scales.rs | 16 +- 9 files changed, 388 insertions(+), 104 deletions(-) create mode 100644 src/writer/hephaestus/CLAUDE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5259b29a0..95d7326e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,85 +5,33 @@ - New `HephaestusWriter` renders a plot to a PNG raster image via [hephaestus](https://github.com/posit-dev/hephaestus), behind a new off-by-default `hephaestus` feature (`--writer hephaestus` in the CLI). Covers - every layer type except `arrow`, all scale types and transforms, multi-layer - plots, `FACET` (Wrap/Grid, fixed and free scales), Cartesian/polar/map - projections, spatial geometry, and plot chrome (axes, legends, facet strips, - title/subtitle/caption). Requires a working GPU adapter — hardware or software, - e.g. lavapipe — at render time. + every layer type except `arrow`, all scale types and transforms, layer settings + and position adjustments, multi-layer plots, `FACET` (Wrap/Grid, fixed and free + scales), Cartesian/polar/map projections, spatial geometry, and plot chrome + (axes with major and minor gridlines, legends, facet strips, and + title/subtitle/caption spanning the whole figure). `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. - 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. Like `LABEL caption`, this has no Vega-Lite equivalent and is - ignored by that writer; the hephaestus writer draws them. -- `LABEL caption => '...'` now renders. It has no Vega-Lite equivalent and is - ignored by that writer, but the hephaestus writer places it below the plot. -- `LABEL title`/`subtitle` are rendered by the hephaestus writer, spanning the - whole figure for faceted plots. + transformation. This has no Vega-Lite equivalent and is ignored by that writer; + the hephaestus writer draws them. ### Fixed -- Minor gridlines in the hephaestus writer were generated from the axis domain - instead of from ggsql's breaks, so an axis with few major breaks — a temporal axis - narrowed to one break in a facet panel — was filled with sub-unit minors that read - as a dotted rail. Minor breaks are now resolved by ggsql alongside the majors and - the writer draws those (see the new `minor_breaks` setting above). -- A binned `size`, `shape` or `linetype` legend in the hephaestus writer drew one - key per bin *edge* — five keys for a four-bin ladder, each sized at an edge value, - implying a category that doesn't exist. It now draws one key per bin, sized at the - bin's midpoint, with the edge labels on a rail between the keys; a binned `color` - legend likewise becomes a stepped bar of one block per bin instead of a smooth - gradient. -- Panels of a `FACET` with free scales were not expanded in the hephaestus writer, so - a mark at a panel's extreme was clipped in half at the panel edge, and - `SCALE ... SETTING expand` stopped applying once that dimension was freed. Free - panels now get the scale's own expansion, like a fixed axis. -- Temporal axes and legends in the hephaestus writer were labelled with the raw - epoch number behind the date (`1208` for a `DATE`, `106358400000000` for a - `TIMESTAMP`), and a `RENAMING` on a temporal scale was ignored. They now read as - dates, honor `RENAMING`, and match the Vega-Lite writer. Scales are also built - calendar-aware, so ticks the writer doesn't supply itself — those of a free facet - panel — are dates rather than numbers. -- `segment`, `rule`, `ribbon` and `tile` layers under a map `PROJECT` now render - in the hephaestus writer. ggsql expands these into per-vertex rows so the edges - follow the projection's curvature, which the writer ignored — segments came out - zero-length, ribbons zero-height, a rule became a fan of straight lines, and - tiles a box per vertex. A non-spatial map is also framed to the map's bounding - box now, so its marks land on the clip boundary and keep the projection's - proportions, as they already did for `spatial` layers. -- The hephaestus writer drew a diagonal `rule` (abline) as a single solid line: it - ignored `linetype`, and a rule mapping several rows of slopes/intercepts - (`MAPPING slope AS slope, y AS y`) rendered only the first. It now draws one line - per row, honoring per-line `stroke`/`linetype`/`linewidth` — constant or - data-mapped, with a legend — like the Vega-Lite writer. -- A `violin` layer with several groups per category (from a mapped aesthetic or - `PARTITION BY`) merged them into a single contour in the hephaestus writer. Each - group is now its own violin, in both orientations. -- The hephaestus writer's `size`, `shape` and `linetype` legends drew empty - swatches next to their labels; the key glyphs are now painted (in the layer's - constant color, or a neutral grey when the color aesthetic is itself mapped). -- The hephaestus writer now honors the `hinge` and `side` settings. `hinge` caps a - `boxplot`'s whiskers and a `range`'s endpoints (10pt by default for a range, so - every range previously lost its hinges), and `side` draws a half `boxplot` or - `violin` on the chosen side of the band, so the two pair up on one category. - Horizontal (transposed) `boxplot` and `violin` layers also render — the first - failed with an internal error and the second drew nothing. -- `position => 'jitter'` and `position => 'dodge'` are honored by every layer type - in the hephaestus writer. Only bars, boxplots, violins and tiles used to move: - jittered points all landed on their category's centre line, and dodged points, - text, lines and ranges overplotted. Together with `side` this makes the - half-boxplot-plus-one-sided-jitter (raincloud) layout render. - -### Changed - -- The hephaestus writer draws a boxplot's median line at the layer's resolved - `linewidth` (default 1.0) instead of a fixed 1.5, matching the Vega-Lite - writer's median tick. `linewidth` and `linetype` now style every part of a - `boxplot` (box, whiskers, median, outlier markers) and both edges of a - `violin`, so `SETTING linewidth => 3, linetype => 'dashed'` is honored. -- The hephaestus writer labels binned facet strips with the bin's range - (`2500 – 3500`, or `≥ 5500` for a squished terminal bin) instead of the raw bin - midpoint, and applies `RENAMING` to discrete facet strips. +- `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. ## 0.4.1 - 2026-06-22 diff --git a/CLAUDE.md b/CLAUDE.md index 2151b8674..21b1c5085 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 via the hephaestus renderer (non-default `hephaestus` feature). -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) (raster). 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), with the phase log and deferred work in [`src/writer/hephaestus/PLAN.md`](src/writer/hephaestus/PLAN.md). - *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/src/CLAUDE.md b/src/CLAUDE.md index 12c6c483f..b8b3ca95e 100644 --- a/src/CLAUDE.md +++ b/src/CLAUDE.md @@ -68,7 +68,7 @@ The pipeline that takes a parsed `Plot` plus a `Reader` and produces a fully-res `Writer` trait in `mod.rs` (associated `Output` type so writers can return text or bytes). Two implementations: - **Vega-Lite** (`vegalite` feature, default) — emits Vega-Lite JSON. Deep-dive: [`writer/vegalite/CLAUDE.md`](writer/vegalite/CLAUDE.md). -- **hephaestus** (`hephaestus` feature, non-default) — renders PNG bytes via a GPU (wgpu/vello) backend. Design + phase log: [`writer/hephaestus/PLAN.md`](writer/hephaestus/PLAN.md), whose §9 lists deferred work. Excluded from the MSRV 1.86 build (hephaestus needs 1.88) and needs a GPU adapter at render time. +- **hephaestus** (`hephaestus` feature, non-default) — renders PNG bytes via a GPU (wgpu/vello) backend. Deep-dive: [`writer/hephaestus/CLAUDE.md`](writer/hephaestus/CLAUDE.md); design + phase log + deferred work (§9) in [`writer/hephaestus/PLAN.md`](writer/hephaestus/PLAN.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. diff --git a/src/writer/hephaestus/CLAUDE.md b/src/writer/hephaestus/CLAUDE.md new file mode 100644 index 000000000..7edd493f8 --- /dev/null +++ b/src/writer/hephaestus/CLAUDE.md @@ -0,0 +1,334 @@ +# `writer/hephaestus/` — raster (PNG) writer internals + +`HephaestusWriter` 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 `hephaestus` cargo feature. + +Two companion documents, with different jobs: + +- **[`PLAN.md`](PLAN.md)** — the design of record and the **phase log**: why this + writer exists, what each work item changed, what was verified, and §9's + inventory of everything deliberately deferred (bugs, upstream items, accepted + constraints). Read it when you need *history* or *known gaps*. +- **This file** — the **architecture**: the abstractions, the invariants, and how + to extend them. Read it when you need to *change the code*. + +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 in +PLAN.md §9 as 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. | + +## 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. + +``` +HephaestusWriter::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 + ├─ 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(h/w).aspect_mode(Range) + │ ├─ 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) | `HephaestusWriter` (size / dpi / background), `Writer` impl, 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). | +| `data_channels` | Per-row values the geom computes itself (bar/tile band edges). 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 swatch, so a line legend shows a line. | +| `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 | 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. + +### `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, so `'top'`/`'right'` are positive in +*either* orientation — one predicate, where the Vega-Lite writer flips 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. + +| 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` / `alpha` | +| Geometry / text | `geometry`; `text`, `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. +- **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"). + +## 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; a grid cell with no data in any layer is + skipped, leaving an empty framed panel. +- **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`. + +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. + +## 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. + +One subtlety in `material_legend`: a legend key paints only what it is told to +paint, so a non-color scaled channel (`size`, `shape`, `linetype`) needs a fixed +body color or the swatch comes out empty. `key_color` supplies the layer's +constant `fill`/`stroke`, skipping a *data-mapped* color aesthetic — its column +holds domain values, not colors. + +## 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, needs unit conversion, or computes its positions. + Even then, route materials through `wire_material` / `resolve_material` rather + than hand-rolling them — that is what keeps data-mapped aesthetics working. +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 hephaestus --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** (PLAN.md §9). Visual correctness is + verified by eyeballing, usually against the Vega-Lite render of the same + query. With a moving pinned hephaestus rev, assume a bump needs re-eyeballing: + +```sh +cargo run -p ggsql-cli --features hephaestus -- exec "" \ + --reader "duckdb://memory" --writer hephaestus --output /tmp/out.png +``` + +## 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. +- **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 hephaestus 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 a pinned git rev** on an unpublished `0.0.1` crate + (`src/Cargo.toml`). crates.io rejects git dependencies even when optional, so + this blocks publishing ggsql — the one item in PLAN.md §9 that is a release + blocker rather than polish. + +## See also + +- [`PLAN.md`](PLAN.md) — design of record, phase log, and §9's deferred work. +- [`../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/PLAN.md b/src/writer/hephaestus/PLAN.md index b46185766..3b8df96a8 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -10,7 +10,8 @@ Status: **implemented** behind the non-default `hephaestus` feature — all geom except `arrow`, all scale types, multi-layer, faceting (fixed + free), Cartesian / Polar / Map projections, spatial, and plot chrome (titles, axes, legends, strips). This document is the design of record and the phase log; §9 lists the -work deliberately deferred. +work deliberately deferred. The *architecture* — abstractions, invariants, how to +extend them — lives in [`CLAUDE.md`](CLAUDE.md). ## 1. Why this is a good fit @@ -1145,13 +1146,10 @@ here so it survives between efforts. `0.0.1` crate. crates.io rejects git dependencies **even when optional**, so ggsql cannot be published while this dep exists in that form. This is the one item that blocks a release rather than polish. -- No `src/writer/hephaestus/CLAUDE.md` (planned in §5). This document is doing - that job and is a phase log, not an architecture doc. -- `src/CLAUDE.md` is stale: no `hephaestus` row in the feature table, and the - `writer/` section still says "Only Vega-Lite is implemented today". -- CLI: no output-extension routing, and no flags for width/height/dpi/background, - so only the one hardcoded `HephaestusWriter::new(1500, 1000, 300.0)` with a - transparent background is reachable. +- CLI: `--writer hephaestus` reaches the writer, but there is no + output-extension routing and no flags for width/height/dpi/background, so only + the one hardcoded `HephaestusWriter::new(1500, 1000, 300.0)` with a transparent + background is reachable. - `doc/` doesn't mention raster output at all. - Default-writer switchover criteria still undecided (Decision 4). diff --git a/src/writer/hephaestus/geom/mod.rs b/src/writer/hephaestus/geom/mod.rs index 84eb2ca7d..fa9d32505 100644 --- a/src/writer/hephaestus/geom/mod.rs +++ b/src/writer/hephaestus/geom/mod.rs @@ -1,6 +1,7 @@ //! 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) are Phase 3b. +//! 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; @@ -23,7 +24,8 @@ use super::wiring::{build_and_add, Ctx}; use crate::plot::layer::geom::GeomType; use crate::{GgsqlError, Result}; -/// Build the layer's geom into `plot`, recording its scales/axes/legends in `w`. +/// 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`). diff --git a/src/writer/hephaestus/geom/violin.rs b/src/writer/hephaestus/geom/violin.rs index 661824ea7..b62617bf2 100644 --- a/src/writer/hephaestus/geom/violin.rs +++ b/src/writer/hephaestus/geom/violin.rs @@ -2,8 +2,8 @@ //! (`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 row per grid -//! sample — no hand-built outline. +//! 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 diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index 5392e54a8..009f1f9ed 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -3,12 +3,15 @@ //! Renders a resolved ggsql `Spec` to PNG bytes via the [`hephaestus`] 2D scene //! renderer. //! -//! **Scope** (see `src/writer/hephaestus/PLAN.md`): multi-layer plots under -//! Cartesian, Polar, and Map projections, with `FACET` faceting (Wrap/Grid, -//! fixed + free scales). All geoms (point/line/path/area/ribbon/bar/histogram/ -//! tile/polygon/segment/rule/range/text/density/smooth/boxplot/violin), all -//! scale types/transforms, material aesthetics, axis titles, and legends are -//! supported. Unsupported geoms are rejected by [`HephaestusWriter::validate`]. +//! **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 +//! [`HephaestusWriter::validate`]. +//! +//! Architecture — the abstractions and the invariants they keep — is documented +//! in `src/writer/hephaestus/CLAUDE.md`; `PLAN.md` holds the design rationale and +//! the inventory of deferred work. //! //! Rendering uses hephaestus's Vello (GPU) backend, so a working wgpu adapter //! (hardware or software, e.g. lavapipe) is required at render time. diff --git a/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs index 72fa7352d..391e4a7da 100644 --- a/src/writer/hephaestus/scales.rs +++ b/src/writer/hephaestus/scales.rs @@ -37,10 +37,8 @@ pub enum RangeKind { Linetype, } -/// Build a hephaestus scale from a resolved ggsql scale. -/// -/// `data_extent` is the finite (min, max) of the channel's data, used as the -/// domain fallback when the ggsql scale carries none (continuous scales only). +/// 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: Option<&GScale>, kind: RangeKind) -> Option { // No resolved scale type → no scale to register. ggsql is the source of scale // truth; the writer never fabricates one. @@ -93,7 +91,7 @@ pub fn build_scale(scale: Option<&GScale>, kind: RangeKind) -> Option { // 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 now uses, so they line up. `apply_breaks` is a no-op when + // domain hephaestus reads, so they line up. `apply_breaks` is a no-op when // the scale has no resolved breaks. if let Some(scale) = scale { hs = apply_breaks(hs, scale, Some(type_kind)); @@ -402,10 +400,10 @@ fn apply_breaks(hs: HScale, scale: &GScale, type_kind: Option) -> 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 (see - // PLAN.md §9 — it currently maps break positions through `binned_map`, which - // sends every value to its bin's centre). Composite "lower – upper" range - // labels belong to keyed legends and facet strips, not axes. + // 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 From 604e400ce9ecc09c06b0db9086a262b8abdb1235 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 10 Aug 2026 19:55:37 +0200 Subject: [PATCH 16/29] add --output-option and shorthands --- CHANGELOG.md | 13 ++ doc/get_started/tooling/cli.qmd | 43 ++++- ggsql-cli/CLAUDE.md | 2 + ggsql-cli/src/main.rs | 172 +++++++++++++------ src/CLAUDE.md | 2 +- src/doc/API.md | 53 +++++- src/writer/hephaestus/CLAUDE.md | 23 ++- src/writer/hephaestus/PLAN.md | 58 ++++++- src/writer/hephaestus/mod.rs | 203 +++++++++++++++++++++- src/writer/mod.rs | 24 +++ src/writer/options.rs | 293 ++++++++++++++++++++++++++++++++ src/writer/vegalite/mod.rs | 9 +- 12 files changed, 829 insertions(+), 66 deletions(-) create mode 100644 src/writer/options.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 95d7326e7..96d5fc2b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,19 @@ `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 hephaestus 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`. - 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 diff --git a/doc/get_started/tooling/cli.qmd b/doc/get_started/tooling/cli.qmd index dc7a484ac..464ac1338 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 `hephaestus` writer can also render straight to a PNG image with `--writer hephaestus`, 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 hephaestus \ + --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 hephaestus -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 hephaestus 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/ggsql-cli/CLAUDE.md b/ggsql-cli/CLAUDE.md index c6ed59703..19d0f632a 100644 --- a/ggsql-cli/CLAUDE.md +++ b/ggsql-cli/CLAUDE.md @@ -32,6 +32,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 diff --git a/ggsql-cli/src/main.rs b/ggsql-cli/src/main.rs index be3b10346..934455e43 100644 --- a/ggsql-cli/src/main.rs +++ b/ggsql-cli/src/main.rs @@ -7,7 +7,7 @@ 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; +use ggsql::writer::{Writer, WriterOptions}; use ggsql::{parser, VERSION}; use std::io::{IsTerminal, Write}; use std::path::PathBuf; @@ -16,7 +16,7 @@ use std::path::PathBuf; use ggsql::writer::VegaLiteWriter; #[cfg(feature = "hephaestus")] -use ggsql::writer::{rgba, HephaestusWriter}; +use ggsql::writer::HephaestusWriter; mod docs { include!(concat!(env!("OUT_DIR"), "/docs_data.rs")); @@ -33,9 +33,30 @@ pub struct Cli { 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 = "hephaestus"), 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 @@ -44,16 +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 (JSON), or hephaestus (PNG; requires the /// `hephaestus` feature and a GPU adapter) - #[arg(long, default_value = "vegalite")] + #[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 + /// hephaestus 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) @@ -67,16 +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 (JSON), or hephaestus (PNG; requires the /// `hephaestus` feature and a GPU adapter) - #[arg(long, default_value = "vegalite")] + #[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 + /// hephaestus 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) @@ -100,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, }, @@ -159,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 } => { @@ -205,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) => { @@ -215,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()); } @@ -234,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, ) { @@ -320,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:"); @@ -334,11 +397,11 @@ fn render_spec(spec: Spec, writer: &str, output: Option, verbose: bool) std::process::exit(1); } - let render = match writer { - "vegalite" => render_vegalite(&spec), - "hephaestus" => render_hephaestus(&spec), + let render = match writer.name.as_str() { + "vegalite" => render_vegalite(&spec, &writer.options), + "hephaestus" => render_hephaestus(&spec, &writer.options), _ => { - eprintln!("\nNote: Writer '{}' not yet implemented", writer); + eprintln!("\nNote: Writer '{}' not yet implemented", writer.name); std::process::exit(1) } }; @@ -727,49 +790,54 @@ fn cmd_skill(format: Option) { } } -fn render_vegalite(spec: &Spec) -> Output { - #[cfg(not(feature = "vegalite"))] - { - eprintln!("VegaLite writer not compiled in. Rebuild with --features vegalite"); - std::process::exit(1) - } - - let json_output; +fn render_vegalite(spec: &Spec, options: &WriterOptions) -> Output { #[cfg(feature = "vegalite")] { - // Render - let vl_writer = VegaLiteWriter::new(); - json_output = match vl_writer.render(spec) { - Ok(r) => r, + // 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); } - }; - }; - Output::Text(json_output) -} - -fn render_hephaestus(spec: &Spec) -> Output { - #[cfg(not(feature = "hephaestus"))] + } + } + #[cfg(not(feature = "vegalite"))] { - eprintln!("Hephaestus writer not compiled in. Rebuild with --features hephaestus"); + let _ = (spec, options); + eprintln!("VegaLite writer not compiled in. Rebuild with --features vegalite"); std::process::exit(1) } +} - let png_output; +fn render_hephaestus(spec: &Spec, options: &WriterOptions) -> Output { #[cfg(feature = "hephaestus")] { - // Render - let hs_writer = - HephaestusWriter::new(1500, 1000, 300.0).background(rgba(0.0, 0.0, 0.0, 0.0)); - png_output = match hs_writer.render(spec) { - Ok(r) => r, + // Configure from --writer-option, then render + let hs_writer = unwrap_writer(HephaestusWriter::from_options(options)); + match hs_writer.render(spec) { + Ok(png) => Output::Bin(png), Err(e) => { eprintln!("Failed to generate Hephaestus output: {}", e); std::process::exit(1); } - }; - }; - Output::Bin(png_output) + } + } + #[cfg(not(feature = "hephaestus"))] + { + let _ = (spec, options); + eprintln!("Hephaestus writer not compiled in. Rebuild with --features hephaestus"); + 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 = "hephaestus"))] +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 b8b3ca95e..a64c1da59 100644 --- a/src/CLAUDE.md +++ b/src/CLAUDE.md @@ -65,7 +65,7 @@ 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). Two implementations: +`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). - **hephaestus** (`hephaestus` feature, non-default) — renders PNG bytes via a GPU (wgpu/vello) backend. Deep-dive: [`writer/hephaestus/CLAUDE.md`](writer/hephaestus/CLAUDE.md); design + phase log + deferred work (§9) in [`writer/hephaestus/PLAN.md`](writer/hephaestus/PLAN.md). Excluded from the MSRV 1.86 build (hephaestus needs 1.88) and needs a GPU adapter at render time. diff --git a/src/doc/API.md b/src/doc/API.md index 2dea4edc2..96938e62a 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 = HephaestusWriter::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, `HephaestusWriter` takes `width`, `height`, `units`, +`dpi`, and `background`. + diff --git a/src/writer/hephaestus/CLAUDE.md b/src/writer/hephaestus/CLAUDE.md index 7edd493f8..c7c0373de 100644 --- a/src/writer/hephaestus/CLAUDE.md +++ b/src/writer/hephaestus/CLAUDE.md @@ -39,6 +39,27 @@ PLAN.md §9 as debt that would disappear if ggsql resolved more: | 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`. +`HephaestusWriter::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 @@ -78,7 +99,7 @@ Layers draw in `spec.layers` order, which is DRAW order, which is z-order. | File | Role | | --- | --- | -| [`mod.rs`](mod.rs) | `HephaestusWriter` (size / dpi / background), `Writer` impl, the orchestration above, `map_bbox`, `render_png`, and the writer's test suite. | +| [`mod.rs`](mod.rs) | `HephaestusWriter` (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. | diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index 3b8df96a8..4260ea20d 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -1113,6 +1113,54 @@ plus `SETTING minor_breaks => 3` (three gridlines per interval), `=> 0` (none), of each setting form, and the rejection; full suite 1793 tests + 24 doctests pass; fmt, clippy clean. +## Writer options — status: implemented + +The writer's canvas was only reachable through Rust: the CLI hardcoded +`HephaestusWriter::new(1500, 1000, 300.0)` with a transparent background, so no +user could pick a size, a resolution, or a background. Fixed generically rather +than with four hephaestus-specific flags, because "a writer takes settings" is a +property of the `Writer` abstraction, not of this writer. + +- **`WriterOptions`** (`src/writer/options.rs`) is a normalised key–value bag — + keys trimmed, lowercased, `-` folded to `_` — built by `parse` from + `key=value` strings and read through accessors that produce the user-facing + error themselves: `number`, `one_of`, `get`, and `reject_unknown`. One string + may carry several options separated by **`;`**, so a caller can spell them out + or collapse them, and mix the two. `;` is the only separator: `,` is common + *inside* a value and `background=rgba(0,0,0,0)` has to survive. Values + otherwise keep everything after the first `=`. +- **`Writer::from_options`** is a required trait method, so every writer answers + the question and a frontend needs no compile-time knowledge of which one was + chosen. Vega-Lite's implementation is `reject_unknown(&[])` — its output is + resolution-independent, so size, DPI and background belong to whoever renders + the spec. +- **hephaestus's** implementation takes `width`, `height`, `units`, `dpi`, and + `background`, documented in [`CLAUDE.md`](CLAUDE.md#configuration). Two + decisions worth keeping: `units` interprets only the dimensions the caller + supplies (the defaults stay pixel counts, so `units=in` with no `height` is + coherent), and the default background is now **white**, matching the writer's + own `new()` and `ggsave`'s default rather than the CLI's old transparent + canvas — `background=transparent` (or `none`) gets it back. +- **The CLI** collects a repeatable `--writer-option key=value` on `exec` and + `run` — short `-D`, visible alias `--writer-options` — into a + `WriterSpec { name, options }`, so the writer name and its settings travel + together through `cmd_exec` → `exec_with_reader` → `render_spec`. An option + error exits non-zero with the message; nothing is ignored. `-D` follows the + gcc / java / cmake convention for a pass-through key=value, which also leaves + `-r`, `-w` and `-o` to mean `--reader`, `--writer` and `--output` — the shorts + a single letter next to those flags would otherwise be misread as, and all + three now exist. + +Verified: `width`/`height` in each unit render at the expected pixel dimensions +(6×4 in at 150 dpi → 900×600, 2.54 cm / 25.4 mm / 72 pt at 96 dpi → 96 px), +`background` accepts hex, names, `rgb()`, `hsl()`, `transparent`; unknown keys, +non-numeric values, a zero or absurd dimension, a bad unit and a bad color each +report the offending option. The collapsed form renders identically to the +spelled-out one under single quotes, double quotes and `\;`, and mixes with +repeated flags; unquoted in zsh the shell splits the command, which is why the +docs lead with the quoting. Eighteen new tests (twelve on `WriterOptions`, six on +`from_options`, neither needing a reader or a GPU); fmt and clippy clean. + ## 8. Key source references ggsql: @@ -1146,11 +1194,11 @@ here so it survives between efforts. `0.0.1` crate. crates.io rejects git dependencies **even when optional**, so ggsql cannot be published while this dep exists in that form. This is the one item that blocks a release rather than polish. -- CLI: `--writer hephaestus` reaches the writer, but there is no - output-extension routing and no flags for width/height/dpi/background, so only - the one hardcoded `HephaestusWriter::new(1500, 1000, 300.0)` with a transparent - background is reachable. -- `doc/` doesn't mention raster output at all. +- CLI: `--writer hephaestus` plus `--writer-option` reach the writer and + configure it, but there is still no output-extension routing — `--output + chart.png` does not by itself select the raster writer. +- `doc/` covers raster output only in the CLI page's writer-options section; the + gallery and the rest of the site are Vega-Lite throughout. - Default-writer switchover criteria still undecided (Decision 4). ### Correctness risks diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index 009f1f9ed..a99f6ce0e 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -40,15 +40,46 @@ 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; +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; + +/// Option keys [`HephaestusWriter::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 via hephaestus. /// /// Configured with a target pixel size and DPI because raster rendering needs /// concrete dimensions, unlike the resolution-independent Vega-Lite writer. +/// [`HephaestusWriter::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 HephaestusWriter { width: u32, height: u32, @@ -74,9 +105,56 @@ impl HephaestusWriter { } } +impl Default for HephaestusWriter { + fn default() -> Self { + Self::new(DEFAULT_WIDTH, DEFAULT_HEIGHT, DEFAULT_DPI) + } +} + impl Writer for HephaestusWriter { 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( @@ -271,6 +349,32 @@ impl Writer for HephaestusWriter { } } +/// 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 @@ -369,6 +473,103 @@ fn render_png( .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 { + HephaestusWriter::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 = HephaestusWriter::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::*; diff --git a/src/writer/mod.rs b/src/writer/mod.rs index 9b9d76c46..a864435c8 100644 --- a/src/writer/mod.rs +++ b/src/writer/mod.rs @@ -23,11 +23,19 @@ //! 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; @@ -53,6 +61,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/mod.rs b/src/writer/vegalite/mod.rs index e7e62b882..448e4b931 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; @@ -1124,6 +1124,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)?; From 5cbf7dbb6207b6d11ce4aa9eefa9b59f52765e80 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 10 Aug 2026 21:37:34 +0200 Subject: [PATCH 17/29] Fix bug mapping raw values to scales --- src/writer/hephaestus/CLAUDE.md | 33 ++++++++++- src/writer/hephaestus/PLAN.md | 85 +++++++++++++++++++++++++++ src/writer/hephaestus/geom/boxplot.rs | 8 +-- src/writer/hephaestus/geom/violin.rs | 3 +- src/writer/hephaestus/wiring.rs | 77 ++++++++++++++++++------ 5 files changed, 182 insertions(+), 24 deletions(-) diff --git a/src/writer/hephaestus/CLAUDE.md b/src/writer/hephaestus/CLAUDE.md index c7c0373de..a63873bc7 100644 --- a/src/writer/hephaestus/CLAUDE.md +++ b/src/writer/hephaestus/CLAUDE.md @@ -128,7 +128,7 @@ for the minimal case): | `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). | +| `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). 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 swatch, so a line legend shows a line. | | `grouped: bool` | Derive hephaestus `keys` from `layer.partition_by`, for multi-vertex marks (line, area, polygon). | @@ -142,7 +142,7 @@ 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 | Constant channel value (`set_literal_channel` / `constant_material`), converted by `RangeKind` | +| `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` | @@ -150,6 +150,19 @@ the same three ways: 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 @@ -328,6 +341,22 @@ cargo run -p ggsql-cli --features hephaestus -- exec "" \ --reader "duckdb://memory" --writer hephaestus --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 hephaestus --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 diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index 4260ea20d..3a9eb670f 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -1161,6 +1161,84 @@ repeated flags; unquoted in zsh the shell splits the command, which is why the docs lead with the quoting. Eighteen new tests (twelve on `WriterOptions`, six on `from_options`, neither needing a reader or a GPU); fmt and clippy clean. +## Visual test harness — status: implemented + +Every phase above ended in "eyeballed", one query at a time, against whatever +cases the work happened to touch. That is how the late-phase omissions kept +surfacing: nothing ever rendered the *whole* feature surface at once. The docs +already contain a curated corpus that does — every executable ```` ```{ggsql} ```` +cell in [`/doc/`](../../../doc/) — so the harness renders that corpus instead of +inventing a new one. + +[`/ggsql-cli/examples/visual_test.rs`](../../../ggsql-cli/examples/visual_test.rs), +run as `cargo run -p ggsql-cli --features hephaestus --example visual_test`, writes +`target/visual-test/index.html`: one HTML page pairing each query with its render, +with `--compare` putting the Vega-Lite render of the **same `Spec`** beside it. +It is a developer tool, not a shipped feature — `[[example]]`'s `required-features` +keeps it out of `cargo test --workspace`. Implementation notes live in +[`/ggsql-cli/CLAUDE.md`](../../../ggsql-cli/CLAUDE.md); three properties matter +here: + +- **The corpus runs like the docs run.** One reader per source file, cells in + document order, so a page that builds a table in one cell and plots it in the + next behaves as written. A cell with no `VISUALISE` runs as setup. +- **Nothing aborts the run.** An execution error, a render error, or a *panic* + inside a writer is captured against its cell, so one report inventories every + problem at once. A harness that stops at the first failure would answer the + question this one exists to answer only for the first cell. +- **It is a display harness, not a snapshot suite.** There is still no baseline + and no automated pass/fail on pixels (§9 Testing) — the judgement stays human. + What changed is the cost of exercising it: 190 queries in one pass instead of + one `--output /tmp/out.png` at a time. + +The report labels each cell with its source file and line, so anything it turns +up points straight back at the query that produced it. + +Verified: 191 cells from 33 files under `doc/syntax/` in ~160 s (185 plots, 6 +setup cells), each rendered beside its Vega-Lite twin and eyeballed. The first +full run found the two constant-channel bugs fixed in the section below — +exactly the class of omission that one-query-at-a-time eyeballing had been +missing. + +## Constant channels — status: implemented + +The first full harness run turned up two bugs with one root: **the writer set +values it had resolved itself as plain hephaestus channel constants**, and a +`Channel::Constant` is *scale-applied* (`resolve.rs::resolve_value`; only the +`Raw*` variants bypass). A binding, meanwhile, belongs to the plot **channel**, +not to the geom that set it. So a constant only behaved as intended while no +other layer bound the same channel. + +- **A non-diagonal `rule` panicked.** `SegmentGeom::build: "x" must be data, not + constant — positions vary per row`. `segment::rule` spans the free axis with a + 0..1 panel fraction through `GeomSpec::raw_numbers`, which `build_and_add` set + as a scalar; hephaestus's `require_data_column` rejects a constant on any + position channel of a geom whose geometry varies per row. Now materialised one + value per row. (The diagonal abline never hit this — `build_diagonal` already + computes per-row endpoints.) Four cells in `doc/syntax/layer/type/rule.qmd`. +- **A constant material vanished next to a data-mapped sibling.** `DRAW line … + DRAW rule MAPPING label AS colour` bound `stroke` to a categorical scale for + the *whole panel*, so the line's constant black was looked up in + `{Critical, Target, Warning}`, resolved to `Null`, and the line simply wasn't + drawn — silently, in a plot that otherwise looked right. Every writer-resolved + constant is now `Raw`: `set_literal_channel` (literals and `SETTING`s), + `MatDefault` (geom defaults), `MaterialSource::Constant` (composites), and the + composites' own `size`/`shape`/`fill_opacity`/`alpha`. Per-row band fractions + and offsets are untouched — no scale is ever bound to a `_band` channel. + +The rule that falls out, now stated in [`CLAUDE.md`](CLAUDE.md): **only a +ggsql-mapped column goes through a scale; everything the writer resolves itself +is `Raw`.** For symmetry `wire_positions` gained `constant_position`, so a +position arriving as a bare `Literal` is materialised per row and still travels +through its position scale instead of erroring — every geom now accepts every +form ggsql delivers an aesthetic in. + +Verified: all five `rule` forms render (horizontal, vertical, N data-driven lines +with a collapsed legend, `aggregate => 'max'`, diagonal abline), the line+rule +overlay draws both layers, 91 writer tests pass, and a full harness re-run over +`doc/syntax/` is clean — 191 cells, 0 problems, all eyeballed against the +Vega-Lite renders. fmt and clippy clean. + ## 8. Key source references ggsql: @@ -1343,3 +1421,10 @@ The writer's tests are render-succeeds smoke tests plus exact-text assertions fo strip labels and bin labelling, backed by manual eyeballing. §6 planned **snapshot PNG tests** and they don't exist — there is no automated protection against visual regression, which matters with a moving pinned rev. + +The visual test harness narrows this but does not close it: it renders the whole +doc corpus into one report (see the section above), so a rev bump can be +re-eyeballed in a single pass, and an error or panic is *reported* per cell. It +still compares nothing against a baseline. The remaining step is to keep a +committed set of reference PNGs and diff against them — the harness's per-cell +naming (`-.png`) is already stable enough to serve as one. diff --git a/src/writer/hephaestus/geom/boxplot.rs b/src/writer/hephaestus/geom/boxplot.rs index 8ee444c02..27d398dce 100644 --- a/src/writer/hephaestus/geom/boxplot.rs +++ b/src/writer/hephaestus/geom/boxplot.rs @@ -10,7 +10,7 @@ //! `pos2` and its values in the `pos1` family. use hephaestus::color::rgb8; -use hephaestus::plot::geom::{BuildableGeom, GeomBuilder}; +use hephaestus::plot::geom::{BuildableGeom, GeomBuilder, Raw}; use hephaestus::plot::{Plot as HPlot, PointGeom, RectGeom, SegmentGeom}; use super::super::channels::{ @@ -127,7 +127,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { fill.apply(&mut b, "fill", &box_i); stroke.apply(&mut b, "stroke", &box_i); outline(&mut b, &linewidth, linetype.as_ref(), &box_i); - b.set("fill_opacity", alpha); + b.set("fill_opacity", Raw(alpha)); plot.add_geom(b.build()); } @@ -185,8 +185,8 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { stroke.apply(&mut b, "stroke", &out_i); // `PointGeom` has no dash pattern — a marker outline can't be dashed. outline(&mut b, &linewidth, None, &out_i); - b.set("size", constant_number(ctx, "size", 3.0)); - b.set("shape", constant_string(ctx, "shape", "circle")); + b.set("size", Raw(constant_number(ctx, "size", 3.0))); + b.set("shape", Raw(constant_string(ctx, "shape", "circle"))); plot.add_geom(b.build()); } diff --git a/src/writer/hephaestus/geom/violin.rs b/src/writer/hephaestus/geom/violin.rs index b62617bf2..be92da2cb 100644 --- a/src/writer/hephaestus/geom/violin.rs +++ b/src/writer/hephaestus/geom/violin.rs @@ -13,6 +13,7 @@ use std::cmp::Ordering; use std::collections::HashMap; use hephaestus::color::rgb8; +use hephaestus::plot::geom::Raw; use hephaestus::plot::{Plot as HPlot, RibbonGeom}; use super::super::channels::{ @@ -175,7 +176,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { } } } - b.set("alpha", constant_number(ctx, "opacity", 1.0)); + b.set("alpha", Raw(constant_number(ctx, "opacity", 1.0))); plot.add_geom(b.build()); Ok(()) diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index 2dbf8dca7..eeb594d2d 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -133,6 +133,10 @@ pub struct GeomSpec { 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). @@ -167,7 +171,7 @@ where } } for (channel, value) in &spec.raw_numbers { - builder.set(*channel, Raw(*value)); + builder.set(*channel, Raw(vec![*value; ctx.df.height()])); } for (channel, values) in spec.data_channels { builder.set(channel, values); @@ -195,14 +199,19 @@ fn wire_positions( ) -> Result<()> { let offsets = AxisOffsets::new(ctx.df); for p in positions { - let col = aesthetic_column_name(ctx.layer, &p.aesthetic).ok_or_else(|| { - GgsqlError::WriterError(format!( - "{} layer has no {} mapping", - ctx.layer.geom.geom_type(), - p.aesthetic - )) - })?; - let data = column_to_channel(ctx.df, col)?; + 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(|| { + GgsqlError::WriterError(format!( + "{} layer has no {} mapping", + ctx.layer.geom.geom_type(), + p.aesthetic + )) + })?, + }; data.apply(builder, p.channel); plot.set_binding(p.channel, ctx.pos_scale(p.axis)); @@ -216,6 +225,29 @@ fn wire_positions( 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` @@ -312,18 +344,20 @@ pub fn wire_material( } } - // Defaults for channels no spec mapped. + // 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, c); + builder.set(m.channel, Raw(c)); handled.insert(m.channel); } MatDefault::Number(n) => { - builder.set(m.channel, n); + builder.set(m.channel, Raw(n)); handled.insert(m.channel); } MatDefault::None => {} @@ -338,6 +372,13 @@ pub fn wire_material( /// 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, @@ -347,21 +388,21 @@ fn set_literal_channel( match (kind, lit) { (RangeKind::Color, ParameterValue::String(s)) => match parse_color(s) { Some(c) => { - builder.set(channel, c); + builder.set(channel, Raw(c)); true } None => false, }, (RangeKind::Shape, ParameterValue::String(s)) => { - builder.set(channel, s.clone()); + builder.set(channel, Raw(s.clone())); true } (RangeKind::Linetype, ParameterValue::String(s)) => { - builder.set(channel, HValue::Linetype(map_linetype(s))); + builder.set(channel, Raw(HValue::Linetype(map_linetype(s)))); true } (RangeKind::Number, ParameterValue::Number(n)) if n.is_finite() => { - builder.set(channel, *n); + builder.set(channel, Raw(*n)); true } _ => false, @@ -602,8 +643,10 @@ impl MaterialSource { ) { 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, v.clone()); + builder.set(channel, Raw(v.clone())); } } } From 728179f137a15645b4cdbcad2c066714c488777f Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Tue, 11 Aug 2026 13:31:01 +0200 Subject: [PATCH 18/29] Fixes based on visual inspection --- CHANGELOG.md | 45 +- Cargo.lock | 2 +- ggsql-cli/CLAUDE.md | 24 + ggsql-cli/Cargo.toml | 7 + ggsql-cli/examples/visual_test.rs | 770 ++++++++++++++++++++++++++ src/Cargo.toml | 2 +- src/plot/layer/orientation.rs | 31 +- src/plot/scale/breaks.rs | 14 +- src/plot/scale/scale_type/mod.rs | 11 +- src/plot/scale/types.rs | 34 +- src/writer/hephaestus/CLAUDE.md | 78 ++- src/writer/hephaestus/PLAN.md | 72 ++- src/writer/hephaestus/channels.rs | 33 +- src/writer/hephaestus/geom/area.rs | 17 + src/writer/hephaestus/geom/boxplot.rs | 49 +- src/writer/hephaestus/geom/rect.rs | 17 +- src/writer/hephaestus/geom/spatial.rs | 50 +- src/writer/hephaestus/geom/text.rs | 182 ++++-- src/writer/hephaestus/geom/violin.rs | 66 ++- src/writer/hephaestus/mod.rs | 24 +- src/writer/hephaestus/projection.rs | 24 +- src/writer/hephaestus/scales.rs | 124 ++++- src/writer/hephaestus/wiring.rs | 288 +++++++--- src/writer/vegalite/encoding.rs | 19 +- src/writer/vegalite/mod.rs | 19 +- 25 files changed, 1772 insertions(+), 230 deletions(-) create mode 100644 ggsql-cli/examples/visual_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 96d5fc2b4..8058bd37a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,8 +33,35 @@ transformation. This has no Vega-Lite equivalent and is ignored by that writer; the hephaestus writer draws them. +### Changed + +- 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. + ### Fixed +- `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`. +- 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` @@ -69,18 +96,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 @@ -91,7 +118,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/Cargo.lock b/Cargo.lock index f3ed72849..cb1d2e11f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2497,7 +2497,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hephaestus" version = "0.0.1" -source = "git+https://github.com/posit-dev/hephaestus.git?rev=5e9a06042af976b54ace5a0ac135d564a4b12ef2#5e9a06042af976b54ace5a0ac135d564a4b12ef2" +source = "git+https://github.com/posit-dev/hephaestus.git?rev=a3536987c595e7574ef62e764fc6e3ad482c3370#a3536987c595e7574ef62e764fc6e3ad482c3370" dependencies = [ "bytemuck", "clipper2-rust", diff --git a/ggsql-cli/CLAUDE.md b/ggsql-cli/CLAUDE.md index 19d0f632a..fc2d4b9cb 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 ``` @@ -75,9 +77,31 @@ 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 hephaestus --example visual_test # doc/syntax +cargo run -p ggsql-cli --features hephaestus --example visual_test -- --compare # + Vega-Lite side by side +cargo run -p ggsql-cli --features hephaestus --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. + +Three 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`. +- **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); + } + + 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()); + sources.push(Source { + label, + title, + 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/src/Cargo.toml b/src/Cargo.toml index bc9b9a77e..53f0fbb8f 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -40,7 +40,7 @@ adbc_core = { version = "0.23", optional = true } geozero = { workspace = true, optional = true, features = ["with-wkb", "with-wkt", "with-geojson"] } # Hephaestus raster writer (non-default; gated, excluded from the MSRV 1.86 build) -hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "5e9a06042af976b54ace5a0ac135d564a4b12ef2", optional = true, default-features = false, features = ["vello", "png", "text", "geom-wkb", "geom-wkt"] } +hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "a3536987c595e7574ef62e764fc6e3ad482c3370", optional = true, default-features = false, features = ["vello", "png", "text", "geom-wkb", "geom-wkt"] } # Serialization serde.workspace = true 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/scale/breaks.rs b/src/plot/scale/breaks.rs index 102063ebd..e83312e93 100644 --- a/src/plot/scale/breaks.rs +++ b/src/plot/scale/breaks.rs @@ -585,8 +585,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 +618,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/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index 9ef34efd2..d1f21ae86 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -1155,17 +1155,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 } diff --git a/src/plot/scale/types.rs b/src/plot/scale/types.rs index b1d9718a9..9895e8439 100644 --- a/src/plot/scale/types.rs +++ b/src/plot/scale/types.rs @@ -145,8 +145,34 @@ impl Scale { /// 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 @@ -159,9 +185,9 @@ 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 diff --git a/src/writer/hephaestus/CLAUDE.md b/src/writer/hephaestus/CLAUDE.md index a63873bc7..e55c48a3e 100644 --- a/src/writer/hephaestus/CLAUDE.md +++ b/src/writer/hephaestus/CLAUDE.md @@ -84,7 +84,7 @@ HephaestusWriter::write(&Plot, &HashMap) │ ├─ 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(h/w).aspect_mode(Range) + │ ├─ map: plot.aspect_ratio(1.0).aspect_mode(Range) ← square units │ ├─ panel.strip_top / strip_right → plot.strip(AxisSide::…) │ └─ view.attach_plot(plot) │ @@ -235,7 +235,26 @@ registers nothing rather than fabricating a scale. 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"). + 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. hephaestus has no reversal + concept either, so it is expressed as the domain read backwards — descending + for a continuum, reversed category list otherwise, which flips a position axis + and walks a material palette the other 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 @@ -268,11 +287,22 @@ 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`. +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. +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. ## Legends @@ -288,11 +318,43 @@ 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. -One subtlety in `material_legend`: a legend key paints only what it is told to -paint, so a non-color scaled channel (`size`, `shape`, `linetype`) needs a fixed -body color or the swatch comes out empty. `key_color` supplies the layer's -constant `fill`/`stroke`, skipping a *data-mapped* color aesthetic — its column -holds domain values, not colors. +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. Three rules: + +- **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. +- **Never pin `size`, `linewidth` or `shape`** (`UNPINNABLE_CHANNELS`). A key's + cell does not grow to fit its glyph, and those three are what hephaestus sizes + the glyph from; pinning a length chosen for a 3pt data marker paints a disc + across the whole legend. See PLAN.md §9. +- **Translate a zero *partial* opacity, don't pin it.** A key has a single `alpha` + covering fill and stroke together, where a geom has `fill_opacity` and + `stroke_opacity` separately. `opacity => 0` on a point geom leaves open circles, + so the key must drop its **fill** — pinning the zero instead fades the whole + glyph away, which also deletes that layer from a key shared with another geom. + `suppressed_channels` handles this; plain `alpha` is a whole-mark value and pins + directly. + +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 diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index 3a9eb670f..d3b16ed72 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -1308,7 +1308,10 @@ here so it survives between efforts. hides colliding labels. The fix belongs in hephaestus's `Axis`: it needs the measured text metrics to decide a stride, which the writer doesn't have and shouldn't guess. Keeping the tick and blanking its label is the presentation to - aim for. + aim for. This is also what the "weird axis label alignment" on + `doc/syntax/scale/type/discrete.qmd:115` (`RENAMING * => 'Species: {}'`) turns + out to be — the labels and their positions are resolved correctly and agree + with VL; they are simply too long to sit side by side. ### Feature gaps @@ -1352,15 +1355,19 @@ resolved per-panel domains and spatial position scales: path should do the same. - **`bar` on a numeric primary axis stays continuous** (no `pos1end`), so band-fraction bars get no width; VL hits the same wall (`bandwidth('x')` is 0). +- **An identity `size` column has no agreed unit**, so the two writers cannot + agree on it. `SCALE IDENTITY size` bypasses scaling by design and both writers + receive the same raw numbers (`flipper_len`, 172–231); VL reads `size` as an + *area in px²* (≈15 px markers), hephaestus as a *pt diameter* (≈230–310 px, so + the panel becomes one undifferentiated field). Neither is wrong on its own + terms — VL converts pt→area only for *literals* (`encoding.rs:558`), never for + an identity column, so the conversion has nowhere to live but core. Settling it + means deciding what unit ggsql promises for an identity material column, then + having both writers honour it. `doc/syntax/scale/type/identity.qmd:15`. - **A data-mapped `linewidth` on a boxplot/violin is rejected by ggsql**: the stat drops the column, so `linewidth AS w` fails validation for *both* writers ("Column `linewidth` … does not exist"). Grouping aesthetics (fill/stroke) survive; scalar ones don't. -- `Scale::break_labels()` misses `label_mapping` for numeric discrete/ordinal - domains: `categorical_break_labels` still labels a non-string category with - `format!("{}", to_json())` (`"5.0"`) while the mapping is keyed by - `to_key_string()` (`"5"`). The default (continuous/binned) impl no longer has - this fault — see the temporal-axes section. - **`TIME` columns are broken for both writers.** ggsql's Time convention is nanoseconds (`casting.rs` targets `Time64(Nanosecond)`, `schema.rs` reads via the strict `as_time64_ns`), but `needs_cast` treats any `Time64(_)` as already the @@ -1380,10 +1387,55 @@ resolved per-panel domains and spatial position scales: ### Upstream hephaestus -Pinned at rev `5e9a060`. One item left, and it is a fallback rather than a gap the -writer can reach. The shape of everything that got resolved here: the writer's job -is to pass resolved values through, so wherever hephaestus had to compute something -itself, the fix was a missing *setter*, not a better algorithm. +Pinned at rev `a353698`. The shape of everything that got resolved here: the +writer's job is to pass resolved values through, so wherever hephaestus had to +compute something itself, the fix was a missing *setter*, not a better algorithm. +The items below are the ones no setter reaches. + +**A shape scale drops its marks entirely.** `plot/geom/point.rs:287` resolves the +`shape` channel with `resolve_str_channel_or(shape_ch, None, i, …)` — passing +`None` where every other channel passes its bound scale (`size` does so on the +line above, from `ctx.scale_for("size")`). The raw *domain* value (`"Adelie"`) +is therefore used as a shape name, misses the registry, and `point.rs:302-305` +`continue`s past the mark. So the layer disappears rather than drawing wrong +shapes. ggsql's side is correct end to end: the default palette's names match +hephaestus's registry 1:1 (`plot/scale/palettes.rs:1928-1969` vs `shape.rs:664`), +`RangeKind::Shape` is registered, and the channel is bound. A literal +`SETTING shape => 'star'` still works, because that goes through `Raw`. +Affects `doc/syntax/scale/aesthetic/shape.qmd` and the discrete-scale shape +example. Fix is `ctx.scale_for("shape")`. + +**A `linewidth` of 0 drops a whole polyline.** `plot/geom/line.rs:547-555` +resolves the mark's width from its *first row* and bails on +`linewidth_px <= 0.0`, so `SCALE linewidth TO (0, 30)` renders nothing at all — +even though every later vertex is wide, and even though ribbon mode is otherwise +active and would interpolate per vertex. `TO (1, 30)` renders correctly, which is +how to tell this apart from the channel not being wired. The guard wants to be +per-vertex (or taken from the mark's maximum) once ribbon mode is on. +Repro: `doc/syntax/layer/type/line.qmd:87-98`. + +**Legend keys are sized from the theme's geom defaults, not from their cell.** +`chrome/legend/render_keys.rs` takes `size_pt`/`linewidth_pt` from the key when +set and from `theme.geom.*` otherwise, and the cell never grows to fit. Three +consequences: a *fixed* `size`/`linewidth`/`shape` cannot be shown on a key at +all (the writer excludes them — `wiring::UNPINNABLE_CHANNELS`); a legend *scaled* +on `linewidth` overflows its cell at the top of the range; and `render_line` +(`render_keys.rs:219-262`) draws edge-to-edge with kurbo's default **round** caps, +so every line key overhangs by `linewidth/2`. `ResolvedKey` has no `cap` field and +`LegendKeySpec::fixed("cap", …)` is silently swallowed, so there is no writer-side +escape; the cheapest upstream fix for the last one is +`Stroke::new(w).with_caps(Cap::Butt)`, as `plot/plot.rs:802-804` already does. + +**Facet strips measure unwrapped but draw wrapped.** `StripMeasure::new` +(`plot/chrome/strip.rs:118-146`) measures the label at `f32::INFINITY` and sizes +the slot for one line; `draw_strip` renders through `draw_text_element_in_rect`, +which wraps to the strip's interior width (`plot/plot.rs:2105-2106`) and then +clips to the background shape. A label wider than the panel therefore wraps to N +lines inside a one-line slot and lines 2..N are clipped — exactly what ggsql's +binned facet strips produce, since those carry bin-range labels. There is no +strip-thickness setter and no way to opt out of wrapping, so the writer can only +work around it via the theme's `strip_text` size. Real fix: measure with the same +`max_width` the draw pass uses. **No scale-level domain expansion / "nice" padding.** Not a gap in practice, and no longer one anywhere the writer can reach. ggsql owns expansion: diff --git a/src/writer/hephaestus/channels.rs b/src/writer/hephaestus/channels.rs index 5cff301b8..27fc7932d 100644 --- a/src/writer/hephaestus/channels.rs +++ b/src/writer/hephaestus/channels.rs @@ -54,12 +54,31 @@ pub fn aesthetic_column_name<'a>(layer: &'a Layer, aesthetic: &str) -> Option<&' } } +/// 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 → /// 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. pub fn column_to_channel(df: &DataFrame, name: &str) -> Result { let array = df.column(name)?; if matches!(array.data_type(), DataType::Utf8 | DataType::LargeUtf8) { - Ok(ChannelData::Strings(column_to_strings(df, name)?)) + Ok(ChannelData::Strings(read_strings(df, name, NULL_CATEGORY)?)) } else { Ok(ChannelData::Floats(column_to_f64(df, name)?)) } @@ -80,8 +99,16 @@ pub fn column_to_f64(df: &DataFrame, name: &str) -> Result> { } /// Read a column as strings, casting non-text columns to text. Nulls become -/// empty strings. +/// 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) { @@ -93,7 +120,7 @@ pub fn column_to_strings(df: &DataFrame, name: &str) -> Result> { Ok((0..str_array.len()) .map(|i| { if str_array.is_null(i) { - String::new() + null_as.to_string() } else { str_array.value(i).to_string() } diff --git a/src/writer/hephaestus/geom/area.rs b/src/writer/hephaestus/geom/area.rs index 208927403..2ac4002c8 100644 --- a/src/writer/hephaestus/geom/area.rs +++ b/src/writer/hephaestus/geom/area.rs @@ -52,7 +52,12 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { ), MaterialSpec::new("color", "fill", RangeKind::Color, MatDefault::None), MaterialSpec::new("colour", "fill", RangeKind::Color, MatDefault::None), + // 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. MaterialSpec::new("stroke", "stroke", RangeKind::Color, MatDefault::None), + MaterialSpec::new("stroke", "stroke2", RangeKind::Color, MatDefault::None), MaterialSpec::new( "opacity", "alpha", @@ -65,12 +70,24 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { 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![], diff --git a/src/writer/hephaestus/geom/boxplot.rs b/src/writer/hephaestus/geom/boxplot.rs index 27d398dce..498152df6 100644 --- a/src/writer/hephaestus/geom/boxplot.rs +++ b/src/writer/hephaestus/geom/boxplot.rs @@ -19,11 +19,49 @@ use super::super::channels::{ use super::super::scales::RangeKind; use super::super::wiring::{ band_edges, band_half_width, constant_number, constant_string, dodge_offsets, resolve_color, - resolve_material, side_sign, BandAxes, Ctx, LegendKind, MaterialSource, + resolve_material, side_sign, BandAxes, Ctx, LegendKind, MatDefault, MaterialSource, + MaterialSpec, }; use super::hinge::{caps, hinge_points}; use crate::{GgsqlError, 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(); @@ -62,6 +100,11 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { 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. @@ -72,6 +115,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { "fill", rgb8(255, 255, 255), LegendKind::Rect, + &material, )?; let stroke = resolve_color( ctx, @@ -80,6 +124,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { "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 @@ -91,6 +136,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { "linewidth", RangeKind::Number, LegendKind::Line, + &material, )?; let linetype = resolve_material( ctx, @@ -99,6 +145,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { "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 diff --git a/src/writer/hephaestus/geom/rect.rs b/src/writer/hephaestus/geom/rect.rs index a1fcc2c36..3b894277c 100644 --- a/src/writer/hephaestus/geom/rect.rs +++ b/src/writer/hephaestus/geom/rect.rs @@ -73,7 +73,22 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { /// 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)>) { - let half = band_half_width(ctx.layer, 0.9); + // 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(); diff --git a/src/writer/hephaestus/geom/spatial.rs b/src/writer/hephaestus/geom/spatial.rs index 8de06f848..13acd36cb 100644 --- a/src/writer/hephaestus/geom/spatial.rs +++ b/src/writer/hephaestus/geom/spatial.rs @@ -10,15 +10,12 @@ use hephaestus::plot::{GeometryGeom, Plot as HPlot}; use super::super::channels::column_to_geometry; use super::super::scales::RangeKind; -use super::super::wiring::{ - resolve_color, wire_material, Ctx, LegendKind, MatDefault, MaterialSpec, -}; +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; - let n = df.height(); // The geometry aesthetic is always materialised to the internal WKB column. let geoms = column_to_geometry(df, &naming::aesthetic_column("geometry"))?; @@ -32,34 +29,25 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { plot.set_binding("x", ctx.pos1_scale); plot.set_binding("y", ctx.pos2_scale); - // fill/stroke: data-mapped (choropleth) → shared scale + legend, else the - // mapped literal or the ggsql spatial defaults. - let all: Vec = (0..n).collect(); - resolve_color( - ctx, - plot, - "fill", - "fill", - rgb8(0x74, 0x74, 0x74), - LegendKind::Rect, - )? - .apply(&mut b, "fill", &all); - resolve_color( - ctx, - plot, - "stroke", - "stroke", - rgb8(0, 0, 0), - LegendKind::Rect, - )? - .apply(&mut b, "stroke", &all); - - // opacity/linewidth/linetype: routed through the shared material path so - // each is honored whether it's the ggsql literal default, a `SETTING` - // constant, or data-mapped (scale-bound + legended). Mirrors the generic - // geoms; ggsql's spatial defaults (opacity 0.8, linewidth 0.2, solid) arrive - // as literals and set the fallback. + // 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", diff --git a/src/writer/hephaestus/geom/text.rs b/src/writer/hephaestus/geom/text.rs index da7e04953..baeb9fbb9 100644 --- a/src/writer/hephaestus/geom/text.rs +++ b/src/writer/hephaestus/geom/text.rs @@ -1,7 +1,10 @@ //! `text` geom → hephaestus `TextGeom`. A custom builder (not the generic -//! position/material path) because several text aesthetics need conversion: -//! `vjust` flips for hephaestus's top-origin `anchor_y`, `rotation` is degrees -//! → radians, `fontweight` accepts CSS keywords, and `italic` is boolean. +//! position/material path) because several text aesthetics need conversion +//! before they can be set: `vjust`/`hjust` accept keywords and flip for +//! hephaestus's top-origin `anchor_y`, `rotation` is degrees → radians, +//! `fontweight` accepts CSS keywords, and `italic` is boolean. The aesthetics +//! that need no conversion still go through `wire_material`, so a scaled +//! `fontsize` maps through its resolved scale like any other geom's. use std::f64::consts::PI; @@ -12,7 +15,12 @@ use hephaestus::plot::{Plot as HPlot, TextGeom}; use super::super::channels::{ aesthetic_column_name, column_to_bool, column_to_channel, column_to_f64, column_to_strings, }; -use super::super::wiring::{resolve_color, resolve_optional_color, Ctx, LegendKind}; +use super::super::scales::RangeKind; +use super::super::wiring::{ + constant_number, constant_string, wire_material, Ctx, LegendKind, MatDefault, MaterialSpec, +}; +use crate::plot::types::{ArrayElement, ParameterValue}; +use crate::plot::AestheticValue; use crate::{GgsqlError, Result}; pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { @@ -36,29 +44,20 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { // Label string. b.set("text", Raw(column_to_strings(df, label)?)); - let rows: Vec = (0..n).collect(); - - // Color: data-mapped (color-by-group) or constant black. - resolve_color(ctx, plot, "fill", "fill", rgb8(0, 0, 0), LegendKind::Point)? - .apply(&mut b, "fill", &rows); - - // Glyph outline, drawn under the fill. Only set when `stroke` is actually - // mapped: ggsql's default for a text geom's `stroke` is Null, and hephaestus - // skips the outline pass entirely while `text_stroke` is unset. The outline - // width is hephaestus's theme default — ggsql's text geom has no `linewidth` - // aesthetic, and neither does the Vega-Lite writer's text mark. - if let Some(stroke) = - resolve_optional_color(ctx, plot, "stroke", "text_stroke", LegendKind::Point)? - { - stroke.apply(&mut b, "text_stroke", &rows); - } - - // Scalar styling (unscaled visual values). - b.set("fill_opacity", Raw(numeric_or(ctx, "opacity", 1.0))); - b.set("size", Raw(numeric_or(ctx, "fontsize", 11.0))); - b.set("anchor_x", Raw(numeric_or(ctx, "hjust", 0.5))); + // Color, glyph outline, size and opacity: 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. `text_stroke` has no default because ggsql's default for + // a text geom's `stroke` is Null and hephaestus skips the outline pass + // entirely while the channel is unset; its width is hephaestus's theme + // default, as ggsql's text geom has no `linewidth` aesthetic. + wire_material(&mut b, &material(), plot, ctx, LegendKind::Point)?; + + // Aesthetics needing conversion, resolved per row: a mapped column, else the + // layer's literal repeated, else the ggsql default. + 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 = numeric_or(ctx, "vjust", 0.5) + let anchor_y: Vec = justification(ctx, "vjust") .iter() .map(|v| 1.0 - v) .collect(); @@ -71,24 +70,127 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { b.set("angle", Raw(angle)); b.set("weight", Raw(weights(ctx, n)?)); b.set("italic", Raw(italics(ctx, n)?)); - if let Some(col) = aesthetic_column_name(layer, "typeface") { - b.set("family", Raw(column_to_strings(df, col)?)); + // Only set `family` when the layer actually names one: an empty family is + // not "use the default", it is a font lookup that misses. + let families = strings_or(ctx, "typeface", ""); + if families.iter().any(|f| !f.is_empty()) { + b.set("family", Raw(families)); + } + + // `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. Everything else this geom sets needs conversion first. +fn material() -> [MaterialSpec; 4] { + [ + 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), + ), + ] +} + +/// 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), + } +} + fn require<'a>(layer: &'a crate::Layer, aesthetic: &str) -> Result<&'a str> { aesthetic_column_name(layer, aesthetic) .ok_or_else(|| GgsqlError::WriterError(format!("text layer has no {aesthetic} mapping"))) } -/// A per-row numeric aesthetic, or `default` repeated when it isn't mapped. +/// A per-row numeric aesthetic. Falls back to the layer's constant — a `SETTING` +/// literal, which is how every fixed value arrives — and only then to `default`. fn numeric_or(ctx: &Ctx, aesthetic: &str, default: f64) -> Vec { match aesthetic_column_name(ctx.layer, aesthetic) { Some(col) => column_to_f64(ctx.df, col).unwrap_or_else(|_| vec![default; ctx.df.height()]), - None => vec![default; ctx.df.height()], + None => vec![constant_number(ctx, aesthetic, default); ctx.df.height()], + } +} + +/// A per-row string aesthetic, falling back to the layer's constant literal. +fn strings_or(ctx: &Ctx, aesthetic: &str, default: &str) -> Vec { + match aesthetic_column_name(ctx.layer, aesthetic) { + Some(col) => column_to_strings(ctx.df, col) + .unwrap_or_else(|_| vec![default.to_string(); ctx.df.height()]), + None => vec![constant_string(ctx, aesthetic, default); ctx.df.height()], + } +} + +/// 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) { + if let Ok(values) = column_to_f64(ctx.df, col) { + return values; + } + if let Ok(names) = column_to_strings(ctx.df, col) { + return names.iter().map(|s| parse_justification(s)).collect(); + } + 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 } } @@ -99,7 +201,14 @@ fn weights(ctx: &Ctx, n: usize) -> Result> { .iter() .map(|s| parse_weight(s)) .collect()), - None => Ok(vec![400.0; n]), + None => Ok(vec![ + parse_weight(&constant_string( + ctx, + "fontweight", + "normal" + )); + n + ]), } } @@ -121,10 +230,17 @@ fn parse_weight(value: &str) -> f64 { } } -/// Italic flags, default false. +/// Italic flags, from a mapped column or the layer's `SETTING italic => true`, +/// default false. fn italics(ctx: &Ctx, n: usize) -> Result> { match aesthetic_column_name(ctx.layer, "italic") { Some(col) => column_to_bool(ctx.df, col), - None => Ok(vec![false; n]), + None => { + let italic = matches!( + ctx.layer.mappings.aesthetics.get("italic"), + Some(AestheticValue::Literal(ParameterValue::Boolean(true))) + ); + Ok(vec![italic; n]) + } } } diff --git a/src/writer/hephaestus/geom/violin.rs b/src/writer/hephaestus/geom/violin.rs index be92da2cb..9a72e4394 100644 --- a/src/writer/hephaestus/geom/violin.rs +++ b/src/writer/hephaestus/geom/violin.rs @@ -22,10 +22,42 @@ use super::super::channels::{ use super::super::scales::RangeKind; use super::super::wiring::{ band_edges, constant_number, dodge_offsets, resolve_color, resolve_material, side_sign, - BandAxes, Ctx, LegendKind, + BandAxes, Ctx, LegendKind, MatDefault, MaterialSpec, }; use crate::{GgsqlError, 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", "alpha", RangeKind::Number, MatDefault::None), + ] +} + pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { let (layer, df) = (ctx.layer, ctx.df); @@ -100,6 +132,11 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { 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( @@ -109,6 +146,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { "fill", rgb8(255, 255, 255), LegendKind::Rect, + &material, )?; let stroke = resolve_color( ctx, @@ -117,6 +155,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { "stroke", rgb8(60, 60, 60), LegendKind::Rect, + &material, )?; // Outline width + dash pattern, applied to both ribbon edges. let linewidth = resolve_material( @@ -126,6 +165,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { "linewidth", RangeKind::Number, LegendKind::Line, + &material, )?; let linetype = resolve_material( ctx, @@ -134,7 +174,22 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { "linetype", RangeKind::Linetype, LegendKind::Line, + &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 [ @@ -162,7 +217,12 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { b.set(frac_ch2, band2); b.set(value_ch, values); fill.apply(&mut b, "fill", &order); - stroke.apply(&mut b, "stroke", &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. @@ -171,7 +231,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { (linetype.as_ref(), ["linetype", "linetype2"]), ] { if let Some(source) = source { - for channel in channels { + for channel in channels.iter().filter(|c| outline_edges.contains(c)) { source.apply(&mut b, channel, &order); } } diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index a99f6ce0e..16db66b83 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -180,8 +180,9 @@ impl Writer for HephaestusWriter { 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()); + 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 @@ -306,14 +307,17 @@ impl Writer for HephaestusWriter { // Axes are created per coordinate system, edge-only for fixed scales. plot = apply_projection(plot, spec, panel, &ps); - // Lock a map panel's aspect to its bounding box so the projection - // keeps its proportions (a globe stays round), the raster analog of - // the Vega-Lite writer's uniform projection scale. - if let Some((xmin, ymin, xmax, ymax)) = map_bbox { - let (w, h) = (xmax - xmin, ymax - ymin); - if w > 0.0 && h > 0.0 { - plot = plot.aspect_ratio(h / w).aspect_mode(AspectMode::Range); - } + // 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). diff --git a/src/writer/hephaestus/projection.rs b/src/writer/hephaestus/projection.rs index 90157130f..18f47c753 100644 --- a/src/writer/hephaestus/projection.rs +++ b/src/writer/hephaestus/projection.rs @@ -95,7 +95,24 @@ fn apply_proj_polar(mut plot: HPlot, proj: &Projection, spec: &Plot, ps: &PanelS // 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`). - let base = PolarProjection::full_circle(); + // 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, @@ -125,9 +142,12 @@ fn apply_proj_polar(mut plot: HPlot, proj: &Projection, spec: &Plot, ps: &PanelS )); } 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: start }, + AxisPlacement::PolarRadius { theta_frac: 0.0 }, )); } plot diff --git a/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs index 391e4a7da..b52529298 100644 --- a/src/writer/hephaestus/scales.rs +++ b/src/writer/hephaestus/scales.rs @@ -9,15 +9,15 @@ use std::sync::Arc; use hephaestus::color::{rgba, Color}; -use hephaestus::plot::geom::linetype::{dashdot, dashed, dotted, solid}; +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 super::channels::{column_to_f64, column_to_strings}; +use super::channels::{column_to_f64, column_to_strings, NULL_CATEGORY}; use crate::naming; -use crate::plot::scale::TransformKind as GTransform; +use crate::plot::scale::{linetype_to_stroke_dash, TransformKind as GTransform}; use crate::plot::{ArrayElement, OutputRange, ParameterValue, Scale as GScale, ScaleTypeKind}; use crate::DataFrame; @@ -49,15 +49,26 @@ pub fn build_scale(scale: Option<&GScale>, kind: RangeKind) -> Option { .and_then(|s| s.transform.as_ref()) .map(|t| t.transform_kind()); + // `SETTING reverse => true` is a scale property ggsql resolves but does not + // apply — each writer flips its own scale (the Vega-Lite writer emits VL's + // `scale.reverse`). hephaestus has no reversal concept either, so it is + // expressed as the domain read backwards: a descending continuous domain + // normalises to a descending fraction, and a reversed category list both + // flips a position axis and walks a material scale's palette the other way + // — which is what VL's `reverse` does for each kind. + let reversed = is_reversed(scale); + let flip = |a: f64, b: f64| if reversed { (b, a) } else { (a, b) }; + let mut hs = match type_kind { - ScaleTypeKind::Discrete => scale::discrete(domain_values(scale)), - ScaleTypeKind::Ordinal => scale::ordinal(domain_values(scale)), + ScaleTypeKind::Discrete => scale::discrete(domain_values(scale, reversed)), + ScaleTypeKind::Ordinal => scale::ordinal(domain_values(scale, reversed)), ScaleTypeKind::Identity => scale::identity(), ScaleTypeKind::Binned => { let h_transform = transform.and_then(map_transform); let (min, max) = continuous_domain(scale); let breaks = scale.map(|x| x.numeric_breaks()).unwrap_or(vec![min, max]); - let mut c = scale::binned(min..=max, breaks); + let (start, end) = flip(min, max); + let mut c = scale::binned(start..=end, breaks); if let Some(t) = h_transform { c = c.with_transform(t); } @@ -65,14 +76,15 @@ pub fn build_scale(scale: Option<&GScale>, kind: RangeKind) -> Option { } ScaleTypeKind::Continuous => { let (min, max) = continuous_domain(scale); + let (start, end) = flip(min, max); // 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) { + match temporal_scale(transform, start, end) { Some(t) => t, None => { - let mut c = scale::continuous(min..=max); + let mut c = scale::continuous(start..=end); if let Some(t) = transform.and_then(map_transform) { c = c.with_transform(t); } @@ -342,12 +354,35 @@ fn continuous_domain(scale: Option<&GScale>) -> (f64, f64) { pad_degenerate(domain.0, domain.1) } +/// Whether the scale carries `SETTING reverse => true`. +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. -fn domain_values(scale: Option<&GScale>) -> Vec { - scale +fn domain_values(scale: Option<&GScale>, reversed: bool) -> Vec { + let mut values: Vec = scale .and_then(|s| s.input_range.as_ref()) - .map(|range| range.iter().map(array_element_to_value).collect()) - .unwrap_or_default() + .map(|range| range.iter().map(category_value).collect()) + .unwrap_or_default(); + if reversed { + values.reverse(); + } + values +} + +/// A categorical domain entry as a hephaestus value. Identical to +/// [`array_element_to_value`] except that a null level becomes +/// [`channels::NULL_CATEGORY`], the sentinel the data side substitutes for its +/// own nulls — see that constant for why the two must agree. +fn category_value(element: &ArrayElement) -> HValue { + match element { + ArrayElement::Null => HValue::String(Arc::from(NULL_CATEGORY)), + other => array_element_to_value(other), + } } /// Attach the resolved output range to a material scale. @@ -365,14 +400,34 @@ fn apply_output_range(hs: HScale, kind: RangeKind, values: &[ArrayElement]) -> H } } -/// Map a ggsql linetype name to a hephaestus dash pattern; unknown → solid. +/// 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]> { - match name { - "dashed" | "longdash" => dashed(), - "dotted" => dotted(), - "dotdash" | "dashdot" | "twodash" => dashdot(), - _ => solid(), - } + 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) + } + })) } /// Feed ggsql's resolved breaks + formatted labels into the hephaestus scale so @@ -382,20 +437,39 @@ pub fn map_linetype(name: &str) -> Arc<[LinetypeStep]> { /// ggsql's to own, majors and minors alike, so nothing here invents either. fn apply_breaks(hs: HScale, scale: &GScale, type_kind: Option) -> HScale { let hs = apply_minor_breaks(hs, scale, type_kind); - let labels = scale.break_labels(); + let categorical = matches!( + type_kind, + Some(ScaleTypeKind::Discrete) | Some(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 { Some(ScaleTypeKind::Discrete) | Some(ScaleTypeKind::Ordinal) => { - // Pair each category value with its (possibly renamed) label. + // 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)> = range - .iter() - .map(array_element_to_value) - .zip(labels.into_iter().map(|(_, l)| l)) + 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) } diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index eeb594d2d..81eead528 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -7,6 +7,7 @@ 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}; use hephaestus::plot::Plot as HPlot; use hephaestus::scales::chrome::LegendSide; use hephaestus::scales::value::Value as HValue; @@ -19,6 +20,28 @@ use super::scales::{map_linetype, parse_color, 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. +/// +/// Only one deviation 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. +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 +} + /// Read-only context for building one layer's geom. pub struct Ctx<'a> { pub spec: &'a Plot, @@ -286,6 +309,15 @@ pub fn wire_material( 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) { @@ -317,18 +349,20 @@ pub fn wire_material( 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. hephaestus collapses compatible legends, so repeated - // records across layers for the same scale merge at registration. + // 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); - ctx.push_legend(material_legend( - ctx, - m.aesthetic, - m.channel, - m.kind, - type_kind, - aesthetic_label(ctx.spec, ctx.layer, m.aesthetic), - legend_kind, - )); + 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 => { @@ -670,24 +704,18 @@ pub fn resolve_color( channel: &'static str, default: Color, legend_kind: LegendKind, + material: &[MaterialSpec], ) -> Result { - Ok( - resolve_material(ctx, plot, aesthetic, channel, RangeKind::Color, legend_kind)? - .unwrap_or(MaterialSource::Constant(HValue::Color(default))), - ) -} - -/// Like [`resolve_color`] but with no fallback. For aesthetics whose ggsql -/// default is `Null` (e.g. a text geom's `stroke`), where "unmapped" must leave -/// the channel unset rather than substitute a color. -pub fn resolve_optional_color( - ctx: &Ctx, - plot: &mut HPlot, - aesthetic: &'static str, - channel: &'static str, - legend_kind: LegendKind, -) -> Result> { - resolve_material(ctx, plot, aesthetic, channel, RangeKind::Color, legend_kind) + 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 @@ -710,12 +738,8 @@ pub fn resolve_material( channel: &'static str, kind: RangeKind, legend_kind: LegendKind, + material: &[MaterialSpec], ) -> Result> { - let type_kind = ctx - .spec - .find_scale(aesthetic) - .and_then(|s| s.scale_type.as_ref()) - .map(|st| st.scale_type_kind()); if is_data_mapped(ctx, aesthetic) { let col = aesthetic_column_name(ctx.layer, aesthetic); plot.set_binding(channel, aesthetic); @@ -724,9 +748,8 @@ pub fn resolve_material( aesthetic, channel, kind, - type_kind, - aesthetic_label(ctx.spec, ctx.layer, aesthetic), legend_kind, + material, )); return Ok(Some(MaterialSource::Data { data: column_to_channel(ctx.df, col.unwrap())?, @@ -820,15 +843,23 @@ fn constant_material(ctx: &Ctx, aesthetic: &str, kind: RangeKind) -> Option, - title: Option, 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, @@ -837,24 +868,22 @@ pub fn material_legend( let mut legend = if continuous_color { Legend::colorbar(scale_name).side(LegendSide::Right) } else { - let mut key = match legend_kind { + let key = match legend_kind { LegendKind::Point => LegendKeySpec::point(), LegendKind::Line => LegendKeySpec::line(), LegendKind::Rect => LegendKeySpec::rect(), } .scaled(channel, scale_name); - // A key only paints what it is told to paint, so when the scaled channel - // isn't itself a color the glyph needs one — otherwise the swatch is - // invisible next to its label. Use the layer's constant color, matching - // the marks the legend describes. - if kind != RangeKind::Color { - let body = match legend_kind { - LegendKind::Line => "stroke", - LegendKind::Point | LegendKind::Rect => "fill", - }; - key = key.fixed(body, HValue::Color(key_color(ctx, legend_kind))); - } - Legend::new(scale_name).side(LegendSide::Right).key(key) + 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(); @@ -865,25 +894,150 @@ pub fn material_legend( legend } -/// The color a non-color legend key paints its glyph with: the layer's constant -/// color for the aesthetic carrying the glyph's body, the other color aesthetic -/// as a fallback (a stroke-only geom has no fill, and vice versa), else a neutral -/// grey. A data-mapped color aesthetic has no single constant, so it falls through -/// to the grey — that scale gets its own legend anyway. -fn key_color(ctx: &Ctx, legend_kind: LegendKind) -> Color { - let order = match legend_kind { - LegendKind::Line => ["stroke", "fill"], - LegendKind::Point | LegendKind::Rect => ["fill", "stroke"], - }; - for aesthetic in order { - // A data-mapped color has no constant to borrow — its column holds domain - // values, not colors — and it carries its own legend anyway. - if is_data_mapped(ctx, aesthetic) { +/// 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. +/// The channels a legend key must *not* inherit from the layer: the ones that +/// decide how much room the glyph takes, rather than how it is painted. +/// +/// A key's cell does not grow to fit its glyph. `render_point` sizes a marker +/// from `theme.geom.point.size_pt` and outlines it at `stroke_width_pt` — both +/// tuned for a legend — and only falls back to those when the key leaves the +/// channel unset. Pin `size`/`linewidth` and the key instead uses a length +/// chosen for a 3pt data marker; pin `shape` and it switches to scaling that +/// shape's path by the same length. Either way the swatch stops fitting its +/// cell: `SETTING size => 10` paints a disc across the whole legend. +/// +/// A legend *scaled* on one of these is unaffected — it is the scaled channel, +/// so it is never pinned, and hephaestus sizes those keys from the scale. +const UNPINNABLE_CHANNELS: &[&str] = &["size", "linewidth", "shape"]; + +/// The colour channel a *partial* opacity governs, if it governs only one. +/// +/// A geom's `fill_opacity` and `stroke_opacity` fade one channel each, but a +/// legend key has a single `alpha` that hephaestus applies to its fill *and* its +/// stroke (`render_point`, `render_rect`). Pinning a partial opacity there would +/// fade the wrong things — for `opacity => 0` on a point layer it fades the +/// glyph out of existence, even though the marks stay visible as open circles. +/// +/// So a partial opacity is translated instead of pinned: at zero, the channel it +/// governs is *absent* from the mark, and a key can say that exactly by leaving +/// that channel unset. `alpha` returns `None` because it really is a whole-mark +/// opacity and maps onto the key's own `alpha` directly. +fn partial_opacity_target(channel: &str) -> Option<&'static str> { + match channel { + "fill_opacity" => Some("fill"), + "stroke_opacity" => Some("stroke"), + _ => None, + } +} + +/// Channels a legend key must leave unset because the layer faded them out +/// entirely: the colour channel behind every partial opacity the layer resolves +/// to zero, plus the opacity channel itself (there is nothing left for it to +/// say). See [`partial_opacity_target`] for why this is a translation rather +/// than a pin. +fn suppressed_channels<'a>(ctx: &Ctx, material: &'a [MaterialSpec]) -> HashSet<&'a str> { + let mut suppressed = HashSet::new(); + for m in material { + let Some(target) = partial_opacity_target(m.channel) else { + continue; + }; + // A data-mapped opacity varies per row; there is no single value to act on. + if is_data_mapped(ctx, m.aesthetic) { + continue; + } + let value = constant_material(ctx, m.aesthetic, m.kind).or(match m.default { + MatDefault::Number(n) => Some(HValue::Number(n)), + _ => None, + }); + if matches!(value, Some(HValue::Number(n)) if n == 0.0) { + suppressed.insert(target); + suppressed.insert(m.channel); + } + } + suppressed +} + +fn pin_constants( + ctx: &Ctx, + mut key: LegendKeySpec, + material: &[MaterialSpec], + scaled_channel: &str, + legend_kind: LegendKind, + kind: RangeKind, +) -> LegendKeySpec { + // Channels the layer fades all the way out, and so leaves off its marks: + // `SETTING opacity => 0` on a point geom draws open circles, and the key + // says so by having no fill rather than by being invisible. Resolved up + // front because the opacity that suppresses a channel may sit after it in + // the table. + let suppressed = suppressed_channels(ctx, material); + + // `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) + || UNPINNABLE_CHANNELS.contains(&m.channel) + || suppressed.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; } - if let Some(HValue::Color(c)) = constant_material(ctx, aesthetic, RangeKind::Color) { - return c; + 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 => "fill", + }; + // Not when the layer suppressed it: an unfilled mark wants an unfilled + // key, and the grey would put back the fill that was just taken away. + if !pinned.contains(body) && !suppressed.contains(body) { + key = key.fixed(body, HValue::Color(rgb8(64, 64, 64))); } } - rgb8(64, 64, 64) + key } diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index ffe714bf3..946ddf366 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -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: @@ -473,7 +481,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)); } } diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 448e4b931..c518d4d40 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -375,6 +375,12 @@ fn build_layer_encoding( // Add pos2 offset encoding for vertical jitter (pos2offset column) // This column is created by position::Jitter when pos2 axis is discrete + // + // The domain runs 0.5 → -0.5 rather than -0.5 → 0.5 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 a 2D dodge grid + // reads the same way round as the axis it sits on. let pos2offset_col = naming::aesthetic_column("pos2offset"); if df.column(&pos2offset_col).is_ok() { encoding.insert( @@ -383,7 +389,7 @@ fn build_layer_encoding( "field": pos2offset_col, "type": "quantitative", "scale": { - "domain": [-0.5, 0.5] + "domain": [0.5, -0.5] } }), ); @@ -1070,6 +1076,17 @@ 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. + "scale": { + "bandPaddingInner": 0 + }, "axis": { "domain": false, "grid": true, From 906404302b7705ec7ded7608d4966cf290d8b3f5 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Tue, 11 Aug 2026 22:59:58 +0200 Subject: [PATCH 19/29] Incorporate fixes from upstream hephaestus --- Cargo.lock | 2 +- src/Cargo.toml | 2 +- src/writer/hephaestus/CLAUDE.md | 33 +++--- src/writer/hephaestus/PLAN.md | 152 +++++++++++++++------------ src/writer/hephaestus/geom/area.rs | 2 +- src/writer/hephaestus/geom/text.rs | 2 +- src/writer/hephaestus/geom/violin.rs | 9 +- src/writer/hephaestus/wiring.rs | 93 +++------------- 8 files changed, 132 insertions(+), 163 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cb1d2e11f..08765d4bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2497,7 +2497,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hephaestus" version = "0.0.1" -source = "git+https://github.com/posit-dev/hephaestus.git?rev=a3536987c595e7574ef62e764fc6e3ad482c3370#a3536987c595e7574ef62e764fc6e3ad482c3370" +source = "git+https://github.com/posit-dev/hephaestus.git?rev=aec4e1b0dba1704b7dffc44ebcba9a1acfd939ae#aec4e1b0dba1704b7dffc44ebcba9a1acfd939ae" dependencies = [ "bytemuck", "clipper2-rust", diff --git a/src/Cargo.toml b/src/Cargo.toml index 53f0fbb8f..aead268da 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -40,7 +40,7 @@ adbc_core = { version = "0.23", optional = true } geozero = { workspace = true, optional = true, features = ["with-wkb", "with-wkt", "with-geojson"] } # Hephaestus raster writer (non-default; gated, excluded from the MSRV 1.86 build) -hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "a3536987c595e7574ef62e764fc6e3ad482c3370", optional = true, default-features = false, features = ["vello", "png", "text", "geom-wkb", "geom-wkt"] } +hephaestus = { git = "https://github.com/posit-dev/hephaestus.git", rev = "aec4e1b0dba1704b7dffc44ebcba9a1acfd939ae", optional = true, default-features = false, features = ["vello", "png", "text", "geom-wkb", "geom-wkt"] } # Serialization serde.workspace = true diff --git a/src/writer/hephaestus/CLAUDE.md b/src/writer/hephaestus/CLAUDE.md index e55c48a3e..189720c82 100644 --- a/src/writer/hephaestus/CLAUDE.md +++ b/src/writer/hephaestus/CLAUDE.md @@ -130,7 +130,7 @@ for the minimal case): | `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). 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 swatch, so a line legend shows a line. | +| `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 @@ -196,13 +196,20 @@ hephaestus channels are named per **panel axis**; scales are registered under th 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` / `alpha` | +| Scalars | `size`, `linewidth`, `linetype`, `shape`, `fill_opacity` / `stroke_opacity` | | Geometry / text | `geometry`; `text`, `anchor_x`, `anchor_y`, `angle`, `weight`, `italic`, `family` | | Scale registry key | Source | @@ -322,7 +329,8 @@ A legend key paints only what it is told to paint — nothing is inherited from 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. Three rules: +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 @@ -331,17 +339,14 @@ line), so the key ends up styled like the marks it describes. Three rules: 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. -- **Never pin `size`, `linewidth` or `shape`** (`UNPINNABLE_CHANNELS`). A key's - cell does not grow to fit its glyph, and those three are what hephaestus sizes - the glyph from; pinning a length chosen for a 3pt data marker paints a disc - across the whole legend. See PLAN.md §9. -- **Translate a zero *partial* opacity, don't pin it.** A key has a single `alpha` - covering fill and stroke together, where a geom has `fill_opacity` and - `stroke_opacity` separately. `opacity => 0` on a point geom leaves open circles, - so the key must drop its **fill** — pinning the zero instead fades the whole - glyph away, which also deletes that layer from a key shared with another geom. - `suppressed_channels` handles this; plain `alpha` is a whole-mark value and pins - directly. + +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. 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 diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index d3b16ed72..266942d85 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -1223,7 +1223,7 @@ other layer bound the same channel. drawn — silently, in a plot that otherwise looked right. Every writer-resolved constant is now `Raw`: `set_literal_channel` (literals and `SETTING`s), `MatDefault` (geom defaults), `MaterialSource::Constant` (composites), and the - composites' own `size`/`shape`/`fill_opacity`/`alpha`. Per-row band fractions + composites' own `size`/`shape`/`fill_opacity`. Per-row band fractions and offsets are untouched — no scale is ever bound to a `_band` channel. The rule that falls out, now stated in [`CLAUDE.md`](CLAUDE.md): **only a @@ -1239,6 +1239,53 @@ overlay draws both layers, 91 writer tests pass, and a full harness re-run over `doc/syntax/` is clean — 191 cells, 0 problems, all eyeballed against the Vega-Lite renders. fmt and clippy clean. +## hephaestus bump `a353698` → `aec4e1b` — status: implemented + +Upstream closed every item §9's "Upstream hephaestus" list had accumulated. The +bump is mostly *subtraction* on this side: three of the seven were writer +workarounds that now have nothing to work around. + +Required by the bump (`aec4e1b` panics on an unknown channel rather than ignoring +it, so these were hard failures, not cosmetic): + +- **The `alpha` channel is gone**; opacity is `fill_opacity` / `stroke_opacity` + everywhere, geoms and legend keys alike. `area` and `violin` were the last two + users — both `RibbonGeom`, both now on `fill_opacity`, which is what every other + fill-bearing geom in the writer already mapped ggsql's `opacity` to. + +Enabled by the bump: + +- **`UNPINNABLE_CHANNELS` deleted.** hephaestus now sizes each swatch cell from + the key it holds (`render_keys::swatch_dim_for`), insets a rect's border, and + reserves a line key's cap body. All three reasons the writer withheld `size` / + `linewidth` / `shape` from a key are gone, so `pin_constants` pins them like + anything else: `SETTING shape => 'star'` now puts stars in the legend, and + `SETTING size => 12` gets a cell that fits the marker instead of a disc painted + across the legend. +- **The partial-opacity translation deleted.** `ResolvedKey` grew separate + `fill_opacity` / `stroke_opacity` matching the geom channels 1:1, so a zero + opacity is *pinned* rather than reverse-engineered into "leave the colour + channel unset". `partial_opacity_target` and `suppressed_channels` are gone; + `opacity => 0` on a point layer still draws open circles in both panel and key. +- **`LegendKind::Text`** added, and the `text` geom switched to it from `Point`. + A scaled `fontsize` legend now draws letters at each size. + +Fixed upstream with no writer change — verified by re-rendering each repro: + +- A **shape scale** draws its marks (`ctx.scale_for("shape")`), and its keys draw + the glyph at a sensible size with no swallowing outline disc. +- **`SCALE linewidth TO (0, 30)`** renders the polyline, tapering from zero. +- A **radar's closing edge** wraps forward across the 1.0/0.0 seam instead of + retracing the interior. +- **Text chrome is measured with the width it is drawn at**, fixing both symptoms + at once: a wrapped facet strip reserves all its lines rather than clipping to + one, and an axis tick label containing a space centres on the whole label rather + than on its first word. + +Verified: 191 cells over `doc/syntax/` in ~164 s, 0 problems, eyeballed against +the Vega-Lite renders; the seven repros above re-rendered individually; 91 writer +tests pass; fmt and clippy clean. + ## 8. Key source references ggsql: @@ -1284,6 +1331,27 @@ here so it survives between efforts. - **Legends are captured from the first panel only**, assuming every panel yields identical legends. True under fixed scales; unverified for a free-scale facet that also maps a material aesthetic. +- **`area` and `density` stroke their baseline as well as their curve.** The + ribbon-edge wiring sends every outline aesthetic to *both* curves, which is + right for `ribbon` (both its edges are data) and wrong for the other two, where + curve A is the axis rather than part of the shape. Gate the curve-A copies on + the geom, the way `violin.rs` gates them on `side`. +- **A row whose scaled *numeric* value is null is drawn unpainted rather than + dropped.** `column_to_f64` maps a null to `NaN`, the geom draws the mark anyway, + and the channel resolves to nothing — so `doc/syntax/layer/position/jitter.qmd:67` + renders penguins' two NULL-`body_mass` rows as white circles that Vega-Lite + omits entirely. Note the rule differs by scale type and the categorical half is + already correct: a **categorical** null is a trained level with its own colour + and legend key (see `channels::NULL_CATEGORY`), a **continuous or binned** null + is missing data and the row should not be drawn at all. +- **A map frames tighter than Vega-Lite does.** Both writers frame to the data + bbox and now agree on proportions, but VL pads the fitted extent by 10% + (`vegalite/projection/map.rs:135-147`, `dx = (xmax - xmin) * 1.1`) while + `map_bbox`/`nice_range` pad not at all — `nice_range` only widens a degenerate + span. Matching that 10% is the writer half. Whether framing should key off the + projection's own extent instead of the data's is a separate core question in + `resolve_final_bbox`, and `doc/syntax/coord/crs.qmd:204-213` currently documents + data-framing as intended. - **A log scale whose expanded lower bound crosses zero renders blank.** Not a writer fault and not "log scales get no expansion" — expansion works whenever it stays positive (`body_mass VIA log` resolves `[2520, 6480]`, a real 5% pad). The @@ -1308,11 +1376,7 @@ here so it survives between efforts. hides colliding labels. The fix belongs in hephaestus's `Axis`: it needs the measured text metrics to decide a stride, which the writer doesn't have and shouldn't guess. Keeping the tick and blanking its label is the presentation to - aim for. This is also what the "weird axis label alignment" on - `doc/syntax/scale/type/discrete.qmd:115` (`RENAMING * => 'Species: {}'`) turns - out to be — the labels and their positions are resolved correctly and agree - with VL; they are simply too long to sit side by side. - + aim for. ### Feature gaps - `arrow` geom — the only unsupported `GeomType` (deliberate). @@ -1387,71 +1451,29 @@ resolved per-panel domains and spatial position scales: ### Upstream hephaestus -Pinned at rev `a353698`. The shape of everything that got resolved here: the +Pinned at rev `aec4e1b`. The shape of everything that got resolved here: the writer's job is to pass resolved values through, so wherever hephaestus had to compute something itself, the fix was a missing *setter*, not a better algorithm. -The items below are the ones no setter reaches. - -**A shape scale drops its marks entirely.** `plot/geom/point.rs:287` resolves the -`shape` channel with `resolve_str_channel_or(shape_ch, None, i, …)` — passing -`None` where every other channel passes its bound scale (`size` does so on the -line above, from `ctx.scale_for("size")`). The raw *domain* value (`"Adelie"`) -is therefore used as a shape name, misses the registry, and `point.rs:302-305` -`continue`s past the mark. So the layer disappears rather than drawing wrong -shapes. ggsql's side is correct end to end: the default palette's names match -hephaestus's registry 1:1 (`plot/scale/palettes.rs:1928-1969` vs `shape.rs:664`), -`RangeKind::Shape` is registered, and the channel is bound. A literal -`SETTING shape => 'star'` still works, because that goes through `Raw`. -Affects `doc/syntax/scale/aesthetic/shape.qmd` and the discrete-scale shape -example. Fix is `ctx.scale_for("shape")`. - -**A `linewidth` of 0 drops a whole polyline.** `plot/geom/line.rs:547-555` -resolves the mark's width from its *first row* and bails on -`linewidth_px <= 0.0`, so `SCALE linewidth TO (0, 30)` renders nothing at all — -even though every later vertex is wide, and even though ribbon mode is otherwise -active and would interpolate per vertex. `TO (1, 30)` renders correctly, which is -how to tell this apart from the channel not being wired. The guard wants to be -per-vertex (or taken from the mark's maximum) once ribbon mode is on. -Repro: `doc/syntax/layer/type/line.qmd:87-98`. - -**Legend keys are sized from the theme's geom defaults, not from their cell.** -`chrome/legend/render_keys.rs` takes `size_pt`/`linewidth_pt` from the key when -set and from `theme.geom.*` otherwise, and the cell never grows to fit. Three -consequences: a *fixed* `size`/`linewidth`/`shape` cannot be shown on a key at -all (the writer excludes them — `wiring::UNPINNABLE_CHANNELS`); a legend *scaled* -on `linewidth` overflows its cell at the top of the range; and `render_line` -(`render_keys.rs:219-262`) draws edge-to-edge with kurbo's default **round** caps, -so every line key overhangs by `linewidth/2`. `ResolvedKey` has no `cap` field and -`LegendKeySpec::fixed("cap", …)` is silently swallowed, so there is no writer-side -escape; the cheapest upstream fix for the last one is -`Stroke::new(w).with_caps(Cap::Butt)`, as `plot/plot.rs:802-804` already does. - -**Facet strips measure unwrapped but draw wrapped.** `StripMeasure::new` -(`plot/chrome/strip.rs:118-146`) measures the label at `f32::INFINITY` and sizes -the slot for one line; `draw_strip` renders through `draw_text_element_in_rect`, -which wraps to the strip's interior width (`plot/plot.rs:2105-2106`) and then -clips to the background shape. A label wider than the panel therefore wraps to N -lines inside a one-line slot and lines 2..N are clipped — exactly what ggsql's -binned facet strips produce, since those carry bin-range labels. There is no -strip-thickness setter and no way to opt out of wrapping, so the writer can only -work around it via the theme's `strip_text` size. Real fix: measure with the same -`max_width` the draw pass uses. - -**No scale-level domain expansion / "nice" padding.** Not a gap in practice, and -no longer one anywhere the writer can reach. ggsql owns expansion: -`resolve_common_steps` applies `SETTING expand` via -`expand_numeric_range_selective` while resolving the scale, so `numeric_domain()` -is already padded before either writer sees it, and both pass it through verbatim -(`continuous_domain` → `scale::continuous(min..=max)`; VL's `build_scale_object` → -`scale.domain`). Neither writer uses its host's own padding — VL never emits `nice` -or `padding` either — so the two agree exactly on a fixed scale. + +Every item this section previously listed is now fixed upstream, verified by +re-rendering its repro (see the bump entry in the phase log for the writer-side +changes the bump required). One item remains, and it costs nothing in practice: + +**No scale-level domain expansion / "nice" padding.** Not a gap anywhere the +writer can reach. ggsql owns expansion: `resolve_common_steps` applies +`SETTING expand` via `expand_numeric_range_selective` while resolving the scale, +so `numeric_domain()` is already padded before either writer sees it, and both +pass it through verbatim (`continuous_domain` → `scale::continuous(min..=max)`; +VL's `build_scale_object` → `scale.domain`). Neither writer uses its host's own +padding — VL never emits `nice` or `padding` either — so the two agree exactly on +a fixed scale. The one place it used to cost something, **a free facet dimension**, is fixed: see -the expansion section below. VL solves the same problem by *delegating* — +the expansion section above. VL solves the same problem by *delegating* — `build_scale_object` skips `domain` when `is_free(...)` and the spec sets `resolve.scale: independent`, so Vega derives each panel's domain and pads it — -whereas the hephaestus writer now asks ggsql for the factors and applies them to -the extent it computed. Both end up padded; ggsql's route additionally honours an +whereas the hephaestus writer asks ggsql for the factors and applies them to the +extent it computed. Both end up padded; ggsql's route additionally honours an explicit `SETTING expand` per panel, which Vega's own padding would ignore. What is left upstream is only the *fallback* case: a host with no resolved scale at all still gets no padding from hephaestus. diff --git a/src/writer/hephaestus/geom/area.rs b/src/writer/hephaestus/geom/area.rs index 2ac4002c8..907dc7797 100644 --- a/src/writer/hephaestus/geom/area.rs +++ b/src/writer/hephaestus/geom/area.rs @@ -60,7 +60,7 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { MaterialSpec::new("stroke", "stroke2", RangeKind::Color, MatDefault::None), MaterialSpec::new( "opacity", - "alpha", + "fill_opacity", RangeKind::Number, MatDefault::Number(0.8), ), diff --git a/src/writer/hephaestus/geom/text.rs b/src/writer/hephaestus/geom/text.rs index baeb9fbb9..a3a7f47e3 100644 --- a/src/writer/hephaestus/geom/text.rs +++ b/src/writer/hephaestus/geom/text.rs @@ -51,7 +51,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { // a text geom's `stroke` is Null and hephaestus skips the outline pass // entirely while the channel is unset; its width is hephaestus's theme // default, as ggsql's text geom has no `linewidth` aesthetic. - wire_material(&mut b, &material(), plot, ctx, LegendKind::Point)?; + wire_material(&mut b, &material(), plot, ctx, LegendKind::Text)?; // Aesthetics needing conversion, resolved per row: a mapped column, else the // layer's literal repeated, else the ggsql default. diff --git a/src/writer/hephaestus/geom/violin.rs b/src/writer/hephaestus/geom/violin.rs index 9a72e4394..680058e85 100644 --- a/src/writer/hephaestus/geom/violin.rs +++ b/src/writer/hephaestus/geom/violin.rs @@ -54,7 +54,12 @@ fn material() -> [MaterialSpec; 5] { RangeKind::Linetype, MatDefault::None, ), - MaterialSpec::new("opacity", "alpha", RangeKind::Number, MatDefault::None), + MaterialSpec::new( + "opacity", + "fill_opacity", + RangeKind::Number, + MatDefault::None, + ), ] } @@ -236,7 +241,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { } } } - b.set("alpha", Raw(constant_number(ctx, "opacity", 1.0))); + b.set("fill_opacity", Raw(constant_number(ctx, "opacity", 1.0))); plot.add_geom(b.build()); Ok(()) diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index 81eead528..0b8344c8f 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -144,6 +144,9 @@ 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 @@ -872,6 +875,7 @@ pub fn material_legend( LegendKind::Point => LegendKeySpec::point(), LegendKind::Line => LegendKeySpec::line(), LegendKind::Rect => LegendKeySpec::rect(), + LegendKind::Text => LegendKeySpec::text(), } .scaled(channel, scale_name); Legend::new(scale_name) @@ -909,69 +913,10 @@ pub fn material_legend( /// 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. -/// The channels a legend key must *not* inherit from the layer: the ones that -/// decide how much room the glyph takes, rather than how it is painted. -/// -/// A key's cell does not grow to fit its glyph. `render_point` sizes a marker -/// from `theme.geom.point.size_pt` and outlines it at `stroke_width_pt` — both -/// tuned for a legend — and only falls back to those when the key leaves the -/// channel unset. Pin `size`/`linewidth` and the key instead uses a length -/// chosen for a 3pt data marker; pin `shape` and it switches to scaling that -/// shape's path by the same length. Either way the swatch stops fitting its -/// cell: `SETTING size => 10` paints a disc across the whole legend. -/// -/// A legend *scaled* on one of these is unaffected — it is the scaled channel, -/// so it is never pinned, and hephaestus sizes those keys from the scale. -const UNPINNABLE_CHANNELS: &[&str] = &["size", "linewidth", "shape"]; - -/// The colour channel a *partial* opacity governs, if it governs only one. -/// -/// A geom's `fill_opacity` and `stroke_opacity` fade one channel each, but a -/// legend key has a single `alpha` that hephaestus applies to its fill *and* its -/// stroke (`render_point`, `render_rect`). Pinning a partial opacity there would -/// fade the wrong things — for `opacity => 0` on a point layer it fades the -/// glyph out of existence, even though the marks stay visible as open circles. -/// -/// So a partial opacity is translated instead of pinned: at zero, the channel it -/// governs is *absent* from the mark, and a key can say that exactly by leaving -/// that channel unset. `alpha` returns `None` because it really is a whole-mark -/// opacity and maps onto the key's own `alpha` directly. -fn partial_opacity_target(channel: &str) -> Option<&'static str> { - match channel { - "fill_opacity" => Some("fill"), - "stroke_opacity" => Some("stroke"), - _ => None, - } -} - -/// Channels a legend key must leave unset because the layer faded them out -/// entirely: the colour channel behind every partial opacity the layer resolves -/// to zero, plus the opacity channel itself (there is nothing left for it to -/// say). See [`partial_opacity_target`] for why this is a translation rather -/// than a pin. -fn suppressed_channels<'a>(ctx: &Ctx, material: &'a [MaterialSpec]) -> HashSet<&'a str> { - let mut suppressed = HashSet::new(); - for m in material { - let Some(target) = partial_opacity_target(m.channel) else { - continue; - }; - // A data-mapped opacity varies per row; there is no single value to act on. - if is_data_mapped(ctx, m.aesthetic) { - continue; - } - let value = constant_material(ctx, m.aesthetic, m.kind).or(match m.default { - MatDefault::Number(n) => Some(HValue::Number(n)), - _ => None, - }); - if matches!(value, Some(HValue::Number(n)) if n == 0.0) { - suppressed.insert(target); - suppressed.insert(m.channel); - } - } - suppressed -} - +/// 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, @@ -980,23 +925,13 @@ fn pin_constants( legend_kind: LegendKind, kind: RangeKind, ) -> LegendKeySpec { - // Channels the layer fades all the way out, and so leaves off its marks: - // `SETTING opacity => 0` on a point geom draws open circles, and the key - // says so by having no fill rather than by being invisible. Resolved up - // front because the opacity that suppresses a channel may sit after it in - // the table. - let suppressed = suppressed_channels(ctx, material); - // `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) - || UNPINNABLE_CHANNELS.contains(&m.channel) - || suppressed.contains(m.channel) - { + if claimed.contains(m.channel) { continue; } // A channel another scale drives is spoken for, whichever aesthetic @@ -1031,11 +966,13 @@ fn pin_constants( if kind != RangeKind::Color { let body = match legend_kind { LegendKind::Line => "stroke", - LegendKind::Point | LegendKind::Rect => "fill", + LegendKind::Point | LegendKind::Rect | LegendKind::Text => "fill", }; - // Not when the layer suppressed it: an unfilled mark wants an unfilled - // key, and the grey would put back the fill that was just taken away. - if !pinned.contains(body) && !suppressed.contains(body) { + // 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))); } } From 6d4a8efde939af1c6a429e4b6191e60a0e2de385 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Wed, 12 Aug 2026 10:03:16 +0200 Subject: [PATCH 20/29] correct stroking of area and density --- src/writer/hephaestus/CLAUDE.md | 2 +- src/writer/hephaestus/PLAN.md | 38 +++++++-- src/writer/hephaestus/geom/area.rs | 128 ++++++++++++++++++++++++++++- 3 files changed, 161 insertions(+), 7 deletions(-) diff --git a/src/writer/hephaestus/CLAUDE.md b/src/writer/hephaestus/CLAUDE.md index 189720c82..c7200034b 100644 --- a/src/writer/hephaestus/CLAUDE.md +++ b/src/writer/hephaestus/CLAUDE.md @@ -129,7 +129,7 @@ for the minimal case): | `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). Channels listed here are *claimed*: `wire_positions` won't overwrite them with the raw offsets, because the geom already folded those in. | +| `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). | diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index 266942d85..100d8e71b 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -1286,6 +1286,39 @@ Verified: 191 cells over `doc/syntax/` in ~164 s, 0 problems, eyeballed against the Vega-Lite renders; the seven repros above re-rendered individually; 91 writer tests pass; fmt and clippy clean. +## Area baselines — status: implemented + +An `area` or `density` outlined its baseline as heavily as its curve, so every +one of them sat on a rule along `y = 0` — clearly wrong with `stroke => 'red'`, +and a visible black hem on the default stack. The ribbon-edge wiring sends each +outline aesthetic to *both* curves, which is right for `ribbon` (both edges are +data) and wrong here, where curve A is usually the axis. + +Not right by geom, though, which is what §9 originally proposed: a **centred** +stack's bottom band rides on `-total/2`, and that baseline is the figure's lower +silhouette — dropping its outline would leave a streamgraph unbordered along the +bottom. The rule is therefore about the data, per mark: **a baseline that holds +one value is the axis; one that wanders is silhouette.** `area::baseline_outline` +groups the resolved baseline column by the same `partition_by` keys +`build_and_add` marks with, and emits a per-row `stroke_opacity` for curve A — +1.0 where the baseline wanders, 0.0 where it doesn't. Opacity is the gate because +hephaestus strokes curve A whenever its channel is *bound*, and a binding belongs +to the whole geom; `stroke_opacity` is per-mark (resolved at the mark's first +row), unmapped by ggsql, and free for the writer. + +What that yields, each eyeballed: a plain area and a `density` outline only their +curve; a normal or `total`-normalised stack loses the hem at zero but keeps every +band boundary; a centred stack is unchanged from the fully-outlined render it +already had; `ribbon` is untouched. A transposed area is the same rule on +`pos1end`. Interior boundaries in a stack are still drawn twice — once as the +lower band's curve, once as the upper band's baseline — which is invisible while +the stroke is one colour, and takes the upper band's colour when `stroke` is +data-mapped. + +Verified: five `silhouette_opacity` unit tests (no GPU) cover flat, wandering, +normal-stack, centred-stack and null baselines; 96 writer tests pass; the seven +renders above; fmt and clippy clean. + ## 8. Key source references ggsql: @@ -1331,11 +1364,6 @@ here so it survives between efforts. - **Legends are captured from the first panel only**, assuming every panel yields identical legends. True under fixed scales; unverified for a free-scale facet that also maps a material aesthetic. -- **`area` and `density` stroke their baseline as well as their curve.** The - ribbon-edge wiring sends every outline aesthetic to *both* curves, which is - right for `ribbon` (both its edges are data) and wrong for the other two, where - curve A is the axis rather than part of the shape. Gate the curve-A copies on - the geom, the way `violin.rs` gates them on `side`. - **A row whose scaled *numeric* value is null is drawn unpainted rather than dropped.** `column_to_f64` maps a null to `NaN`, the geom draws the mark anyway, and the channel resolves to nothing — so `doc/syntax/layer/position/jitter.qmd:67` diff --git a/src/writer/hephaestus/geom/area.rs b/src/writer/hephaestus/geom/area.rs index 907dc7797..22c707b7d 100644 --- a/src/writer/hephaestus/geom/area.rs +++ b/src/writer/hephaestus/geom/area.rs @@ -3,8 +3,11 @@ //! 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, @@ -14,6 +17,8 @@ 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]. @@ -56,6 +61,8 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { // 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( @@ -91,8 +98,127 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { ], raw_strings: &[], raw_numbers: vec![], - data_channels: 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]); + } +} From 2760cfaef5fc0755df29d77780d33f136fcc6fd0 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Wed, 12 Aug 2026 10:12:54 +0200 Subject: [PATCH 21/29] Pad spatial bbox like VegaLite --- src/writer/hephaestus/CLAUDE.md | 5 ++++ src/writer/hephaestus/PLAN.md | 41 +++++++++++++++++++++------ src/writer/hephaestus/mod.rs | 49 ++++++++++++++++++++++++--------- 3 files changed, 74 insertions(+), 21 deletions(-) diff --git a/src/writer/hephaestus/CLAUDE.md b/src/writer/hephaestus/CLAUDE.md index c7200034b..75b8ce385 100644 --- a/src/writer/hephaestus/CLAUDE.md +++ b/src/writer/hephaestus/CLAUDE.md @@ -311,6 +311,11 @@ 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 diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index 100d8e71b..189112f7f 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -1319,6 +1319,32 @@ Verified: five `silhouette_opacity` unit tests (no GPU) cover flat, wandering, normal-stack, centred-stack and null baselines; 96 writer tests pass; the seven renders above; fmt and clippy clean. +## Map framing — status: implemented + +A map filled its panel edge to edge while the Vega-Lite render of the same query +kept a margin around the same data: VL fits its projection to `span * 1.1` +centred on the bbox (`vegalite/projection/map.rs`), and `map_bbox` handed the +raw bbox straight to `scale::continuous`. So the two writers framed the same +extent differently, and a shape on the boundary — Antarctica on a world map, +the disc of an orthographic globe — was drawn hard against the frame. + +`nice_range` is now `map_range`, padding a real span by `MAP_PADDING` (0.1) +split evenly around its centre, which is VL's framing expressed as a domain +rather than as a projection scale factor. The degenerate branch is unchanged: a +zero-width extent has no span to take a fraction of, so it is still widened by +±0.5 to stay mappable. Padding both axes matches VL on the constraining one and +leaves the other showing more than its own 10%, which is what `min(width/dx, +height/dy)` does there and what `aspect_mode(Range)` does here. + +Only the writer half of §9's item. Whether framing should key off the +projection's own extent rather than the data's stays a core question in +`resolve_final_bbox`, with `doc/syntax/coord/crs.qmd` documenting data-framing +as intended. + +Verified: two `map_range` unit tests (no GPU) pin the padded and degenerate +ranges; 98 writer tests pass; an orthographic globe eyeballed with room on every +side; fmt and clippy clean. + ## 8. Key source references ggsql: @@ -1372,14 +1398,13 @@ here so it survives between efforts. already correct: a **categorical** null is a trained level with its own colour and legend key (see `channels::NULL_CATEGORY`), a **continuous or binned** null is missing data and the row should not be drawn at all. -- **A map frames tighter than Vega-Lite does.** Both writers frame to the data - bbox and now agree on proportions, but VL pads the fitted extent by 10% - (`vegalite/projection/map.rs:135-147`, `dx = (xmax - xmin) * 1.1`) while - `map_bbox`/`nice_range` pad not at all — `nice_range` only widens a degenerate - span. Matching that 10% is the writer half. Whether framing should key off the - projection's own extent instead of the data's is a separate core question in - `resolve_final_bbox`, and `doc/syntax/coord/crs.qmd:204-213` currently documents - data-framing as intended. +- **A map frames to the data, not to the projection's own extent.** Both writers + now frame the data bbox identically, padding it by 10% (`map_range`, matching + VL's `dx = (xmax - xmin) * 1.1`). What remains is a core question in + `resolve_final_bbox` — whether an `orthographic` globe should frame to the + whole disc rather than to whichever countries the query selected — and it would + move both writers together. `doc/syntax/coord/crs.qmd:204-213` currently + documents data-framing as intended. - **A log scale whose expanded lower bound crosses zero renders blank.** Not a writer fault and not "log scales get no expansion" — expansion works whenever it stays positive (`body_mass VIA log` resolves `[2520, 6480]`, a real 5% pad). The diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index 16db66b83..cfc28543a 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -59,6 +59,11 @@ const DEFAULT_DPI: f64 = 300.0; /// 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 [`HephaestusWriter::from_options`] understands. const OPTIONS: &[&str] = &["width", "height", "units", "dpi", "background"]; @@ -229,14 +234,8 @@ impl Writer for HephaestusWriter { // 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(nice_range(xmin, xmax)), - ); - view.insert_scale( - "pos2".to_string(), - scale::continuous(nice_range(ymin, ymax)), - ); + 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 @@ -430,11 +429,17 @@ fn map_bbox( ) } -/// A non-degenerate inclusive range for a continuous position scale, widening a -/// zero-width or inverted extent so the scale can map it. -fn nice_range(min: f64, max: f64) -> std::ops::RangeInclusive { - if max - min > f64::EPSILON { - min..=max +/// 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) } @@ -1477,6 +1482,24 @@ mod tests { )); } + #[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(); From e79c50cb94c525ecc02b422d63823f9bc622a709 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Wed, 12 Aug 2026 11:15:36 +0200 Subject: [PATCH 22/29] Fix axis label repetition in faceted plots --- src/writer/hephaestus/CLAUDE.md | 36 ++++++- src/writer/hephaestus/PLAN.md | 59 +++++++++++ src/writer/hephaestus/geom/boxplot.rs | 7 +- src/writer/hephaestus/geom/text.rs | 141 +++++++------------------- src/writer/hephaestus/mod.rs | 94 +++++++++++++++++ src/writer/hephaestus/projection.rs | 42 ++++++-- src/writer/hephaestus/scales.rs | 66 +++++++++++- src/writer/hephaestus/wiring.rs | 88 ++++++++++++++-- 8 files changed, 404 insertions(+), 129 deletions(-) diff --git a/src/writer/hephaestus/CLAUDE.md b/src/writer/hephaestus/CLAUDE.md index 75b8ce385..669944be4 100644 --- a/src/writer/hephaestus/CLAUDE.md +++ b/src/writer/hephaestus/CLAUDE.md @@ -73,6 +73,7 @@ HephaestusWriter::write(&Plot, &HashMap) │ 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 @@ -237,6 +238,14 @@ registers nothing rather than fabricating a scale. 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. @@ -294,6 +303,14 @@ 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 @@ -353,6 +370,16 @@ includes `fill_opacity` / `stroke_opacity`, which a key carries separately just 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 @@ -376,9 +403,12 @@ a channel belongs there. `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, needs unit conversion, or computes its positions. - Even then, route materials through `wire_material` / `resolve_material` rather - than hand-rolling them — that is what keeps data-mapped aesthetics working. + 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 diff --git a/src/writer/hephaestus/PLAN.md b/src/writer/hephaestus/PLAN.md index 189112f7f..9eab016ae 100644 --- a/src/writer/hephaestus/PLAN.md +++ b/src/writer/hephaestus/PLAN.md @@ -1345,6 +1345,65 @@ Verified: two `map_range` unit tests (no GPU) pin the padded and degenerate ranges; 98 writer tests pass; an orthographic globe eyeballed with room on every side; fmt and clippy clean. +## Text legend keys inherit the font — status: implemented + +A text layer with a data-mapped aesthetic drew its key in hephaestus's default +face — upright sans at weight 400 — however the layer was set, so `SETTING +typeface => 'Times New Roman', fontweight => 'bold', italic => true` styled every +mark and none of the swatches. Nothing was missing upstream: `LegendKey::Text` +consumes `family`, `weight`, `italic` and `angle` alongside the colour and size +channels the writer already pinned. The gap was that `pin_constants` walks the +geom's `MaterialSpec` table, and `text`'s custom builder set exactly those four +channels *outside* it, as per-row `Raw` vectors with their conversions inlined — +so the table the key is dressed from never mentioned them. + +The conversions moved into `RangeKind`, which is where every other +literal/identity conversion already lives (`Color` parses a hex string, +`Linetype` a dash name). Four kinds cover them: `Text` (a family name), `Bool` +(italic), `FontWeight` (CSS keyword or number → 100–900), and `Angle` (ggsql's +degrees → hephaestus's radians). The font aesthetics then join `text`'s material +table like any other, and three things follow from the one change: the key +inherits the face, a *mapped* font column travels the identity path rather than a +bespoke one, and a `SCALE typeface`/`fontweight` would resolve a real range +(`mod.rs` registers those aesthetics with the matching kind instead of defaulting +them to numbers). `weights`, `italics`, `strings_or`, `numeric_or` and +`parse_weight` are gone from `geom/text.rs`; only justification stays custom, +since hjust/vjust need a keyword parse and the anchor flip that no `RangeKind` +models — and the key centres its glyph anyway. + +`angle` pins too, matching ggplot2's `draw_key_text`; `swatch_dim_for` sizes the +cell from the rotated glyph, so the rotation costs no clipping. + +Verified: 98 writer tests pass; a scaled `fontsize` legend eyeballed with bold +italic serif keys rotated 20° at each size, a colour-scaled one with the face +inherited while the key keeps the scale's colour, and a layer mapping all four +font aesthetics to columns rendered per row; fmt and clippy clean. + +## Axis titles are outer chrome — status: implemented + +`add_cartesian_axis` titled the rail it created, and rails are created per +panel, so a faceted plot repeated the axis title once per row and once per +column — and a `free` dimension, whose rail is drawn on *every* panel, repeated +it inside the grid. A figure has one x dimension and one y dimension, so it gets +one centred title each, in the outer chrome. + +The title moved off the rail onto the composition, which is where the plot-level +labels already live for the same reason: `projection::composition_axis_titles` +resolves `(AxisSide, String)` pairs from the same `aesthetic_label` lookup and +`write` installs them via `PlotComposition::axis_title` next to +title/subtitle/caption. The suppression rules are unchanged — `has_real_axis` +still filters out a synthetic `__ggsql_stat_dummy` scale, and only Cartesian +coords get titles (polar rails are untitled and a map has no rails). Nothing was +missing upstream; `axis_title` already spans the whole facet grid. + +The unfaceted case needs no branch: its composition is a 1×1 grid, so the shared +title lands in the same place the panel's own title used to. + +Verified: three new no-GPU tests (`axis_titles_*`) pin one title per dimension +across wrap/grid/free faceting, the `LABEL` override, and the polar/dummy-scale +suppressions; 107 writer tests pass; wrap, free grid and unfaceted renders +eyeballed with a single centred title on each axis; fmt and clippy clean. + ## 8. Key source references ggsql: diff --git a/src/writer/hephaestus/geom/boxplot.rs b/src/writer/hephaestus/geom/boxplot.rs index 498152df6..f419906a5 100644 --- a/src/writer/hephaestus/geom/boxplot.rs +++ b/src/writer/hephaestus/geom/boxplot.rs @@ -222,14 +222,17 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { plot.add_geom(b.build()); } - // Outliers: hollow points (stroke only, matching VL's `filled = false`) - // at their value, honoring the `size`/`shape` aesthetics. + // 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); + b.set("fill_opacity", Raw(alpha)); // `PointGeom` has no dash pattern — a marker outline can't be dashed. outline(&mut b, &linewidth, None, &out_i); b.set("size", Raw(constant_number(ctx, "size", 3.0))); diff --git a/src/writer/hephaestus/geom/text.rs b/src/writer/hephaestus/geom/text.rs index a3a7f47e3..51c914a70 100644 --- a/src/writer/hephaestus/geom/text.rs +++ b/src/writer/hephaestus/geom/text.rs @@ -1,23 +1,20 @@ //! `text` geom → hephaestus `TextGeom`. A custom builder (not the generic -//! position/material path) because several text aesthetics need conversion -//! before they can be set: `vjust`/`hjust` accept keywords and flip for -//! hephaestus's top-origin `anchor_y`, `rotation` is degrees → radians, -//! `fontweight` accepts CSS keywords, and `italic` is boolean. The aesthetics -//! that need no conversion still go through `wire_material`, so a scaled -//! `fontsize` maps through its resolved scale like any other geom's. - -use std::f64::consts::PI; +//! 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_bool, column_to_channel, column_to_f64, column_to_strings, + aesthetic_column_name, column_to_channel, column_to_f64, column_to_strings, }; use super::super::scales::RangeKind; use super::super::wiring::{ - constant_number, constant_string, wire_material, Ctx, LegendKind, MatDefault, MaterialSpec, + constant_number, wire_material, Ctx, LegendKind, MatDefault, MaterialSpec, }; use crate::plot::types::{ArrayElement, ParameterValue}; use crate::plot::AestheticValue; @@ -44,17 +41,20 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { // Label string. b.set("text", Raw(column_to_strings(df, label)?)); - // Color, glyph outline, size and opacity: 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. `text_stroke` has no default because ggsql's default for - // a text geom's `stroke` is Null and hephaestus skips the outline pass - // entirely while the channel is unset; its width is hephaestus's theme - // default, as ggsql's text geom has no `linewidth` aesthetic. + // 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)?; - // Aesthetics needing conversion, resolved per row: a mapped column, else the - // layer's literal repeated, else the ggsql default. + // 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") @@ -62,20 +62,6 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { .map(|v| 1.0 - v) .collect(); b.set("anchor_y", Raw(anchor_y)); - // ggsql rotation is in degrees; hephaestus angle is radians (math CCW). - let angle: Vec = numeric_or(ctx, "rotation", 0.0) - .iter() - .map(|d| d * PI / 180.0) - .collect(); - b.set("angle", Raw(angle)); - b.set("weight", Raw(weights(ctx, n)?)); - b.set("italic", Raw(italics(ctx, n)?)); - // Only set `family` when the layer actually names one: an empty family is - // not "use the default", it is a font lookup that misses. - let families = strings_or(ctx, "typeface", ""); - if families.iter().any(|f| !f.is_empty()) { - b.set("family", Raw(families)); - } // `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 @@ -93,8 +79,11 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { } /// The layer aesthetics wired through the shared material path, with ggsql's -/// text defaults. Everything else this geom sets needs conversion first. -fn material() -> [MaterialSpec; 4] { +/// 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", @@ -115,6 +104,19 @@ fn material() -> [MaterialSpec; 4] { 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), ] } @@ -139,24 +141,6 @@ fn require<'a>(layer: &'a crate::Layer, aesthetic: &str) -> Result<&'a str> { .ok_or_else(|| GgsqlError::WriterError(format!("text layer has no {aesthetic} mapping"))) } -/// A per-row numeric aesthetic. Falls back to the layer's constant — a `SETTING` -/// literal, which is how every fixed value arrives — and only then to `default`. -fn numeric_or(ctx: &Ctx, aesthetic: &str, default: f64) -> Vec { - match aesthetic_column_name(ctx.layer, aesthetic) { - Some(col) => column_to_f64(ctx.df, col).unwrap_or_else(|_| vec![default; ctx.df.height()]), - None => vec![constant_number(ctx, aesthetic, default); ctx.df.height()], - } -} - -/// A per-row string aesthetic, falling back to the layer's constant literal. -fn strings_or(ctx: &Ctx, aesthetic: &str, default: &str) -> Vec { - match aesthetic_column_name(ctx.layer, aesthetic) { - Some(col) => column_to_strings(ctx.df, col) - .unwrap_or_else(|_| vec![default.to_string(); ctx.df.height()]), - None => vec![constant_string(ctx, aesthetic, default); ctx.df.height()], - } -} - /// 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` / @@ -193,54 +177,3 @@ fn parse_justification(value: &str) -> f64 { _ => 0.5, // centre / center / middle / unknown } } - -/// Font weights as numeric 100–900 (CSS keywords parsed), default 400. -fn weights(ctx: &Ctx, n: usize) -> Result> { - match aesthetic_column_name(ctx.layer, "fontweight") { - Some(col) => Ok(column_to_strings(ctx.df, col)? - .iter() - .map(|s| parse_weight(s)) - .collect()), - None => Ok(vec![ - parse_weight(&constant_string( - ctx, - "fontweight", - "normal" - )); - n - ]), - } -} - -/// Parse a CSS font-weight keyword or numeric string to 100–900. -fn parse_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 - } -} - -/// Italic flags, from a mapped column or the layer's `SETTING italic => true`, -/// default false. -fn italics(ctx: &Ctx, n: usize) -> Result> { - match aesthetic_column_name(ctx.layer, "italic") { - Some(col) => column_to_bool(ctx.df, col), - None => { - let italic = matches!( - ctx.layer.mappings.aesthetics.get("italic"), - Some(AestheticValue::Literal(ParameterValue::Boolean(true))) - ); - Ok(vec![italic; n]) - } - } -} diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index cfc28543a..5ed0822c9 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -203,6 +203,12 @@ impl Writer for HephaestusWriter { 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 { @@ -210,6 +216,11 @@ impl Writer for HephaestusWriter { "fill" | "stroke" | "color" | "colour" => 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 @@ -602,6 +613,13 @@ mod tests { .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) @@ -764,6 +782,33 @@ mod tests { )); } + /// 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)", + )); + } + + /// 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( @@ -1089,6 +1134,55 @@ mod tests { 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!( diff --git a/src/writer/hephaestus/projection.rs b/src/writer/hephaestus/projection.rs index 18f47c753..e141547b8 100644 --- a/src/writer/hephaestus/projection.rs +++ b/src/writer/hephaestus/projection.rs @@ -62,11 +62,11 @@ 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`, titled from the plot's labels -/// (or the first layer's mapped column, keyed by `aesthetic`). 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). +/// 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, @@ -77,13 +77,33 @@ fn add_cartesian_axis( if !has_real_axis(spec, aesthetic) { return; } - let mut rail = Axis::rail(scale_name, AxisPlacement::Cartesian(side)); - if let Some(layer) = spec.layers.first() { - if let Some(title) = aesthetic_label(spec, layer, aesthetic) { - rail = rail.title(title); - } + 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(), } - plot.add_axis(rail); + 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 { diff --git a/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs index b52529298..8874ea252 100644 --- a/src/writer/hephaestus/scales.rs +++ b/src/writer/hephaestus/scales.rs @@ -22,7 +22,9 @@ use crate::plot::{ArrayElement, OutputRange, ParameterValue, Scale as GScale, Sc 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. +/// `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. @@ -35,6 +37,16 @@ pub enum RangeKind { 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 @@ -390,13 +402,26 @@ fn apply_output_range(hs: HScale, kind: RangeKind, values: &[ArrayElement]) -> H match kind { RangeKind::Color => hs.range_colors(values.iter().filter_map(array_element_to_color)), RangeKind::Number => hs.range_numbers(values.iter().filter_map(|e| e.to_f64())), - RangeKind::Shape => { + RangeKind::Shape | RangeKind::Text => { hs.range_strings(values.iter().map(|e| Arc::from(e.to_key_string().as_str()))) } RangeKind::Linetype => { hs.range_linetypes(values.iter().map(|e| map_linetype(&e.to_key_string()))) } - RangeKind::Position => hs, + // 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.iter().map(|e| parse_font_weight(&e.to_key_string()))) + } + RangeKind::Angle => hs.range_numbers( + values + .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, } } @@ -430,6 +455,26 @@ pub fn map_linetype(name: &str) -> Arc<[LinetypeStep]> { })) } +/// 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). /// @@ -823,4 +868,19 @@ mod tests { 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 index 0b8344c8f..64b624e8a 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -13,10 +13,10 @@ use hephaestus::scales::chrome::LegendSide; use hephaestus::scales::value::Value as HValue; use super::channels::{ - aesthetic_column_name, build_group_keys, column_to_channel, column_to_colors, column_to_f64, - column_to_strings, ChannelData, + 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, RangeKind}; +use super::scales::{map_linetype, parse_color, parse_font_weight, RangeKind}; use crate::plot::{ParameterValue, ScaleTypeKind}; use crate::{AestheticValue, DataFrame, GgsqlError, Layer, Plot, Result}; @@ -371,9 +371,27 @@ pub fn wire_material( RangeKind::Color => { builder.set(m.channel, Raw(column_to_colors(ctx.df, col)?)); } - RangeKind::Shape => { + RangeKind::Shape | RangeKind::Text => { builder.set(m.channel, Raw(column_to_strings(ctx.df, col)?)); } + 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)?)); } @@ -434,6 +452,29 @@ fn set_literal_channel( 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 @@ -821,16 +862,51 @@ fn constant_material(ctx: &Ctx, aesthetic: &str, kind: RangeKind) -> Option { + 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())) + } } } From 7095ae58fe0effd96b4f20f321709da801be347c Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Thu, 13 Aug 2026 09:18:01 +0200 Subject: [PATCH 23/29] Last round of small fixes based on visual inspection --- CHANGELOG.md | 34 +++- Cargo.lock | 2 +- doc/syntax/layer/position/dodge.qmd | 2 + doc/syntax/scale/type/identity.qmd | 7 +- ggsql-cli/CLAUDE.md | 5 +- ggsql-cli/examples/visual_test.rs | 32 +++- src/Cargo.toml | 2 +- src/plot/layer/position/dodge.rs | 67 +++++++- src/plot/layer/position/jitter.rs | 16 +- src/plot/layer/position/mod.rs | 60 +++++++ src/writer/hephaestus/CLAUDE.md | 19 ++- src/writer/hephaestus/PLAN.md | 160 ++++++++++++++++-- src/writer/hephaestus/facet.rs | 12 ++ src/writer/hephaestus/mod.rs | 108 +++++++++---- src/writer/hephaestus/wiring.rs | 14 +- src/writer/vegalite/CLAUDE.md | 36 +++++ src/writer/vegalite/encoding.rs | 117 +++++++++++++- src/writer/vegalite/layer.rs | 96 ++++++----- src/writer/vegalite/mod.rs | 242 ++++++++++++++++++++++++---- 19 files changed, 887 insertions(+), 144 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8058bd37a..4ed4566c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,16 @@ ### 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 @@ -45,10 +55,32 @@ 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. + 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 diff --git a/Cargo.lock b/Cargo.lock index 08765d4bd..70fa9f5e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2497,7 +2497,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hephaestus" version = "0.0.1" -source = "git+https://github.com/posit-dev/hephaestus.git?rev=aec4e1b0dba1704b7dffc44ebcba9a1acfd939ae#aec4e1b0dba1704b7dffc44ebcba9a1acfd939ae" +source = "git+https://github.com/posit-dev/hephaestus.git?rev=202f38e0356b9d1160422965e5de5b8a2aa29593#202f38e0356b9d1160422965e5de5b8a2aa29593" dependencies = [ "bytemuck", "clipper2-rust", 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/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/ggsql-cli/CLAUDE.md b/ggsql-cli/CLAUDE.md index fc2d4b9cb..545c56199 100644 --- a/ggsql-cli/CLAUDE.md +++ b/ggsql-cli/CLAUDE.md @@ -82,7 +82,7 @@ Library-level coverage lives in `ggsql` itself — this crate is thin glue, so i [`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 hephaestus --example visual_test # doc/syntax +cargo run -p ggsql-cli --features hephaestus --example visual_test # doc/syntax + doc/gallery cargo run -p ggsql-cli --features hephaestus --example visual_test -- --compare # + Vega-Lite side by side cargo run -p ggsql-cli --features hephaestus --example visual_test -- doc/gallery -f pie open target/visual-test/index.html @@ -90,9 +90,10 @@ 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. -Three properties are worth preserving when changing 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 `