diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 059e365..aff4d87 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -15,6 +15,18 @@ concurrency: cancel-in-progress: true jobs: + windows_review_storage: + name: PR / Windows review storage + runs-on: windows-2025 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - name: Exercise real Windows byte locks and atomic replacement + run: uv run --no-project --python 3.12 --with pytest --with numpy python -m pytest -q tests/test_review_store.py + preflight: name: PR / Workflow and assets runs-on: ubuntu-24.04 @@ -70,9 +82,7 @@ jobs: toolchain: '1.99.0' components: rustfmt - name: Install native software-GPU and X11 runtime - run: | - sudo apt-get update - sudo apt-get install -y git mesa-vulkan-drivers libvulkan1 libxkbcommon0 libxkbcommon-x11-0 xvfb xdotool xclip openbox wmctrl + run: bash scripts/install_ci_runtime.sh - name: Build and install the exact PR wheel run: | cargo fmt --check @@ -134,9 +144,7 @@ jobs: persist-credentials: false - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 - name: Install native software-GPU and X11 runtime - run: | - sudo apt-get update - sudo apt-get install -y git mesa-vulkan-drivers libvulkan1 libxkbcommon0 libxkbcommon-x11-0 xvfb xdotool xclip openbox wmctrl + run: bash scripts/install_ci_runtime.sh - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: pr-linux-wheel @@ -170,6 +178,7 @@ jobs: --ci-shard-count=4 --ci-shard-index="$CI_SHARD" \ --basetemp=ci-results/full --junitxml=ci-results/junit.xml \ 2>&1 | tee ci-results/full.log + echo 'CI pytest log pipeline exited' SCRIPT - name: Save integration results if: always() diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7850d25..fbae0eb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,7 +13,7 @@ on: description: 'Exact source version (public upload to the selected index)' required: true type: string - default: '0.3.0' + default: '0.4.0' testpypi-run-id: description: 'For verify-testpypi/PyPI: numeric TestPyPI run ID from the run URL (not #2)' type: string diff --git a/Cargo.lock b/Cargo.lock index bfdf8a3..57e455e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,6 +133,181 @@ dependencies = [ "libloading", ] +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.5", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -145,6 +320,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bit-set" version = "0.10.0" @@ -172,6 +353,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -190,6 +380,19 @@ dependencies = [ "objc2 0.6.5", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -390,6 +593,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -411,12 +623,32 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -430,6 +662,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.2", + "block2 0.6.2", + "libc", "objc2 0.6.5", ] @@ -558,6 +792,33 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "epaint" version = "0.36.2" @@ -602,7 +863,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -620,6 +881,32 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "fax" version = "0.2.7" @@ -663,7 +950,7 @@ dependencies = [ "futures-channel", "js-sys", "png", - "pollster", + "pollster 1.0.1", "swash", "tiny-skia 0.12.0", "wasm-bindgen", @@ -674,19 +961,24 @@ dependencies = [ [[package]] name = "figgyplot" -version = "0.3.0" +version = "0.4.0" dependencies = [ + "arboard", + "base64", "egui", "egui-wgpu", "egui-winit", "figgy-renderer", "fontdb", + "image", "numpy", - "pollster", + "pollster 1.0.1", "pyo3", + "rfd", "serde", "serde_ignored", "serde_json", + "sha2", "wgpu", "winit", ] @@ -796,6 +1088,36 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "futures-task" version = "0.3.34" @@ -809,11 +1131,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-io", + "futures-macro", "futures-task", + "memchr", "pin-project-lite", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -974,6 +1309,12 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "icu_collections" version = "2.3.0" @@ -1086,10 +1427,23 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "image-webp", "moxcms", "num-traits", "png", "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] [[package]] @@ -1329,6 +1683,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1562,6 +1925,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", + "block2 0.6.2", "objc2 0.6.5", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -1847,6 +2211,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -1856,6 +2230,12 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1924,6 +2304,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -1963,6 +2354,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "pollster" version = "1.0.1" @@ -2002,6 +2399,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "presser" version = "0.3.1" @@ -2131,6 +2537,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -2205,6 +2640,30 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster 0.4.0", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -2255,7 +2714,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2363,12 +2822,44 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -2571,6 +3062,19 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -2748,14 +3252,29 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] [[package]] name = "ttf-parser" @@ -2772,6 +3291,23 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -2806,8 +3342,15 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2819,6 +3362,11 @@ name = "uuid" version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] [[package]] name = "vello_common" @@ -3294,7 +3842,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3737,6 +4285,76 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.5", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zeno" version = "0.3.3" @@ -3843,3 +4461,45 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index 9106b14..65f1f80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "figgyplot" -version = "0.3.0" +version = "0.4.0" edition = "2024" rust-version = "1.99" license = "MIT OR Apache-2.0" @@ -24,6 +24,11 @@ egui = "0.36.2" egui-wgpu = "0.36.2" egui-winit = "0.36.2" fontdb = "0.24" +arboard = "3.6.1" +base64 = "0.22" +image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] } +rfd = "0.15.4" +sha2 = "0.10" [profile.release] lto = "thin" diff --git a/PYPI_README.md b/PYPI_README.md index 661aab4..ff75b30 100644 --- a/PYPI_README.md +++ b/PYPI_README.md @@ -2,8 +2,8 @@ **Plot NumPy data on the GPU. Edit directly. Reuse the settings in Python.** -Explore lines, scatter, errorbars, histograms, heatmaps, contours, categorical -bars, boxplots, pies and donuts in native windows or Jupyter. +Explore lines, scatter, errorbars, histograms, heatmaps, contours, polar and radar +plots, categorical bars, boxplots, pies and donuts in native windows or Jupyter. Adjust the axes, text, colors and layout, then copy executable Python to reuse the result across datasets. Export the edited chart directly to PNG. @@ -76,9 +76,11 @@ separately. These measurements include RGBA readback and do not measure UI FPS. ## Install -This description covers the published **figgyplot 0.3.0** release. The -[source checkout](https://github.com/shim9610/figgy-python#install) may contain -subsequent changes; its renderer migration does not update installed wheels. +This distribution is **figgyplot 0.4.0**, using renderer 0.18.1. It includes +reusable style tokens and execution-local style defaults, plus an opt-in review +workflow with targeted comments and reference images. See the +[style guide](https://github.com/shim9610/figgy-python/blob/main/docs/STYLE_TOKENS.md) +and [review guide](https://github.com/shim9610/figgy-python/blob/main/docs/REVIEW_FEEDBACK_VIEWS.md). Install from PyPI: @@ -104,8 +106,11 @@ import figgyplot as plt x = np.linspace(0, 20, 100_000, dtype=np.float32) fig, ax = plt.subplots(backend="native", figsize=(8, 5), dpi=120) -ax.plot(x, np.sin(x), id="signal") -ax.set_title("Edit, copy Python, and reuse") +line, = ax.plot(x, np.sin(x), lw=2, label="Signal", id="signal") +ax.set(title="Edit, copy Python, and reuse", xlabel="Time (s)", ylabel="Amplitude") +ax.tick_params(direction="out", labelsize=10) +ax.spines["top"].set_visible(False) +ax.legend(loc="upper right") try: fig.show() # Edit; close the window to continue. fig.savefig("edited.png") # Save the edited chart without the UI. @@ -113,6 +118,13 @@ finally: fig.close() ``` +The supported plotting API follows Matplotlib's object and unit conventions. +Existing objects remain editable through handles such as `line.set(color="C2")` +and `ax.title.set_fontsize(14)`. Copy Python generates public setters by default; +`fig.to_code(mode="exact")` retains full document recovery. See the +[API subset and limits](https://github.com/shim9610/figgy-python/blob/main/docs/MATPLOTLIB_API.md). +Matplotlib itself is not a runtime dependency. + In Jupyter, use `plt.subplots(backend="webgpu")` and display `fig.widget()`. Selection and editing run in the notebook's browser. Both hosts expose tick visibility/direction, numeric notation/significant digits, rich text editing, diff --git a/README.md b/README.md index 6b23626..bb07f8c 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,8 @@ host coverage, materials and draw modes, including the line-only Milkyway restri ## Install -This checkout documents **figgyplot 0.3.0**. Before that release is published, -PyPI installs the previous release (0.2.1); use the source-checkout instructions -below to try the features described here. +This checkout targets **figgyplot 0.4.0**. Until it is published, PyPI installs +0.3.0; use the source-checkout instructions below to try the new features. Install from **PyPI** with **Python 3.8–3.15**: @@ -212,6 +211,25 @@ finally: A small `figgy.pyplot` interface is also available. The API is familiar to matplotlib users, but does not implement the full matplotlib API. +For a shared-X comparison, construct both panels in one Figure: + +```python +fig, axes = figgy.subplots(2, 1, sharex=True, backend="native") +try: + axes[0].plot(x, np.sin(x), label="Signal") + axes[1].plot(x, np.cos(x), label="Reference") + axes[1].set_xlabel("Time (s)") + axes[0].legend(loc="upper left") + fig.savefig("comparison.png") +finally: + fig.close() +``` + +Cartesian and supported polar panels can use grids and shared axes. Categorical +bars, boxplots, radar charts and pies/donuts require separate Figures; they cannot +be placed in that grid. See the [capability matrix](docs/RENDERER_018_FEATURES.md#construction-and-capability-matrix) +for polar-root and mixed-chart restrictions. + | Native editor input | Action | | --- | --- | | Ctrl + wheel | Zoom X around the pointer | @@ -267,6 +285,18 @@ ax.set_ylim(0.0, 2.7) # The complete export also preserves legend placement and advanced styling. ``` +`fig.to_code()` and both editors' **Copy Python** prefer public Figure, Axes, +Axis and Artist setters for Cartesian appearance. The code targets matching +panels and source columns, retaining the destination Figure identity. Renderer +features without public setters remain in an explicit advanced block. Common bounded appearance uses named `ax.chart` controls. + +Use `fig.to_code(mode="exact")` for complete SSOT recovery, including stable +identities, tick policies, rich text and advanced layout. This compact form +restores omitted defaults and preserves resident arrays without uploading them +again. `compact=False` expands the diagnostic form. See the +[Matplotlib-shaped API, scoped appearance controls and supported subset](docs/MATPLOTLIB_API.md) for object +handles, units, coordinate conventions and explicit limitations. + Ranges, ticks and titles: - Property-panel edits and `fig.update("axis_bottom", major_spacing=2)` share @@ -290,16 +320,15 @@ Ranges, ticks and titles: lines, scatter or other Cartesian series uses 5%. Explicit padding overrides either default: `fig.fit(padding=0.05)` adds 5%, while `fig.fit(padding=0)` fits tightly to the rendered data extent. -- For selective control, use `fig.configure(config={...})` or read the current - state with `fig.get_settings()`, edit only the intended fields, then submit it - through `fig.apply_settings(state)`. For example: +- For a one-sided range edit that preserves ticks and formatting, use the + existing boundary's public Axis handle: ```python - fig.configure(config={"bottom_x": {"min": 20.0, "max": 80.0}}) + ax.secondary_xaxis("bottom").xaxis.set_view_interval(20.0, 80.0) ``` - This preserves that axis's tick and label settings and leaves the top axis - untouched. Include `top_x` too when the pair should share bounds. In contrast, + This leaves the top axis untouched. `get_settings`, `configure` and + `apply_settings` remain advanced APIs for exact document state. In contrast, `set_xlim` / `set_ylim` choose ticks and label formatting, `fit` also derives bounds from data, and title helpers can enlarge the title band. Direct SSOT edits do not run these adjustments. Rendering still validates settings and @@ -309,10 +338,23 @@ Ranges, ticks and titles: is capped at 99. Each change is reported once as a `figgy.RenderWarning` by the next native PNG/RGBA render (`render_rgba`, `to_png`, `savefig`, the PNG widget). Desktop windows and the browser (webgpu) backend do not report it. -- `fig.update("chart_title", text="Line 1\nLine 2", align="center")` aligns the +- `ax.set_title("Line 1\nLine 2", multialignment="center")` aligns the lines of multi-line text (`left`, `center`, `right`); a title taller than its band grows the band instead of being clipped. Unicode super/subscripts such as `10⁻³` are drawn as super/subscript text. +- Bounded chart titles accept up to 512 rich characters / eight lines. The rich + title holds the full wording; the legacy `title` field is a single-line, + 160-character compatibility fallback. Category labels and category/value axis + names remain single-line. Axis names set before `bar`/`barh`/`boxplot`/`bxp` + construction survive; explicit constructor axis names, including `""`, win. +- `ax.legend(loc="upper left")` places a Cartesian legend; the four supported + locations are upper/lower left/right. `fig.update("legend", corner="TopLeft")` + edits placement without rebuilding labels. Bounded legends support visibility + only; a location request raises an error. +- `ax.invert_xaxis()` / `ax.invert_yaxis()` toggle Cartesian direction while + keeping increasing limits and existing ticks. On `twinx`, Y inversion affects + only that secondary axis. Use `fig.update("axis_bottom", major_spacing=2)` for + regular ticks; arbitrary `set_xticks`/`set_yticks` positions are not provided. - `font=` must name an installed family or `"Liberation Sans"` (or `""` for the default); an unknown name raises `ValueError` on the native backend. The browser backend cannot see installed fonts and draws an unknown family with the bundled font. @@ -336,8 +378,10 @@ for run in range(3): You can also obtain the code directly with `edited_fig.to_code()`. An export contains settings, not NumPy arrays: recreate the plotted series with the same IDs, kinds and DPI before applying it. Axis ranges and titles are included; -override them afterward when a dataset needs different values. Advanced settings -use `fig.configure(...)` with ordinary Python dictionaries. +override them afterward when a dataset needs different values. Scoped appearance +objects cover palettes, vectors, contours, annotations, colorbars and bounded +materials; see the [API guide](docs/MATPLOTLIB_API.md#complete-appearance-controls). +`to_code(mode="exact")` provides full document recovery. ## Multiple windows @@ -356,6 +400,38 @@ the Python main thread. For an external main-thread loop, call `figgy.desktop.poll_events()` regularly. See the complete [multiple-window example](examples/multiple_windows.py). +## Reusable style tokens + +Apply appearance without replacing the destination data, axis ranges or wording: + +```python +fig.apply_style("paper") +style = fig.extract_style() +style.save("my-style.json") +other.apply_style(figgy.StyleToken.load("my-style.json"), size_scale=1.25) +``` + +Declare defaults once within a script; new Figures and later additions inherit +them, while explicit constructor arguments take precedence: + +```python +figgy.use_style("dashboard.operations") +fig, ax = figgy.subplots() +ax.plot(time, latency, style_key="service-a", label="Service A") +ax.plot([0, 10], [150, 150], style_key="warning", label="Supplied limit") + +with figgy.style_context("publication.nature"): + paper, paper_ax = figgy.subplots(figsize=(89 / 25.4, 62 / 25.4), dpi=300) +figgy.use_style(None) +``` + +Twenty presets include fourteen task profiles for publication, measurement, +print, teaching, presentations and seven dashboard purposes. Screen profiles +offer light/dark and compact/comfortable variants. See the +[catalog with actual renders](docs/STYLE_PROFILES.md) and +[style token guide](docs/STYLE_TOKENS.md). Scale and wording changes require +explicit opt-in; declarations are local to the current execution context. + ## Jupyter ```bash @@ -420,7 +496,8 @@ corners, textures and slice editing. Their bounded model contains up to 64 categories and uses the upstream radial renderer on the shared GPU device. All chart families support native desktop and Jupyter WebGPU editing and PNG export. Categorical bars and boxplots use their upstream bounded models; histograms and -contours use resident Cartesian columns. Multi-panel figures remain unsupported. +contours use resident Cartesian columns. Cartesian/polar panels support one-level +grids and shared axes; bounded families require their own single-panel Figures. `figsize` is in inches; `fontsize`, `linewidth` and `markersize` use points. Native Cartesian arrays are uploaded to a resident GPU column pool and reused during edits. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index abd326f..1d67c65 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -11,6 +11,8 @@ The font notices are shipped in `figgyplot/licenses/LICENSE-LiberationSans.txt` `figgyplot/licenses/LICENSE-ComicNeue.txt`, including when fonts are embedded in the native extension. The native desktop uses winit/wgpu and egui. PySide6 is optional benchmark-only tooling. +Review image attachments use arboard, image, base64 and sha2 (MIT OR Apache-2.0) +and rfd (MIT) for clipboard access, image decoding, content hashes and native file selection. The notebook extra uses anywidget and ipywidgets under their respective licenses. ## Packaged browser kernel diff --git a/docs/CHART_TYPES.md b/docs/CHART_TYPES.md index da2f29f..85e66bb 100644 --- a/docs/CHART_TYPES.md +++ b/docs/CHART_TYPES.md @@ -190,8 +190,10 @@ ax.set_ylabel("Count") # Choose fig.show() on desktop or display(fig.widget()) in Jupyter. ``` -Use separate Figures for these examples (bounded families cannot mix with -Cartesian series): +The following calls are independent examples, not one combined document. +Errorbars, histograms and contours are Cartesian and can share a Figure or grid. +Bars, boxplots, radar and pies/donuts each need a separate single-panel Figure; +they cannot mix with Cartesian series or occupy grid slots. ```python ax.errorbar([1, 2, 3], [2, 4, 3], yerr=[[.1, .2, .3], [.2, .1, .2]], xerr=.1) @@ -218,6 +220,11 @@ one box and a sequence of arrays as one box per array; it computes NumPy linear quartiles, actual sample whiskers within `whis * IQR` (default 1.5), and all outliers. It does not silently drop outliers to fit the bounded limits (128 per box, 4096 total). `bxp` uses supplied statistics without recomputing them. +Axis names may be set before or after `bar`/`barh`/`boxplot`/`bxp`. Horizontal +charts map the X name to the value axis and the Y name to the category axis. +Explicit `category_title`/`value_title` constructor properties override inherited +names, including an explicit empty string. Category labels and these axis names +are single-line; use the chart title or annotations for longer descriptions. The hollow circles beyond the whiskers are these individual outlier values. They are data points, not extra boxes or selection handles. With the default `whis=1.5`, the fences are `Q1 - 1.5*IQR` and `Q3 + 1.5*IQR`; whiskers extend @@ -230,6 +237,32 @@ these setters do not perform automatic layout or adjust ticks. Constructors and layout. Copy Python replays style/configuration against matching category and series IDs, preserving the destination's data values. +Copy Python omits unchanged renderer defaults and writes rich text in compact +runs. It resets chart-level defaults before replaying its bounded patch, so a +destination's previous material or label styling cannot survive an omitted +default field. Existing category/series values are preserved. For manual edits, +`configure_bounded` and `configure_radial` keep their selective-patch behavior +unless `reset=True` is explicitly supplied. `fig.to_code(compact=False)` exposes +expanded dictionaries for diagnostics; `get_settings()` always returns full SSOT. + +`ax.set_title("Comparison\nObserved sample", fontsize=18)` uses the independent +rich title, with up to 512 characters and eight lines. It does not change the +category/data-label font. Titles set before construction keep their text and +rich formatting. The legacy single-line `title` is only a compatibility fallback; +`chart_title.text.segments` holds the complete displayed wording. Raw SSOT edits +still have to satisfy both fields' model constraints. + +For Cartesian charts, `ax.legend(loc="upper left")` accepts upper/lower left/right +and their renderer names (`TopLeft`, `TopRight`, `BottomLeft`, `BottomRight`). +`fig.update("legend", corner="TopLeft", fontsize=11)` edits the existing legend. +Bounded legends expose visibility through `ax.legend(False)`; `loc` is rejected. + +Use `ax.invert_yaxis()` for depth increasing downwards, after setting increasing +limits such as `ax.set_ylim(0, 700)`. Inversion preserves limits and tick spacing; +on `twinx` it affects only that Y axis. Cartesian tick spacing is configurable +with `fig.update("axis_bottom", major_spacing=2, minor_count=0)`. Explicit +arbitrary tick-position setters are not part of the API. + Both hosts open the property panel on selection. Drag annotations to move them; use Layout and plot-area handles to resize; Fit restores automatic plot layout. Left/right arrows step through bounded data selections, Escape clears selection, diff --git a/docs/DESKTOP_EDITOR.md b/docs/DESKTOP_EDITOR.md index 6f01dc0..2cd1977 100644 --- a/docs/DESKTOP_EDITOR.md +++ b/docs/DESKTOP_EDITOR.md @@ -422,3 +422,34 @@ Members of an explicitly shared X/Y group also share the primary axis's automatic/fixed tick policy. A numeric spacing edit from any member updates the shared spacing; enabling automatic spacing from any member restores it for the group. Opposite-edge styling and secondary Y axes remain locally owned. + +## Figure, panel and plot geometry + +Use **Layout**, then click a chart. The first click selects its plot area and +opens its resize handles and geometry controls. **Panel geometry** changes the +outer chart rectangle; **Plot area** changes the inner region used by its axes +and data. X/Y/width/height use logical document pixels, independent of window +DPI. Moving plot edges preserves axis ranges and resident data. Bounded charts +show their current measured plot rectangle while automatic, materialize a manual +rectangle on the first edit, and offer **Use automatic plot layout**. + +![Plot geometry controls in the actual Jupyter editor](images/layout-plot-controls.png) + +Select **Figure** in Objects to change document width/height. Grid documents +reflow their named slots and preserve their row/column weights and gaps. Manual +panel layouts scale their outer rectangles. These are accepted SSOT edits with +Undo/Redo and code replay. Window resizing scales the document view to the +available stage while preserving its aspect ratio. Widen the properties pane by +dragging its separator in native windows or its resize grip in the browser. + +Polar and radar charts do not use wheel zoom, including modifier-wheel input. +Change their declared radial ranges explicitly in Properties. Cartesian and +box-plot wheel navigation retain their existing behavior. + +Dragging reuses native property descriptions and defers browser property-form +reconstruction until release. Chart edits and their final values still commit; +this is a host UI optimization, not a change to renderer drawing or caches. +Dragging an annotation changes only that annotation's coordinates; other +annotations, series, axes and layout stay unchanged. Renderer redraw policy +remains upstream-owned. The host regression checks dump and compare complete +SSOT snapshots and verify no additional column upload during the gesture. diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index 2fbe553..a707ce7 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -14,7 +14,8 @@ import figgyplot.pyplot as pyplot ``` Version 0.3.0 is published (registry and sdist checked 2026-10-20); it uses -renderer 0.17.0 and model 0.11.0. This source tree adds the renderer 0.18 migration. +renderer 0.17.0 and model 0.11.0. The 0.4.0 source tree uses renderer 0.18.1 and +model 0.12.1; publication requires its own complete build and installation gates. The GitHub repository remains `shim9610/figgy-python`. No `figgy` compatibility package is shipped: that name belongs to an unrelated PyPI package. Existing source users should change `import figgy` to diff --git a/docs/EDITOR_COMMANDS.md b/docs/EDITOR_COMMANDS.md index b083caf..3b9998a 100644 --- a/docs/EDITOR_COMMANDS.md +++ b/docs/EDITOR_COMMANDS.md @@ -20,6 +20,14 @@ Ordinary commands resolve the object and property again against the current document. An invalid choice, removed object, stale revision or failed renderer validation preserves the accepted document and its history. +Notebook form drafts wait for earlier selection/edit acknowledgements before +submission. If a newer Python revision arrives later, a form can retry once +using the actual before/after SSOT field changes. Retry requires unchanged +figure/panel ownership and no conflicting changes to those fields. Arrays are +compared atomically, so insertion or reordering cannot redirect an index edit. +Unrelated accepted changes are retained; conflicting fields and stale full +snapshots remain explicit errors. A subsequent valid edit clears the error. + The source arrays stay resident. Mapping controls choose registered columns; they do not infer numbers from rendered colors or upload replacement data. The panel colorbar owns the colormap and value domain. Explicit point-color diff --git a/docs/EDITOR_CONTROLS.csv b/docs/EDITOR_CONTROLS.csv index 9dc3874..67e9530 100644 --- a/docs/EDITOR_CONTROLS.csv +++ b/docs/EDITOR_CONTROLS.csv @@ -98,6 +98,14 @@ radial_split,bounded,Detail shape/Donut/inner_radius,/radial/split/kind/Donut/in radial_split,bounded,Detail shape/mode,/radial/split/kind,choice,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay radial;radial_split,bounded,Pie / donut/Donut/inner_radius,/radial/kind/Donut/inner_radius,number,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay radial;radial_split,bounded,Pie / donut/mode,/radial/kind,choice,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay +boxplot,bounded,Plot area/automatic,/boxplot/decorations/data_area,action,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay +categorical,bounded,Plot area/automatic,/categorical/decorations/data_area,action,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay +radar,bounded,Plot area/automatic,/radar/decorations/data_area,action,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay +radial;radial_split,bounded,Plot area/automatic,/radial/decorations/data_area,action,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay +boxplot,bounded,Plot area/bounds,/boxplot/decorations/data_area,rectangle,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay +categorical,bounded,Plot area/bounds,/categorical/decorations/data_area,rectangle,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay +radar,bounded,Plot area/bounds,/radar/decorations/data_area,rectangle,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay +radial;radial_split,bounded,Plot area/bounds,/radial/decorations/data_area,rectangle,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay boxplot,bounded/annotation/arrow,Annotation/order,/boxplot/annotations/items,number,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay categorical,bounded/annotation/arrow,Annotation/order,/categorical/annotations/items,number,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay radar,bounded/annotation/arrow,Annotation/order,/radar/annotations/items,number,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay @@ -1238,6 +1246,8 @@ panels,figure/inventory,Figure grid/gap,/identity/layout/gap,number,Editor::cata panels,figure/inventory,Figure grid/row_weights,/identity/layout/row_weights,numbers,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay panels,figure/inventory,Figure grid/slot/left,/identity/layout/placement/left,choice,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay panels,figure/inventory,Figure grid/slot/right,/identity/layout/placement/right,choice,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay +boxplot;categorical;contour;errors;field;histogram;line;mapped_points;panels;polar;radar;radial;radial_split;vector_grid;vector_points,figure/inventory,Figure size/height,/config/chart_area/height,number,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay +boxplot;categorical;contour;errors;field;histogram;line;mapped_points;panels;polar;radar;radial;radial_split;vector_grid;vector_points,figure/inventory,Figure size/width,/config/chart_area/width,number,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay contour;errors;field;histogram;line;mapped_points;panels;polar;vector_grid;vector_points,figure/inventory,Grid/major_x_color,/config/grid/major_x_color,color,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay contour;errors;field;histogram;line;mapped_points;panels;polar;vector_grid;vector_points,figure/inventory,Grid/major_x_style,/config/grid/major_x_style,text,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay contour;errors;field;histogram;line;mapped_points;panels;polar;vector_grid;vector_points,figure/inventory,Grid/major_x_width,/config/grid/major_x_width,number,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay @@ -1287,6 +1297,7 @@ panels,panel/left,Panel geometry/height,/config/chart_area/height,number,Editor: panels,panel/left,Panel geometry/width,/config/chart_area/width,number,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay panels,panel/left,Panel geometry/x,/config/chart_area/x,number,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay panels,panel/left,Panel geometry/y,/config/chart_area/y,number,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay +panels,panel/left,Plot area/bounds,/config/data_area,rectangle,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay panels,panel/left,Shared axes/x,/identity/groups,choice,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay panels,panel/left,Shared axes/y,/identity/groups,choice,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay panels,panel/left/axis_bottom,Layout and appearance/inverted,/config/bottom_x/inverted,boolean,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay @@ -1506,6 +1517,7 @@ contour;errors;field;histogram;line;mapped_points;polar;vector_grid;vector_point contour;errors;field;histogram;line;mapped_points;polar;vector_grid;vector_points,panel/main,Panel geometry/width,/config/chart_area/width,number,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay contour;errors;field;histogram;line;mapped_points;polar;vector_grid;vector_points,panel/main,Panel geometry/x,/config/chart_area/x,number,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay contour;errors;field;histogram;line;mapped_points;polar;vector_grid;vector_points,panel/main,Panel geometry/y,/config/chart_area/y,number,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay +contour;errors;field;histogram;line;mapped_points;polar;vector_grid;vector_points,panel/main,Plot area/bounds,/config/data_area,rectangle,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay polar,panel/main,Polar coordinates/angle_unit,/config/polar/angle_unit,choice,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay polar,panel/main,Polar coordinates/clockwise,/config/polar/clockwise,boolean,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay polar,panel/main,Polar coordinates/inner_radius_fraction,/config/polar/inner_radius_fraction,number,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay @@ -1933,6 +1945,7 @@ panels,panel/right,Panel geometry/height,/config/subplots/0/config/chart_area/he panels,panel/right,Panel geometry/width,/config/subplots/0/config/chart_area/width,number,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay panels,panel/right,Panel geometry/x,/config/subplots/0/config/chart_area/x,number,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay panels,panel/right,Panel geometry/y,/config/subplots/0/config/chart_area/y,number,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay +panels,panel/right,Plot area/bounds,/config/subplots/0/config/data_area,rectangle,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay panels,panel/right,Shared axes/x,/identity/groups,choice,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay panels,panel/right,Shared axes/y,/identity/groups,choice,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay panels,panel/right/axis_bottom,Layout and appearance/inverted,/config/subplots/0/config/bottom_x/inverted,boolean,Editor::catalog_properties / specialized form,webgpu.js::catalogProperties / properties,Figure.edit_property,group behavior + code replay diff --git a/docs/HOST_VALIDATION.md b/docs/HOST_VALIDATION.md index 919671e..9d8e243 100644 --- a/docs/HOST_VALIDATION.md +++ b/docs/HOST_VALIDATION.md @@ -2,11 +2,120 @@ ## Current 0.3 validation setup -The current binding is `figgyplot 0.3.0`, targeting `figgy-renderer 0.18.0` -and `figgy-model 0.12.0`. The dated measurements and test totals below belong +The current binding is `figgyplot 0.3.0`, targeting `figgy-renderer 0.18.1` +and `figgy-model 0.12.1`. The dated measurements and test totals below belong to earlier checkpoints; they are not a test count or performance claim for the current release candidate. +### Native annotation binding and review lifecycle (2026-10-22) + +The native drag path previously constructed a complete temporary `ChartView` +for every pointer move merely to obtain its annotation map. The renderer API +also creates grid, axis and annotation GPU resources. The host now reuses the +presented map in its physical pixel space, falling back to a fresh logical map +when layout, scale or annotation identity has changed. The renderer's own +`ChartRenderStamp` selects axis versus annotation refresh; series styles rebuild +only when their specs, display scale or pool generation change. No renderer +internals or drawing/cache policies are modified. + +[Before/after measurements](measurements/native-annotation-drag-20261022.json) +record normal and burst gestures for text and arrows at two sizes. For each +32-move gesture, the fixed native path makes zero temporary annotation views, +zero axis refreshes and zero style rebuilds. Complete SSOT checks preserve +everything except the moved coordinates. A new counter assertion fails against +the instrumented baseline wheel. Review-controller gestures additionally record +durable manifest writes. Timings are CPU wall times on llvmpipe, with input-driver +waits excluded; they are not physical-GPU frame-rate measurements. + +An installed release wheel passed 212 focused review, history, recovery and +negative-oracle cases, and 134 workflow/asset checks. Two 12-case actual-host +runs cover dragging/resize/text/Undo and review submission/storage failures, +Korean targeted comments, native connect and both hosts' finish controls. The +final wheel repeated the three new/expanded UI cases. Full collection is 827 +cases; these local checks do not establish that commit's full CI result. +[Package checks](measurements/native-review-package-20261022.json) compare all +installed package files with the release wheel, and packaged Python/browser/ +skill sources with the worktree. + +Review storage tests cover process readers/writers, nested locking, Windows +sharing-error retries, unrelated errors and bounded failure propagation. A +separate Windows job executes the same storage cases with real byte locks. +Successor approval tests reject changes to arrays even when extents match, and +source-backed finish validates exact approved settings/data without new windows. +Colour swatches and reference-image attachments remain a future improvement. + +Run 65's four Linux pytest summaries report 827 passing cases, and the real +Windows storage job passed all seven cases. Shard 0 nevertheless timed out: +its 207 cases finished in 11m19s, then a clipboard-owner process retained the +CI logging pipe until job cancellation roughly 33 minutes later. The execution +audit correctly rejected a cancelled shard. Artifact upload itself took seconds. +The [timestamped failure and local controls](measurements/ci65-clipboard-pipe-20261022.json) +separate these phases. A clipboard writer now redirects the background owner's +streams without terminating the owner. A real X11 negative control verifies +Python can exit while its inherited output remains open; the fixed writer's +output closes while its Unicode clipboard remains available. Tests also run +through the actual tee pipeline and exercise JSON settings paste. Pytest return, +interpreter exit and pipeline exit have distinct CI markers. Assertions, audit +rules and budgets are unchanged; full collection now contains 828 cases. + +### Layout and review editing follow-up (2026-10-22) + +The installed release wheel passed **273 focused model, history, recovery and +oracle cases**, **12 real UI cases** (9 paired native/Jupyter and 3 native-only), and **134 workflow +and packaged-asset checks**, with no skips in those runs. Full collection contains +809 cases; the focused runs do not establish a complete CI result. + +The input cases cover first-click plot geometry editing, inspector resizing, +unchanged plot dimensions when opening the inspector, disabled polar/radar wheel +zoom, annotation drawing, targeted Korean review comments, comment editing and +deletion, title/series ownership, and annotation dragging. Geometry checks also +resize radar, box, pie/donut and bar documents and restore their exact settings +with Undo. Subscription is checked through both Python and the CLI across the +complete Figure approval barrier. + +Annotation drag checks save complete before/after SSOT dumps. Only the selected +arrow's four coordinate values change; the other annotation, series, axes and +layout remain identical, and no columns are uploaded again. During the synthetic +gesture, property descriptions rebuild 0 times in native and 1 time in the +browser. [Recorded counters and SSOT changes](measurements/layout-review-host-work-20261022.json) +include the tested wheel digest. Elapsed times include input-driver waits and +are not a physical-GPU frame-rate measurement. Renderer drawing/cache behavior +is unchanged. + +Environment: Linux x86_64, Python 3.12.14, llvmpipe Vulkan, Xvfb/Openbox, +Chromium 154/SwiftShader and installed Noto Sans CJK KR. Rust formatting, JS +syntax, installed-package checks and property-catalog inventory checks passed. +Physical GPU and Windows/macOS runtime behavior remain unverified by these runs. + +PR run 61 did not execute tests: its build job exhausted the existing 20-minute +budget after the Azure Ubuntu mirror downloaded 105 MB in 14 minutes 37 seconds +(120 kB/s). Font unpacking/setup took seconds. The +[timestamped log evidence](measurements/ci61-ubuntu-mirror-20261022.json) records +the download separately from font configuration and build cancellation. Both CI +install steps now use the official Ubuntu archive for the same signed packages; +test assertions, execution auditing and job budgets are unchanged. + +Run 62 built the wheel successfully, but one shard's setup failed when the +image's unrelated Microsoft APT feed returned HTTP 403. Its log also showed +that replacing a host in `ubuntu.sources` did not override the image's separate +mirrorlist. The final [shared installer](../scripts/install_ci_runtime.sh) uses +an explicit temporary source list for the official Ubuntu archive and security +feeds, with the Ubuntu archive signing key and image source parts disabled for +both APT commands. No repository error is ignored. Three local installer checks +exercise the real APT URI planner, detect vendor feeds when isolation is removed, +and stop installation after an update failure. See the +[failed setup evidence](measurements/ci62-image-apt-sources-20261022.json). + +Run 63 completed three shards with 202 passing cases each. The fourth exposed +a native form regression: after Esc cleared a pie selection, the geometry form +hid the ordinary title/material controls. The existing test was retained and +reproduced locally before the fix. Geometry now takes priority for explicit plot +selection or Layout mode; ordinary unselected editing keeps its global form. +The expanded test edits title/material, checks Copy Python and no readbacks, and +compares complete settings across mode switches with the same settled pointer +hover state. All 12 focused UI cases and the 48 cases not executed after that CI +failure passed locally. See the [before/after control evidence](measurements/native-unselected-form-20261022.json). + Automatic pull-request checks are enabled in [PR tests](../.github/workflows/pr-tests.yml), including Linux native/Jupyter integration. The [interaction parity guard](../scripts/check_interaction_parity.py) @@ -626,3 +735,66 @@ RGBA readbacks/PNG display transport and unchanged source upload counts. Workflow/assets preflight has 112 passes; formatting, locked compilation, JavaScript syntax and installed browser asset comparisons also pass. Full Actions integration results are reported by the PR checks, independently of local results. + +## Native typing follow-up (2026-10-22) + +The annotation-drag measurements did not cover typing latency. Actual X11 +character input exposed a separate wrapper cost: every annotation-property +keystroke serialized and reparsed the entire Settings document, and built its +property description before and after accepting that edit. The native form now +serializes only the edited annotation, then passes its typed Config through the +same model validation, renderer acceptance and history boundary used by JSON +settings. The public JSON boundary still rejects unknown fields. Text changes +remain immediate; there is no delayed commit, skipped validation or dropped input. + +An unchanged document's object navigation is reused by revision during inline +editing and comment composition. Ending an inactive history gesture avoids a +document snapshot. Review polling reads the small comment draft once instead of +serializing and decoding full window diagnostics twice. Durable draft writes +and the approval barrier remain in place. Renderer source and repaint policy +are unchanged. + +`scripts/profile_text_input.py` presents 24 separately delivered character +inputs in each of four native editors, with the durable review controller active, +12 series and 16 annotations. On Linux/llvmpipe, annotation-property UI work +fell from **110.36 to 74.48 ms total** (4.60 to 3.10 ms per input; about 33%). +Whole-frame totals were 460.00 and 417.52 ms, including software submit/present. +The other editors had small UI costs before and after; whole-frame timings +varied, so this does not establish a general frame-rate improvement or Windows +IME performance. Raw stages, driver waits and the tested wheel hash are in +`docs/measurements/native-typing-20261022.json`. Driver wait times are not FPS. + +Four new real-input cases cover inline text, annotation properties, general +review comments and targeted comments with the inspector open. They verify +per-character text, CJK paste, the renderer's structured superscript encoding, +exact unrelated SSOT preservation, unchanged uploads, Undo/Redo and durable +recovery. Typing creates no temporary chart views, axis rasters or series styles; +unchanged drafts do not rebuild navigation or property descriptions. Six existing +native/Jupyter checks for numeric editing, multiline/resize/format preservation, +CJK targeted comments and annotation drag also passed. Settings/history/review +checks passed 240 cases, and workflow/assets checks passed 72. One UI case and +seven action-contract cases initially lacked their environment flags; all eight +were subsequently executed and passed in those totals. Full PR CI is separate +from these local results. + +## Review summary and exact detail views (2026-10-22) + +The [feedback view contract](REVIEW_FEEDBACK_VIEWS.md) adds summary-first agent +consumption while preserving full protocol-1 checkpoints. Object summaries use +the native editor's stable ownership addresses and retain every exact operation +ID. Detail queries return the original operations in order and complete object +snapshots, including deleted objects. Reverted edits disappear; comments remain +separate; formatting-only and truncated text remain distinguishable. Binary +reference changes are reported even when settings and axis bounds are identical. + +Seventeen new summary/schema/CLI tests and 169 existing review, recovery, +history, navigation and property cases passed. Six actual native/Jupyter cases +passed, covering typing, contextual editor behavior and targeted-comment IDs +through the new summary/detail APIs. Workflow/assets checks passed 72 cases +with the actual maturin-action metadata supplied. In total these are 264 local +cases; full PR CI remains a separate check. The installed 35-file release +package matches the tested wheel and worktree sources. The sample JSON and +wheel hash are in `docs/measurements/review-summary-20261022.json`: 42,610 bytes +of complete feedback become a 1,794-byte summary, while the six original +operations remain unchanged. No renderer or LLM timing improvement is inferred +from that payload measurement. diff --git a/docs/MATPLOTLIB_API.md b/docs/MATPLOTLIB_API.md new file mode 100644 index 0000000..ef218f4 --- /dev/null +++ b/docs/MATPLOTLIB_API.md @@ -0,0 +1,292 @@ +# Matplotlib-shaped plotting API + +Figgy implements a plotting subset with Figure, Axes, Axis, Text, Spine, +Legend and Artist handles. NumPy is the only required Python dependency; +Matplotlib is used as an independent reference in tests, not at runtime. + +```python +import figgyplot as fg +from figgyplot.ticker import MultipleLocator + +fig, ax = fg.subplots(figsize=(7, 4), dpi=120) +line, = ax.plot([0, 1, 2], [2, 1, 4], lw=2, marker="o", label="Signal") +ax.set(title="Measured signal", xlabel="Time (s)", ylabel="Amplitude") +ax.set_xlim(-0.5, 2.5) +ax.xaxis.set_major_locator(MultipleLocator(0.5)) +ax.tick_params(direction="out", labelsize=10) +ax.spines["top"].set_visible(False) +ax.grid(True, axis="y", color="gray", alpha=0.3) +legend = ax.legend(loc="upper left", fontsize=10) +ax.title.set_fontweight("bold") +line.set_color("C2") +fig.savefig("signal.png") +``` + +Font sizes, line widths, tick lengths and line-marker diameters use points. +Scalar **and array** `scatter(s=...)` values are marker areas in points squared. +Previously, array sizes were automatically normalized into a diameter range; +that was inconsistent with Matplotlib. Explicit `size_domain` / `size_range` +select Figgy's optional data-to-size mapping instead. + +`scatter(c=...)` accepts a single color, explicit color lists/RGB(A) rows, or +numeric values for a scalar map. Explicit colors do not create a colorbar. CSS +named colors, grayscale strings, `C0` cycle references and short colors are +supported. Independent opacity for scalar-mapped points requires renderer +support and raises explicitly; uniform/explicit-color opacity is supported. + +`set_title`, `set_xlabel` and `set_ylabel` return editable Text handles. `plot` +returns a list of artists, while `scatter` and `imshow` return one artist. +`ax.lines`, `ax.collections`, `get_legend()` and stable `fig.artist(id)` lookup +edit existing objects without uploading their source columns again. + +```python +points = ax.scatter([0, 1], [2, 3], s=[16, 64], c=[10, 20], cmap="viridis") +bar = fig.colorbar(points, ax=ax, label="Temperature") +points.set_cmap("magma") +points.set_clim(5, 25) +bar.ax.tick_params(labelsize=10) + +note = ax.text(0.05, 0.95, "Review", transform=ax.transAxes) +note.set_text("Reviewed") +ax.annotate("Peak", xy=(1, 3), xytext=(0.5, 0.8), + textcoords="axes fraction", arrowprops={"arrowstyle": "->"}) +``` + +`transAxes` and `axes fraction` use a bottom-left origin, as in Matplotlib. +The older Figgy `coords="ratio"` option keeps its top-left origin for existing +scripts. Text layout uses Figgy's renderer; this does not promise identical +font metrics, baseline placement or pixels to Matplotlib. Annotations currently +belong to the parent panel; subplot annotations are rejected. + +## Supported subset and extensions + +- Tuple, partial and descending Cartesian limits are accepted. Boxplots and radar + accept an explicit increasing value range. Categorical bars use the renderer's + automatic range and reject manual limits with `NotImplementedError`; pie/donut + charts have no adjustable numeric axis. Automatic bounded ranges are not exposed + by the current renderer, so getters do not invent limits. +- `tick_params` supports direction, lengths, label sizes/colors/font family, + boundary visibility and grid styling. Regular, zero-aligned ticks are + supported. Arbitrary positions/labels, tick padding and custom callable + formatters require renderer capabilities that are not available yet. +- `MultipleLocator` accepts a regular locator's public `tick_values` protocol, + including Matplotlib's locator. Figgy supplies `AutoLocator`, `AutoMinorLocator`, + `LogLocator` and `SignificantFormatter` for renderer-supported rules. Explicit + secondary-boundary locators affect only that boundary. Colorbar locators + affect the colorbar without changing the main axes or data normalization. +- The renderer shares tick and spine color, width and line style. Spine edits + therefore affect that boundary's ticks too. `tick_params` rejects a requested + independent color/width change that would alter a visible spine implicitly. +- Existing lines accept `line.set(marker="s", ms=7)` without recreating or + reuploading their data. Marker style is stored once a marker exists; latent + marker sizes before that, and removing markers while retaining their hidden + style, are outside the supported subset. +- Legends support corner locations, font sizes, family/size in `prop`, frame + colors/alpha and border padding. Bounded legends support visibility only. +- `secondary_xaxis` / `secondary_yaxis` expose an existing boundary. Arbitrary + transformed secondary axes are rejected; use `twinx()` for a separate Y scale. +- `get_position` / `set_position` use normalized **data-area** bounds. + `get_panel_position` / `set_panel_position` are Figgy extensions for the outer + panel, including labels and title. Bounded automatic geometry is guarded. +- Title `set_offset`, `set_layout_height`, axis `set_layout_margin`, + `set_ticklabel_offset`, and legend `set_offset` are named Figgy extensions; + their lengths use points. `arrow()`'s legacy sizes also use points. + +Unsupported arguments fail explicitly. Renderer materials, bounded-chart +containers and arbitrary decoration APIs are **not** full Matplotlib equivalents. Bounded charts expose named Figgy controls: + +```python +ax.chart.set_label_style(fontsize=12) +ax.chart.set_series("series_0", color="C2", label="Observed") +ax.chart.set_category_label("category_0", "Baseline") +ax.chart.set_material("paper") +``` + +These setters keep existing values and IDs. Shared bounded labels have a renderer +limit of 8–32 logical pixels (8–64 for pies), converted from points at the Figure +DPI; an out-of-range request raises without changing the document. The chart +title has independent typography. `set_orientation`, `set_group_width` and +`set_gap` control categorical bars/boxes; `set_slice`, `set_startangle` and +`set_inner_radius` control pies/donuts. + +The advanced SSOT methods remain available; they are not required by the common +plotting example above. + +## Editing and Python export + +`fig.batch_update()` commits existing style/layout edits as one revision and +rolls back on an error. Nested blocks provide savepoints. Create/upload data +outside this block. Concurrent accepted native edits are detected at commit. + +`fig.to_code()` and Copy Python prefer public object calls for Cartesian and +bounded appearance. Replay targets an existing Figure with matching panel +ownership, stable artist IDs, source bindings and DPI; the destination Figure +identity is retained. Named Figgy objects cover materials, decorations, palettes, sparse overrides, +fields, vectors, contours, polar projections, annotations and colorbar axes. +The library does not claim full Matplotlib API coverage; exact internal state +that lacks a visual authoring API retains a clearly marked recovery block. Style constants are shortened only +when they round-trip to the same renderer f32 value. Scientific values and arrays +are never rounded by export. + +`fig.to_code(mode="exact")` is the full recovery form: it restores the complete +accepted SSOT, including identities, editor metadata and advanced chart state. +`compact=False` expands that form for diagnostics. Exact recovery tests continue +to compare complete SSOT and arrays; public-API tests separately compare actual +Matplotlib properties, current native pixels and future automatic/fixed tick +behavior. Public replay preserves accepted tick spacing and the policy used by +subsequent zooms; it does not re-fit the source layout. `axis.set_tick_auto(True)` +selects future automatic ticks without changing the current spacing, `False` +selects fixed ticks, and `None` removes the explicit policy override. + +An explicit colorbar `label=` or `bar.set_label(...)` shows the label by default; +`visible=False` keeps it hidden. The wrapper uses the renderer's text bounds to +reserve space between unpositioned labels and tick text. Explicit label offsets +and layout margins remain available for manual placement. Explicit face colors, +including white, remain part of the accepted document until style reset. + +Editor startup is independent of public-code export: an export failure keeps the +chart available and provides a complete exact recovery recipe with its diagnostic. +The PNG editor waits for pending edits to be accepted before copying code. +Independent opposite-axis scales and angle/time tick formats use scoped Axis +setters and SignificantFormatter/TimestampFormatter. + +## Complete appearance controls + +Features outside Matplotlib's plotting subset have named Figgy objects. They +accept ordinary colors, marker symbols, point sizes and stable IDs. Passing a +renderer `render_type`, `matrix`, `color_map` or style-enum tree is not necessary. +These APIs use the same accepted document edits as the native and notebook UI. + +| Object | Controls | +| --- | --- | +| `artist.markers` | Shared marker style, color/size mappings, palettes, individual overrides | +| `artist.errors` | Error strokes/caps, palettes and individual overrides | +| `artist.bins` | Bin fills/outlines, width, gap and individual overrides | +| `artist.vectors` | Stroke, arrowhead, pivot, data/pixel scaling and scalar coloring | +| `artist.contours` | Levels, strokes, per-level colors and inline labels | +| `artist.set_field_fill(...)` | Continuous/banded fields, flat/interpolated shading, opacity | +| `artist.set_grid(...)` | Registered scalar matrix, orientation and center/edge coordinates | +| `artist.legend_label` | One series' legend typography | +| `ax.legend_handle.content` | Custom rich legend keys, including hidden legends | +| `ax.get_overlay(id)` | Existing secondary Y axis and its independent legend | +| `ax.get_colorbar()` | Shared color scale, placement, outline, NaN color and independent axis | +| `ax.chart` | Materials, camera, labels, series/cell/slice styles and decoration placement | +| `fig.annotation(id)` | Existing text, line, span or arrow, retaining its ID | + +```python +from figgyplot import MarkerStyle, ErrorBarStyle, TextRun, ContourLabel +from figgyplot.ticker import SignificantFormatter, TimestampFormatter + +points.markers[2].set(color="red", marker="s", markersize=8) +points.markers.set_palette([ + MarkerStyle(color="C0", marker="o"), + MarkerStyle(color="C1", marker="s", fillstyle="none"), +], source=fig.data.column("class_index")) +points.markers.map_color(fig.data.column("temperature"), clim=(250, 400), cmap="magma") +points.markers.map_size(fig.data.column("population"), domain=(0, 1e6), + markersizes=(4, 16), scale="area") +points.set_fillstyle("none") +``` + +`fig.data.add(name, values)` explicitly registers a new column outside a batch; +`fig.data.column(name)` references an existing column without copying data. +Constructors register their own columns. `artist.data.x`, `.y`, `.values`, `.u`, +`.v`, `.color`, `.size` and the applicable palette-index roles return resident +references. Rebinding a map does not replace scientific coordinates or values. +`fig.data.grid(columns, axis="y", coordinates="centers")` describes matrix +storage; it does not transpose, interpolate or resample the arrays. + +Palette slots inherit missing properties. `markers.palette[i].set(...)` edits a +slot; `markers[i].set(...)` edits one point. Error bars have the same interfaces. +`set_overrides([(index, MarkerStyle(...)), ...])` preserves declaration order, +including repeated partial records. `set_palette(None)`, `set_overrides(None)`, +`map_color(None)` and `map_size(None)` explicitly clear those optional policies. +Native precedence remains shared style → palette → scalar mapping → point +override. An explicit point color therefore remains fixed when its colormap +changes. Filled/hollow markers use normal symbols and `fillstyle="full"/"none"`. +Bin overrides use `BinStyle`, with no palette because the renderer has none. + +```python +field.contours.set_levels([1, 2, 5]) +field.contours.set_style(linewidth=1, linestyle="--", colors=["C0", "C1", "C2"]) +field.contours.set_labels(fontsize=9, formatter=SignificantFormatter(3), + positions=[ContourLabel(1, x=2, y=3, tangent=(1, 0))], + spacing=80, background="white", padding=2) +field.set_field_fill(mode="bands", shading="interpolated", alpha=0.7) +``` + +Contour labels use explicit data positions/tangents; an empty positions list +restores automatic placement. `set_labels(None)` removes the optional label +configuration. Vector scaling has Figgy semantics: `units="data"` multiplies +components in data units, while `units="pixels"` specifies logical pixels per +component unit. It is not Matplotlib's reciprocal `quiver(scale=...)` convention. +Polar projection uses `ax.set_polar_options(unit="radians", zero=90, +clockwise=True, interpolation="arc", pieces=8)`; zero/label angles are degrees, +sector endpoints use the selected unit. Unsupported polar plot combinations +remain rejected. + +```python +bar = ax.get_colorbar() +bar.set_layout(side="bottom", align="center", fraction=0.8, + thickness=12, gap=8, offset=(0, 4)) +bar.set_nan_color("gray") +bar.set_outline(color="black", linewidth=0.5) +bar.ax.yaxis.set_major_formatter(SignificantFormatter(4, style="scientific")) +bar.set_cmap("magma") +bar.set_clim(250, 400) +``` + +Both orientations use the same colorbar axis object (`bar.ax.xaxis` aliases +`bar.ax.yaxis`). `ax.add_colorbar(...)` can explicitly create a standalone scale; +it does not turn arbitrary per-point colors into a scalar mapping. Colors and +limits edited through an artist or colorbar share one update path. A combined +palette/domain edit validates the complete domain and fits its label once. +Colorbar `tick_params` uses the same option translator as plot axes; grid options +are rejected because colorbars have no grid, leaving the entire edit unapplied. +`TimestampFormatter(unit="ms", timezone=540, pattern="%Y-%m-%d")` formats numeric +Unix timestamps, without converting their data. Degrees/radians are additional +`SignificantFormatter` styles. `Axis.set_scale` and `.line` edit just that +boundary, including independent secondary/colorbar axes. + +```python +note = ax.text(.1, .2, [TextRun("Temperature ", bold=True), + TextRun("T", italic=True), TextRun("max", script="sub")], + coords="ratio", id="peak-note") +note.set_bbox(facecolor="#ffffffe6", edgecolor="gray", linewidth=0.5, pad=4) +arrow = ax.arrow(0, 0, 1, 1, id="peak-arrow") +arrow.set_endpoints((.2, .2), (3, 10), coords="ratio", endcoords="data") +arrow.set_style(linestyle="--", head_length=8, head_width=6) +arrow.set_label("Maximum", fontsize=10) +``` + +`TextRun` works in titles, axis labels, legend entries and annotations. It also +supports per-span color/size, underline, superscript/subscript and rule marks. +`note.set_multialignment(...)` aligns multiline content. Reference lines expose +`set_value`, `set_style` and `set_label`; spans expose `set_bounds` and fill +`set_style`. Annotation frame padding uses points. Subplot-local annotation +spaces, curved arrows and unsupported transforms remain guarded. + +For bounded charts, `ax.chart.get_style()` returns supported reusable keywords. +`set_style` covers every chart-global material/style leaf, including box +statistics/outliers, radial camera/light/corners/outlines and radar grid/fill. +Returned bounded/radial artists use the same named `get_style`/`set_style` API. +`chart.get_series()` and `get_categories()` expose IDs with display labels; +`get_slice_count(detail=True)` inspects detail slices without reading raw SSOT. +`chart.series(id)`, `chart.cell(category_id, series_id)` and +`chart.slice(index, detail=False)` expose scoped styles. `reset_style()` on an +element restores inheritance. Full per-element bar/box/pie styles snapshot the +chart defaults; the renderer's partial radar styles continue to inherit unset +fields. Value-label choices depend on the chart family: bars use +none/inside/outside/auto, boxes none/median/median_and_count, pies +none/inside/outside, and radar none/values/emphasized. + +`chart.set_split(index, values, labels=..., colors=..., inner_radius=...)` creates +a pie detail chart whose child values must sum to the parent. Existing detail +slices are edited with `slice(i, detail=True)`. Appearance export retains those +resident child values. `chart.decoration("title").set_offset((x, y))`, named +axis/value/spoke/slice labels and `chart.set_plot_bounds((left, top, width, +height))` use points; `set_plot_bounds(None)` restores automatic layout. + +Exact checkpoint/recovery APIs remain available for identities, interaction +records, diagnostic snapshots and renderer fields outside these visual +controls. They are separate from ordinary chart authoring. diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index b0e292b..ad971d9 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -47,14 +47,14 @@ other branches before building. This does not change main's branch protection. | --- | --- | --- | | `figgy-model` | 0.12.1 | 0.12.1 | | `figgy-renderer` | 0.18.1 | 0.18.1 | -| `figgyplot` | 0.3.0 | 0.3.0 | +| `figgyplot` | 0.4.0 | 0.3.0 | | `figgy` WASM wrapper | 0.15.1 | Not a crates.io package (`publish = false`) | Model 0.12.1 and renderer 0.18.1 were published on 2026-10-21. Python 0.3.0 was published on 2026-10-16 with renderer 0.17.0/model 0.11.0, as verified from its PyPI sdist. -The migration tree still carries that package version; select a new unused -version before building publication artifacts. Do not upload the changed source -as 0.3.0. For future coordinated releases, +This tree targets Python 0.4.0; its artifacts still require the complete +TestPyPI build and installation gates before production promotion. Do not upload +the changed source as 0.3.0. For future coordinated releases, publish the model before the renderer, then the Python package. Each package has its own version; the WASM wrapper's 0.15.1 is not the renderer version. The native extension consumes crates.io packages with versions and checksums @@ -85,8 +85,8 @@ wheels contain the compiled extension and need neither Git nor Cargo to install. packaged WASM provenance. Publishing a new Rust version does not automatically update these pinned dependencies or existing Python installations. 2. Open **Actions → Publish figgyplot (manual only) → Run workflow**. -3. Select **main**, **target `testpypi`** and the new source version set in step 1 - (`0.3.0` is already published). Leave the run-ID input empty. +3. Select **main**, **target `testpypi`** and version **`0.4.0`**, matching the + source version set in step 1. Leave the run-ID input empty. 4. Run it. This is an actual public TestPyPI upload, not a build-only dry run. The pipeline runs these gates in order: @@ -219,8 +219,8 @@ also confirms version 0.2.1 with 75 distribution files. The published 0.3.0 release uses figgy-renderer 0.17.0 and includes the fixes reported against 0.2.1; it also carries the installation instructions prepared -as 0.2.2, which was never uploaded. The renderer 0.18 migration needs a new -Python package version and its own TestPyPI build/upload and installation +as 0.2.2, which was never uploaded. The renderer 0.18 migration targets Python +0.4.0 and needs its own TestPyPI build/upload and installation checks before promotion. Follow the coordinated version order above while retaining the native/WASM source pins. Future description changes are package metadata: committing `PYPI_README.md` diff --git a/docs/README.md b/docs/README.md index bbac637..265ef8c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,10 +6,15 @@ | --- | --- | | [Project README](../README.md) | Installation, quick start, interactive demo and chart gallery | | [Example index](../examples/README.md) | Runnable chart recipes, image links and commands | +| [Matplotlib-shaped API](MATPLOTLIB_API.md) | Public plotting objects, argument conventions, export modes and supported subset | | [Chart and style API](CHART_TYPES.md) | All chart constructors, host coverage, procedural materials and draw modes | +| [Style tokens](STYLE_TOKENS.md) | Appearance presets, extraction, JSON reuse, DPI scaling and opt-in axes/text | +| [Style profiles](STYLE_PROFILES.md) | Execution-local defaults, publication targets and dashboard task catalog with actual renders | +| [Preset research](STYLE_PRESET_RESEARCH.md) | Verified source guidance, measured palette gaps and profile design decisions | | [Renderer 0.18 features](RENDERER_018_FEATURES.md) | ID-addressed grids, polar/radar, vectors, maps, fills, layers, annotations and complete-document replay | | [Desktop editor](DESKTOP_EDITOR.md) | Zoom/pan, selection, visibility, properties, code export and windows | | [Editor commands and history](EDITOR_COMMANDS.md) | Shared property commands, Undo/Redo, reset, atomic validation and field inventory | +| [Review feedback views](REVIEW_FEEDBACK_VIEWS.md) | Object summaries, exact snapshot changes, targeted comments and agent detail queries | | [Edit and reuse demo](README_DEMO.md) | Real clipboard paste, batch application and recording reproduction | | [Hosts](HOSTS.md) | Native surface and browser WebGPU behavior, data transfer and export | | [GPU preparation](PREPARATION.md) | Explicit prewarming and cache boundaries | diff --git a/docs/RENDERER_018_FEATURES.md b/docs/RENDERER_018_FEATURES.md index 096187d..fffbf73 100644 --- a/docs/RENDERER_018_FEATURES.md +++ b/docs/RENDERER_018_FEATURES.md @@ -62,7 +62,10 @@ requested scale, independently of which layer currently receives interaction. | Secondary Y | `right = ax.twinx(id='pressure')`; `right.plot(...)`, `right.set_ylim(...)` | Shares the parent X mapping/plot rectangle; Cartesian only | | Subplots | `Figure(grid=...)`, `set_layout`, `subplots`, `share_axes` | Cartesian/polar panels; one level; bounded families require separate Figures | | Annotations | `axhline`, `axvline`, `axhspan`, `axvspan`, `text`, `arrow`, `annotate` | One parent-panel annotation layer; subplot-local annotation spaces rejected | -| Independent bounded titles | `ax.set_title(text, fontsize=..., color=..., visible=...)` | Rich title no longer changes category/data-label font sizes | +| Independent bounded titles | `ax.set_title(text, fontsize=..., color=..., visible=...)` | Up to 512 rich characters / eight lines; preserved before construction; independent of category/data-label fonts | +| Bounded axis names | `ax.set_xlabel`, `ax.set_ylabel` before or after construction | Bars/boxes map names by orientation; explicit constructor axis names override inherited names; single-line | +| Cartesian legend location | `ax.legend(loc='upper left')`; `fig.update('legend', corner='TopLeft')` | Four upper/lower left/right corners; bounded legends support visibility only | +| Cartesian axis inversion | `ax.invert_xaxis()`, `ax.invert_yaxis()` | Keeps increasing limits and ticks; secondary Y is local; polar and bounded charts rejected | | Heatmap legend swatches | `ax.imshow(..., cmap=...)`, series label, `ax.legend()` | Uses the current colormap and preserves independently edited legend text | For mixed documents containing fields, vectors, histograms or errorbars, renderer @@ -120,6 +123,27 @@ samples, with no whole-array or display-image readback. ## Settings and Copy Python +Copy Python and `to_code()` share the compact exporter. A grid, overlay, mapped +series or annotation no longer forces a fully expanded Config/text dump. The +export stores nondefault Config leaves and each panel rectangle, while keeping +the complete topology, series references, identity and tick intent. Its one +`apply_settings(..., reset=True)` call expands root/subplot/overlay/colorbar +defaults and validates the complete result before committing; it does not merge +with the target's edited defaults or upload source arrays. + +Categorical/box/radar and radial exports use `configure_bounded(reset=True, ...)` +or `configure_radial(reset=True, ...)`: chart-level appearance defaults are +restored, exported per-item properties are applied, and destination data values +are retained. Omit `reset` for the ordinary selective-patch behavior. Expanded +diagnostic output remains available as `fig.to_code(compact=False)`. + +Plain rich text becomes `text_segments('Text')`; adjacent equal formatting is a +run, such as `text_segments('Note', bold=True)`. Renderer legend rule/field +segments, superscripts, colors and per-character overrides remain exact. A run +is abbreviated only when reconstructing it produces identical segment values. +Unsupported segment shapes fall back to explicit dictionaries. Repeated literal +colors use local bindings without rounding their channel values. + `get_settings` / `apply_settings` now use schema 2, adding `identity` metadata: Figure ID, panel/overlay IDs, grid placement and shared-axis membership. Visual values and source references remain in the upstream Config/series/family model. diff --git a/docs/REVIEW_FEEDBACK_VIEWS.md b/docs/REVIEW_FEEDBACK_VIEWS.md new file mode 100644 index 0000000..c5d8df6 --- /dev/null +++ b/docs/REVIEW_FEEDBACK_VIEWS.md @@ -0,0 +1,125 @@ +# Review feedback: summary and exact details + +The opening SSOT checkpoint and the accepted submission are the comparison +boundary. Intermediate gestures are not a second change log. Returning to the +opening persistent state produces no edit. Selection and hover are excluded by +the existing persistent-settings contract. + +The complete protocol-1 checkpoint remains available. Summary and detail views +are read-only projections of that checkpoint; they do not render, mutate the +source, revise the approval or replace exact retained-edit validation. + +## Read the summary first + +```sh +python -m figgyplot.review feedback CACHE WORK_ID --summary +``` + +`Session.feedback_summary(work_id)` provides the same JSON. It includes every +Figure's disposition, the batch lifecycle intent, source provenance, counts, +Figure-level comments and objects with changes or targeted comments. Unchanged +accepted Figures remain listed with empty object lists. Accepted Figures with +human edits still require source reconciliation. +`data_changed` compares complete cached source references, including data whose +axis bounds did not change; inspect the full checkpoint's references when true. + +Each object has its stable editor/comment `target`, label, scope, property +summaries and separate comments. Native editor ownership provides its SSOT +bindings. Panel, secondary-axis, series and annotation addresses are resolved +against both snapshots; removed objects remain inspectable. A shared or +unclassified property remains at panel/Figure scope instead of being assigned to +an unrelated leaf object. + +Rich text character/style operations are grouped into one property with +`text_changed` and `formatting_changed`. Identical displayed text does not imply +identical formatting. Every original operation ID remains in `operation_ids`. +Values and labels longer than 160 characters have an explicit truncation flag; +exact values remain in the detail snapshots and raw changes. Natural-language +comments remain separate instructions rather than annotation text or SSOT edits. + +## Reference images in review comments + +The general review field and element-comment dialog accept PNG, JPEG and WebP +files. Native windows provide **Attach image…**, **Paste image**, and +Ctrl/Cmd+V while the comment input is focused. Notebook comments support file +selection, clipboard paste and dropping an image onto the attachment area. +Thumbnails open a larger preview; each image can be removed before submission. +An element comment can consist of an image without text. + +Each comment's `attachments` list identifies its own reference images. Images +are decoded and normalized to PNG during import, stored separately under +`attachments/SHA256.png`, and verified during recovery. They do not change chart +Settings, revisions or source arrays. Drafts and submitted comments retain the +references, including after a controller disconnect. An import is limited to +20 MiB and 8192 pixels per side; invalid/unsupported images produce a visible +error rather than a successful empty attachment. + +Summary and detail views include the image name, dimensions, MIME type, byte +count, SHA-256 and absolute `path`, beside the owning comment and stable target. +Open that path with an image-viewing tool. No image bytes or base64 are inserted +into feedback JSON. Raw feedback's `file` is relative to the review cache beside +the event's manifest. Images referenced by retained review rounds survive +cleanup; files that no retained round owns are collected after successor commit. + +Python callers can attach images through the same store: + +```python +session.comment("line", "Use this palette", target="panel/main/chart_title", + attachments=["/absolute/path/reference.png"]) +``` + +`session.attach_image(figure_id, image_bytes, name="Reference.png")` returns a +durable reference for a later comment or upload acknowledgement. Notebook +uploads send binary buffers once and receive these references; subsequent text +edits send metadata rather than resending the image bytes. + +## Inspect the listed changes when needed + +```sh +# All exact changes for one Figure, in their original order: +python -m figgyplot.review feedback CACHE WORK_ID --figure line + +# One object, using the ID returned in its summary/details selector: +python -m figgyplot.review feedback CACHE WORK_ID --figure line --target panel/main/chart_title + +# One original operation, scoped to its Figure: +python -m figgyplot.review feedback CACHE WORK_ID --figure line --change CHANGE_ID + +# Original full checkpoint and protocol/schema discovery remain available: +python -m figgyplot.review feedback CACHE WORK_ID +python -m figgyplot.review protocol +``` + +Python callers use `Session.feedback_details(work_id, figure_id, target=None, +change_id=None)` with keyword filters. Details include the exact ordered +`changes`, comments, object summaries and their complete before/after SSOT roots. +`selection` records the filters. A change filter narrows the operation list; +object snapshots still describe the complete opening/submitted object. + +`target` is the same address used by the editor and element comments. Raw diff +`object_id` remains unchanged for compatibility. Change IDs identify exact diff +operations and must be queried with their Figure ID; identical edits in separate +Figures can have identical operation hashes. Unknown Figures, objects without +feedback, unknown operation IDs and stale work IDs fail explicitly. + +The bundled review skill starts with summary, requests relevant details before +reproducing truncated values or formatting, retains approved human edits, and +continues through the existing claim/regenerate/finish/subscription workflow. + +## Validation + +The two-Figure sample from the feedback audit produces 42,610 bytes of complete +feedback and about 1.8 KB of summary JSON. +The six exact character operations for `Before` to `AFTER!` become one summary +property and remain six identical operations in details. This is an example of +payload reduction, not a measurement of model or renderer execution speed. + +Tests account for each `(figure_id, change_id)` exactly once, preserve original +operation order and values, and check source snapshots remain unchanged. Coverage +includes additions, removals, ordering, reverted edits, long text with differences +beyond the preview, formatting-only changes, stable panels/secondary axes/series, +all four bounded families, comments without edits, unchanged accepted Figures, +finish intent, scoped operation hashes, CLI filters and JSON Schema validation. +The real native/Jupyter comment regression also follows the UI's actual target +into summary and exact detail. Existing native text, history, review recovery, +property editing and typing checks remain applicable. diff --git a/docs/REVIEW_MODE.md b/docs/REVIEW_MODE.md index 7b28651..033a5eb 100644 --- a/docs/REVIEW_MODE.md +++ b/docs/REVIEW_MODE.md @@ -31,7 +31,7 @@ first acknowledges notebook edits. `skip` is an explicit programmatic dispositio `session.start()` seals a complete set for headless use without opening views. `session.comment(figure_id, text, target=...)` appends a targeted API comment. The editable UI comment has its own channel; changing or clearing it preserves -API comments and their targets. Both channels are included in feedback. +API comments and their targets. All comment channels are included in feedback. Runtime revision counters are scoped to recorded Figure instance IDs. Recovery creates a new instance; baseline and approved instance/revision pairs therefore remain explicit. Persistent hashes and stable IDs identify the compared states. @@ -72,6 +72,33 @@ controller's working directory, module state or UI event loop. ## Feedback and persistence +For native agent use, `python -m figgyplot.review connect CACHE` combines window +presentation and feedback waiting in one main-thread process. Keep the execution +call pending, then reconnect after regeneration. It has no default timeout. +The builder calls `session.start()` before handing its cache to that command; +an in-process `session.connect()` can seal the Figures it still owns directly. +Existing `present()` / `run_events()`, notebook `watch()` with `subscribe`, and +manual `events` remain available. The library does not launch a model SDK. + +Successor rounds retain an accepted chart's approval only when its complete +persistent settings, DPI, column references and bounded numeric payloads match +the accepted checkpoint exactly. Matching settings or min/max alone are not +sufficient. Changed and new charts still require approval. + +`Save and finish` submits an accepted checkpoint with `intent="finish"` through +both hosts. The complete-batch event carries that intent separately from review +comments. `finish CACHE WORK_ID --claim-token TOKEN` validates any recorded +builder against the exact accepted settings and data and terminates without +another review window. A session without recorded source saves its approved +settings and binary data as a terminal checkpoint. File exports remain explicit +in source/agent instructions; this button does not invent export paths. + +On Windows, the store locks a byte beyond EOF without reading or initializing +it first. Lock acquisition and atomic replacement retry bounded sharing +contention, while unrelated errors still propagate. Same-thread nested store +transactions reuse the outer lock. Separate Windows CI exercises real process +contention; deterministic tests cover transient failures and timeout behavior. + The versioned session manifest carries session/round/work IDs, source entry point, function, arguments and digest, per-Figure schema/renderer versions, baseline and approved revisions, full baseline/approved settings, canonical persistent hashes, @@ -103,7 +130,7 @@ Readers and active writers are protected by the same lock. Cleanup never depends on an agent acknowledgement or compares unrelated Figure revision counters. The filesystem adapter is local and agent-neutral. A disconnected agent resumes -by polling `python -m figgyplot.review events CACHE`; raw arrays never enter the +through `python -m figgyplot.review subscribe CACHE --timeout 50`; raw arrays never enter the feedback text. `feedback CACHE WORK` reads structured feedback. Before editing source, `claim CACHE WORK --worker ID` obtains a renewable writer lease; `regenerate CACHE WORK --claim-token TOKEN` validates updated source and publishes @@ -116,7 +143,7 @@ their approved windows are closed. The [bundled skill](../python/figgyplot/skills/figgyplot-review/SKILL.md) is also available through `python -m figgyplot.review skill`. Agent hosts use their -existing terminal/tool runner to poll events while a human reviews the figures. +existing terminal/tool runner to keep a feedback subscription pending while a human reviews the figures. The adapter does not require a vendor model SDK. A disconnected agent resumes the same durable work ID instead of relying on an in-memory notification. @@ -134,3 +161,32 @@ source builder's reproducible inputs/arguments. The checkpoint restores the chart without inferring those computations from its rendered result. ![One of two validated next-round Figures in the live browser review controller](images/review-mode-browser.png) + + +## Review fonts and element comments + +Initialize `Session(..., review_font="Noto Sans CJK KR", review_font_size=14)` +with an installed family that supports the user's primary language. This is +review UI typography, separate from document typography. Native review inputs +load the actual font face; notebook widgets transfer the selected standalone +face to the browser. Unknown families fail at initialization rather than silently +substituting a Latin face. Font metadata survives reopening the cache. + +Right-click a chart object or an object-tree row and choose **Add review comment**. +The comment is addressed by its stable object ID and is stored in the `ui_target` +metadata channel. **Element comments** reopens the list for editing/deletion. + +![Element review comment using the configured Korean font](images/review-element-comment-cjk.png) +General UI comments, targeted UI comments and API comments are preserved +independently; they are never inserted into chart annotations or SSOT. + +`Session.open(cache).subscribe(after=last_work_id, timeout=50)` waits for one +complete durable feedback event. Use it from an agent execution process while +the controller continues `run_events()` or `watch()`. The last required Figure's +disposition publishes a requested-change batch and wakes the subscription. +Timeout or session completion returns `None` (`null` from the CLI), not approval. +Check `status`: stop for `completed`/`cancelled`, otherwise resubscribe. +Reconnect using the cache and the last +delivered work ID. Manual `events` lookup remains available. Subscription does +not invoke a model vendor or create an automatic conversation turn on its own; +the waiting execution-tool result delivers the signal to the attached agent. diff --git a/docs/STYLE_PRESET_RESEARCH.md b/docs/STYLE_PRESET_RESEARCH.md new file mode 100644 index 0000000..fca2524 --- /dev/null +++ b/docs/STYLE_PRESET_RESEARCH.md @@ -0,0 +1,125 @@ +# Style preset research and current gaps + +Checked: 2026-10-26. This document separates verified source requirements, +measured implementation behavior, and product choices. The task profiles are now +implemented; none claims complete publication compliance. + +## Current status + +The six legacy presets are generic appearance starting points. None is a +validated journal profile or a complete dashboard design system. Existing tests +establish API behavior, data/text/domain preservation and transactional edits; +they do not establish editorial compliance, accessibility or task suitability. + +The palette in `paper`, `report` and `colorblind` is identical. Their main +appearance difference is the grid. The standalone `colorblind` name does not +constitute a whole-chart accessibility guarantee. `monochrome` repeats three +grays and supplies no additional dash/marker discrimination for more series. +`dark` is a surface treatment; it is not an operational-monitoring profile. + +Fourteen additional task profiles now implement the architecture below, with +execution-local declarations, construction-time inheritance, explicit argument +precedence, category keys and semantic roles. The [catalog](STYLE_PROFILES.md) +contains the actual rendered examples and source/implementation boundaries. +New light/dark palettes and semantic role colors measure above 4.5:1 against +their opaque profile backgrounds; this is not a CVD or whole-chart guarantee. +The [new profile measurements](measurements/style-profiles-20261026.json) retain +physical DPI, source guidance, palette/role contrasts and renderer provenance. + +## Verified primary sources + +| Source | Verified guidance | Consequence | +| --- | --- | --- | +| [Matplotlib customization](https://matplotlib.org/stable/users/explain/customizing.html) | Current default X/Y tick direction is outward; other directions are configurable. | A library default is not a universal editorial requirement. | +| [Korean Journal of Fisheries and Aquatic Sciences instructions](https://www.e-kfas.org/intro/instructions.asp) | Figures use a four-sided frame and inward ticks. | The current all-outward preset cannot claim this journal's style. | +| [Nature final submission](https://www.nature.com/nature/for-authors/final-submission) and [figure specifications](https://research-figure-guide.nature.com/figures/preparing-figures-our-specifications/) | Typical final widths are 89/183 mm; ordinary figure lettering is 5–7 pt, with separate panel-letter rules. Standard sans-serif fonts and editable vector artwork/font handling matter. | A generic 11 pt preset is not a Nature profile. Validate at final physical size. Our current PNG-only export cannot establish editable-vector compliance. These sources do not establish a universal tick-direction rule. | +| [Proceedings of the IEEE figure guidelines](https://proceedingsoftheieee.ieee.org/resources/guidelines-for-figures-and-tables/) | Approximately 9–10 pt lettering is recommended, with consistent typography, sufficient margins and accessible colors. | This is a different target from Nature. Do not generalize these recommendations to every IEEE publication or imply an unspecified tick rule. | +| [Okabe and Ito: Color Universal Design](https://jfly.uni-koeln.de/color/index.html) | Guidance from the palette's authors addresses color-vision differences and figure/presentation design. | Palette provenance is useful, but does not replace checking background contrast and redundant identification in each use case. | +| [Carbon color palettes](https://www.carbondesignsystem.com/building-blocks/data-visualization/color-palettes) | Distinguishes categorical, sequential, diverging and alert palettes; gives separate light/dark choices and semantic examples. | Color policy must follow the data's meaning, not just a cycling list of hues. | +| [WCAG non-text contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) and [use of color](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html) | Essential graphical cues generally need 3:1 against adjacent colors. Meaning must not depend on hue alone. Context and equivalent information can affect the assessment. | Check actual rendered backgrounds/alpha and use labels, shapes, patterns or other sufficient alternatives. This is not a rule requiring every pair of category colors to have 3:1 contrast. | +| [Microsoft dashboard guidance](https://learn.microsoft.com/en-us/power-bi/create-reports/service-dashboards-design-tips) | Audience, viewing device, information hierarchy, consistent scales and category colors determine dashboard choices. | Dashboard purpose and viewing conditions need separate policies. A light/dark switch is insufficient. | +| [Grafana dashboard best practices](https://grafana.com/docs/grafana/latest/visualizations/dashboards/build-dashboards/best-practices/) | USE, RED and the four golden signals address different observability questions. Dashboards should answer a question and reduce cognitive load. | Operational dashboards need stable semantics and meaningful status encodings rather than arbitrary category coloring. | + +The main Science manuscript page returned HTTP 403 during this review. No +Science-specific quantitative profile was accepted from third-party summaries. +No APS-specific inward-tick requirement was verified. Requirements not found in +the checked sources must be marked as design choices, not attributed to a journal. + +## Measured palette gaps + +[Reproducible audit output](measurements/style-preset-audit-20261026.json) records +all current palettes and tick directions. With the current white background: + +| Color | Contrast against white | +| --- | ---: | +| Yellow `#F0E442` | approximately 1.32:1 | +| Orange `#E69F00` | approximately 2.25:1 | +| Sky blue `#56B4E9` | approximately 2.31:1 | +| Monochrome gray `#A0A0A0` | approximately 2.61:1 | + +These colors are below 3:1 as standalone marks on white. This observation is not +a blanket verdict on complete figures: borders, redundant labels or equivalent +information can change the assessment. It does establish that choosing a named +palette alone is insufficient to guarantee accessible thin lines. The audit +also does not simulate protanopia, deuteranopia or tritanopia. + +## Profile architecture + +Keep a reusable token, but resolve it from independent choices: + +1. **Target:** a verified publication target or a screen task profile. Store + source URL, checked date, explicit requirements, recommendations and our own + design choices separately. Specify final print dimensions without treating + window size as publication size. +2. **Surface and density:** light/dark, device/viewing distance, compact versus + comfortable typography and spacing. Derive tick density from available space + when the axis is Auto; retain explicitly fixed ticks and data domains. +3. **Color encoding:** categorical identity, sequential magnitude, diverging + deviation with an explicit reference, semantic status, or a focus/comparison + relationship. Semantic roles and divergence reference values come from the + caller; do not infer thresholds, success direction or physical units. +4. **Bindings:** stable category/series roles across views. Reordering a series + must not recolor the same entity in another panel. Exceeding a categorical + palette's supported distinct count must not silently recycle colors; use + additional encodings, require grouping, or report the limitation. +5. **Validation:** final-size font/line checks, contrast and CVD checks, grayscale + discrimination, legend occlusion, clipping and dense-label tests on actual + renders. Record unsupported export or renderer capabilities explicitly. + +The token still applies appearance by default. Coordinate ranges, normalization +and wording remain opt-in. A profile does not manufacture KPIs, alert rules, +filter behavior or domain-specific values. + +## Dashboard task profiles + +This is a product taxonomy derived from the sources, not a universal standard. + +| Profile | Question and visual priorities | +| --- | --- | +| `dashboard.executive` | Is performance on target? Sparse overview, strong KPI hierarchy, comparison context, consistent category identity. | +| `dashboard.operations` | What needs action now? Live trends, explicit units, stable resource/service colors, status reserved for supplied warning/critical roles. | +| `dashboard.analysis` | Why did a metric change? Multiple comparison views, detail and uncertainty, stable roles under filtering and selection. | +| `dashboard.instrument` | What is the measurement doing? Quantitative axes, units, linear/log control, dense signals, measurement/reference distinction. | +| `dashboard.public` | What should a broad audience understand? Plain structure, accessible labels and redundant encodings, useful static/exported views. | +| `dashboard.wallboard` | Can a distant viewer read the state? Larger marks and text, few panels, minimal precision, no reliance on hover. | +| `dashboard.mobile` | What matters on a small touch screen? Low density, clear hierarchy and reduced visual clutter. Responsive navigation belongs to the host, not the chart token. | + +Dark appearance can be combined with several of these profiles; it does not +identify the task. Publication targets likewise need a specific journal and +output context, not the generic name `paper`. + +## Validation scope + +Use different realistic scenes: publication-sized figures, +color-mapped scalar fields, category comparisons, error bars, operational +latency/error panels, dense instruments and a wallboard. Rendering the same five +curves six times verifies visual differences but does not validate all use cases. +The gallery now covers these scenes through declared built-ins. Automated tests +cover native and notebook model inheritance, explicit/default-equal arguments, +existing-edit preservation, nested scopes and failure atomicity. Maintain +data/text/domain preservation checks alongside these visual and +source-based checks. A journal-inspired profile must not be labeled fully +compliant while required editable/vector output remains unsupported. +Final pixel/CVD inspection, long-label stress, responsive UI behavior and real +viewing-distance acceptance remain task-specific work, not established by the +token/model tests. diff --git a/docs/STYLE_PROFILES.md b/docs/STYLE_PROFILES.md new file mode 100644 index 0000000..5e88341 --- /dev/null +++ b/docs/STYLE_PROFILES.md @@ -0,0 +1,127 @@ +# Task-specific style profiles + +Fourteen task profiles supplement the six legacy presets (`paper`, `report`, +`presentation`, `dark`, `monochrome`, `colorblind`). They are editable +`StyleToken` values, available through `styles.available()` and +`styles.preset(name)`. Each combines typography, axes, margins, grids, line and +marker cycles, annotation defaults, bounded-chart appearance and semantic roles. + +## Choose by output and task + +| Profile | Default | Practical starting point | +| --- | --- | --- | +| `publication.nature` | Light, 6.5 pt | 89/183 mm final-width figures; small error bars and ordinary lettering | +| `publication.ieee-proceedings` | Light, 9 pt, grayscale | Engineering curves with redundant line styles; specifically the Proceedings guidance | +| `publication.kfas` | Light, 9 pt | Four-sided frames and inward ticks; 9 pt is our choice, not a verified journal requirement | +| `scientific.precision` | Light, 10 pt | Full quantitative frame, two-direction major grid and inward ticks | +| `print.monochrome` | Light, 9 pt | Grayscale printing, dash/shape differentiation and contrasting box medians | +| `presentation.stage` | Dark, 17 pt | Large labels and thicker marks for projection | +| `dashboard.executive` | Light, 12 pt | Sparse overview, restrained grid, title hierarchy | +| `dashboard.operations` | Dark, 11 pt | Operational trends; warm alert colors reserved for supplied warning/critical roles | +| `dashboard.analysis` | Light, 10 pt | Comparison/detail grids and focus/context/reference encodings | +| `dashboard.instrument` | Dark, 10 pt | Dense measurement frame and fine quantitative grid | +| `dashboard.wallboard` | Dark, 17 pt | Large text and strokes for distant viewing | +| `dashboard.public` | Light, 13 pt | Broad-audience reading with larger labels and redundant categories | +| `dashboard.mobile` | Light, 11 pt | Narrow single-chart reading and reduced extra margins | +| `education.explainer` | Light, 12 pt | Clear frame/grid, annotation defaults, data/model emphasis | + +Source requirements and product choices are separate. Nature and Proceedings +profiles use different recommended text sizes; their inward ticks are our design +choice. KFAS explicitly specifies inward ticks and a four-sided frame. These +profiles do **not** certify submission compliance: export is currently PNG, +fonts depend on the host, and journals can require editable vector artwork. +See [primary sources and research](STYLE_PRESET_RESEARCH.md). +`styles.describe(name)` returns the checked date, sources, verified guidance, +intended use, tick provenance, category capacity and limitations. + +## Declare once, then create + +```python +import figgyplot as fg + +style = fg.styles.preset("dashboard.operations", theme="light", density="compact") +fg.use_style(style) +fig, ax = fg.subplots() +ax.plot([0, 1, 2], [80, 95, 85], style_key="service-a", label="Service A") +ax.plot([0, 2], [100, 100], style_key="warning", label="Supplied limit") +ax.set_xlabel("Elapsed time (min)") +ax.set_ylabel("Latency (ms)") +ax.legend() +``` + +All screen profiles accept `theme="light"` / `"dark"` and +`density="comfortable"` / `"compact"`. Print/publication profiles keep a light +surface and fixed profile typography. A profile never resizes the Figure or +manufactures a subplot layout. Set output dimensions and composition explicitly. + +Use `with fg.style_context(name): ...` for a group of Figures, or +`Figure(style=name)` for one Figure. New objects inherit the captured style even +after leaving the block. Existing edits stay in place when adding objects. +Explicit constructor values win. `apply_style()` is a one-time existing-chart +edit; `set_style()` also retains defaults for later additions. These declarations +live in the current code execution, with no persistent user configuration. +See [scope, precedence, extraction and replay](STYLE_TOKENS.md). + +## Encoding policy + +- **Categories:** six purpose-made light/dark colors; print monochrome uses + three grays. Repeated colors gain a different dash block and marker shape. + The shared advertised capacity is 24 category encodings, or 12 for grayscale; + exceeding it warns rather than silently promising distinction. Keys preserve + category assignment across Figures in the same declaration. No palette + guarantees useful interpretation of a chart with dozens of overlapping lines. + This dash/marker capacity applies to Cartesian series. Bounded charts warn + beyond the six category colors (three for monochrome); supply explicit colors + or group categories instead of assuming additional encodings exist there. +- **Status:** `warning`, `critical`, `positive` are caller-selected roles. + Warning uses a dashed line/triangle; critical uses a dotted line/diamond when + those components exist. An explicit marker or color takes precedence. + Roles never add markers to a line-only plot or infer thresholds. +- **Comparison:** `focus`, `context`, `reference` supply stronger emphasis, + restrained context and a dashed comparison. The caller decides which series + belongs to each role. +- **Magnitude:** mapped points, fields and vectors keep their value references + and normalization. Choose a sequential map such as `viridis` for increasing + magnitude, or a diverging map such as `RdBu` with an explicitly supplied + reference/domain. A task name cannot infer the correct zero or sign semantics. + +The current task palettes and role colors exceed 4.5:1 against their own opaque +backgrounds in both themes. This is a measured solid-color check, not a guarantee +for alpha blending, adjacent series, arbitrary user colors, color-vision +simulation or whole-chart WCAG conformance. Light alert colors differ from dark +alert colors. Operational/instrument/wallboard categories avoid warm alert hues. +The legacy palettes are retained and have the gaps documented in the research. + +## Actual native renders + +These examples use their built-in declarations, then construct real charts. +They cover final-width scientific error bars, logarithmic engineering curves, +box distributions, regional categorical comparisons, operational panels, +focus/context analysis plus a scalar field, instruments, projection, wallboard, +portrait mobile and annotated teaching plots. Labels and thresholds are explicit +example inputs, not generated by the style. + +![Publication and print profiles rendered by Figgy](images/style-profiles-publication.png) + +![Scientific, presentation, dashboard and teaching profiles rendered by Figgy](images/style-profiles-screens.png) + +Reproduce the full-size PNGs, independent style JSONs, DPI metadata, contrast +measurements and source manifest with: + +```bash +python -m pip install Pillow +python examples/style_gallery.py --output output/styles +``` + +Every chart pixel comes from the Figgy native renderer; Pillow adds only the +thumbnail overview/captions and DPI metadata. Native and notebook document paths +are covered by the style tests. Browser pixels and viewing-distance suitability +are not certified by this gallery. + +The upstream bounded font range remains 8–32 logical pixels (radial: 8–64). +At 300 DPI a 9 pt bounded font is 37.5 pixels and therefore rejected; the print +boxplot example uses 240 DPI to keep 9 pt, with no silent text shrink. Cartesian +print examples use 300 DPI. Profile font sizes, margins and colors remain fully +editable through the token. Automatic layout retains the renderer's capabilities; +long labels, very narrow grids and display distance still need inspection at +the intended output size. diff --git a/docs/STYLE_TOKENS.md b/docs/STYLE_TOKENS.md new file mode 100644 index 0000000..1f97cf2 --- /dev/null +++ b/docs/STYLE_TOKENS.md @@ -0,0 +1,255 @@ +# Style tokens + +`StyleToken` packages reusable appearance separately from optional coordinate +scales and text. Data arrays, column references, Figure/panel/series IDs, category +names, annotation geometry, and chart types stay owned by the destination. +Applying a token resolves and validates one candidate document, then commits it +through `Figure.apply_settings` once. Existing native windows and notebook +widgets receive that same accepted state; one Undo restores the previous state. + +## Presets and common controls + +The six legacy presets are generic appearance starting points. `paper` is not +a verified journal profile; `colorblind` is not whole-chart accessibility +certification, and `dark` does not define a dashboard task. See the +[preset research and measured gaps](STYLE_PRESET_RESEARCH.md) before treating +these names as suitability claims. Fourteen additional task profiles cover +publication, printing, scientific measurement, presentations, dashboards and +teaching. See the [profile catalog and rendered examples](STYLE_PROFILES.md). + +```python +import figgyplot as fg + +fig, ax = fg.subplots() +ax.plot([1, 2, 3], [2, 4, 3], label="Measured") +ax.set_title("My result") +ax.set_xlabel("Time (s)") +ax.legend() + +fig.apply_style("paper") +# paper, report, presentation, dark, monochrome, colorblind +print(fg.styles.available()) + +style = fg.styles.preset("report") # a fresh independent token +style.appearance["typography"]["font_size"] = 12 +style.appearance["typography"]["font"] = "Noto Sans CJK KR" +style.appearance["palette"] = ["#0072b2", "#d55e00", "#009e73"] +style.appearance["background"] = "#ffffff00" +report = fig.apply_style(style) +``` + +Use a font available on the destination host. Explicit common typography wins +over captured object fonts and covers chart/axis titles, ticks, legends, +colorbar axes, secondary axes, and existing annotation text. Role-specific sizes +are `title_size`, `label_size`, and `legend_size`. Presets instead use relative +`title_scale`, `label_scale`, and `legend_scale`, so changing the common +`font_size` adjusts all roles together while preserving their hierarchy. +An explicit role size overrides its relative factor. +Extracted tokens preserve individual object fonts until a common override is +explicitly added. Colors accept the same named/hex/RGB(A) values as plotting, +and serialize as RGBA mappings. + +The official presets contain appearance only: no text payload or coordinate +scale payload. Palette cycles style solid colors. Value-mapped points/vectors +retain their mappings and use the chart's colormap rather than the solid palette. + +## Declare defaults within a codebase + +```python +fg.use_style("dashboard.operations") +first, ax = fg.subplots() +ax.plot(time, latency, style_key="api-service", label="API") + +second, ax2 = fg.subplots() +ax2.plot(time, throughput, style_key="api-service", label="API") +# Same category key, same appearance in both Figures. + +with fg.style_context("publication.nature"): + paper, paper_ax = fg.subplots(figsize=(89 / 25.4, 62 / 25.4), dpi=300) +paper_ax.errorbar(x, y, yerr=error) # inherits even outside the context + +fg.use_style(None) # subsequent Figures return to ordinary defaults +``` + +Declarations live in the current Python execution context. They write no +configuration files and have no effect on another process. Async tasks inherit +their context and can override it independently; a new thread starts without a +declaration. Nested `style_context` blocks replace defaults temporarily and +restore them on normal exit or exceptions. `style_context(None)` temporarily +disables defaults. + +Each Figure captures a deep copy at creation. Later series, annotations, +subplots and secondary axes inherit that copy. Changing the execution defaults +does not restyle existing Figures. Adding an object styles only that new object; +accepted edits to existing objects remain in place. Style resolution joins the +original constructor commit, without reuploading earlier data. + +```python +standalone = fg.Figure(style="print.monochrome") +unstyled = fg.Figure(style=None) # explicitly opt out of execution defaults + +custom = fg.StyleToken({"typography": {"font_size": 12}}) +local = fg.Figure(style=custom) # override these fields over execution defaults + +first.apply_style("dashboard.analysis") # one existing-document edit +first.set_style("dashboard.analysis") # restyle and retain defaults for additions +first.set_style(None) # disable future inheritance +snapshot = first.style_defaults # independent copy, or None +``` + +Precedence is: explicit constructor arguments, then the Figure's captured +style, then original constructor defaults. Explicit arguments win even when +they equal the original default, such as `linewidth=1.5`, `color="black"` or +`markersize=4`. A format string such as `"r--o"` also counts as explicit. +Styles never introduce markers into a line-only series or convert chart kinds. +Scale and text payloads in a default declaration are intentionally ignored; +use `apply_scales` or explicit text opt-in for those operations. + +`style_key` identifies a category across Figures created under one declaration. +Use keys consistently for categorical series; anonymous series use each +Figure's local cycle. User-specified colors/shapes may intentionally reuse an +encoding. A named semantic role (`focus`, `context`, `reference`, `warning`, +`critical`, `positive`) applies its declared role instead. These are labels +supplied by the caller, never inferred from numeric values: + +```python +ax.plot(time, latency, style_key="api-service") +ax.plot([0, 10], [150, 150], style_key="warning", label="Supplied limit") +ax.scatter(events, values, style_key="critical", label="Flagged events") +# Explicit marker='o' would take precedence over the role's diamond marker. +first.apply_style("dashboard.operations", role_map={"limit-series-id": "warning"}) +``` + +Keys address Cartesian series or a single-series bounded chart. A bounded +chart with multiple series/slices requires explicit `colors`; one key cannot +identify several independent series. Built-in task profiles repeat categorical +colors with additional dash/marker encodings, warning after the declared common +capacity for Cartesian categories (24, or 12 for monochrome). Bounded bars, +boxes and slices do not gain these line/marker combinations and warn once their +palette runs out (six, or three for monochrome). Use grouping or explicit +encodings beyond those sizes. Color-mapped values keep their column/grid references and domain; +their colormap remains a separate explicit choice. + +## Extract, save, and reuse + +```python +style = fig.extract_style() +style.save("my-style.json") +style = fg.StyleToken.load("my-style.json") +other.apply_style(style) + +# String/dict transports for applications and agents: +encoded = style.to_json() +style = fg.StyleToken.from_json(encoded) +style = fg.StyleToken.from_dict(style.to_dict()) +``` + +The token has `version=1`, `appearance`, `scales`, and `text` fields. The optional +payloads are `None` unless captured explicitly. Unknown appearance/schema fields +and unsupported token versions are rejected. Numeric values must be finite. + +For pending edits made in a notebook browser, synchronize before extraction: + +```python +style = await fig.extract_style_async() +# Equivalent: await fig.sync(); style = fig.extract_style() +``` + +## Optional scales and wording + +```python +template = fig.extract_style(scales=True, text=True) +other.apply_style(template) # appearance only, even though payloads are present +other.apply_style(template, scales=True) +other.apply_style(template, text={"axis_titles"}) +other.apply_style(template, scales=True, text=True) +other.apply_scales(template.scales, limits=False) +``` + +Scale payloads include linear/log, inversion, bounds, major tick spacing and its +Auto/Fixed mode for all four axes and secondary axes; colorbar normalization; +and available box/radar value ranges. They do not carry shared-axis group IDs. +`limits=False` copies only linear/log and inversion on coordinate/color axes; +it preserves target bounds and ticks, and leaves bounded value ranges alone. +Nonpositive log domains and conflicts with destination shared-axis groups fail +atomically. The API never silently fits or clips destination data to accept a +scale. Polar angle units/orientation and per-point size-map domains are not +copied; these remain destination chart semantics. + +Text selection is `True` for all captured groups, or a set containing: + +- `titles`: chart titles. + Bounded extraction reads the authoritative rich title, including all lines and + character formatting. Its optional `text.bounded.title_segments` payload keeps + those segments; older tokens containing only `text.bounded.title` still work. + Applying appearance alone never imports the source wording, including when the + destination still uses its legacy title. The single-line legacy fallback is + derived only when title text is explicitly applied. +- `axis_titles`: all four axis titles, secondary Y titles, and colorbar titles; + category/value axis titles for applicable bounded charts. +- `legend_labels`: existing series/slice legend wording. + +Category names, annotation wording, and values are not copied. Without text +opt-in, even rich-text characters and their superscripts/subscripts remain +unchanged. Text copying requires corresponding objects/counts or an explicit +series map; it cannot create a missing axis or colorbar. + +## Physical sizes and layout + +```python +other.apply_style(style, size_scale=1.25) +``` + +Visual lengths in tokens use points and convert using the destination DPI. +`size_scale` multiplies the lengths supplied by the token, such as font sizes, +line widths, markers, padding, axis margins and grid gaps. It does not multiply +data, axis bounds or fixed tick intervals. Reapplying the same token is absolute, +not a cumulative multiplication of the previous appearance. + +Grid styles preserve current topology by default, while reusing applicable gap +and row/column weights. Axis/title margins reuse the renderer's existing layout +fields; automatic plot rectangles remain derived from those fields. To copy the +captured grid topology explicitly, use matching panel counts: + +```python +other.apply_style(style, layout=True) +other.apply_style(style, layout=True, panel_map={0: "right", 1: "left"}) +``` + +The token stores numeric panel slots, not source IDs. Figure size, DPI, source +membership and existing shared-axis groups are retained. + +For a new grid under captured style defaults, omitted gap/weights inherit the +token when their dimensions match. Explicit `set_layout` options take priority; +later calls with omitted options preserve compatible accepted layout values. +Grid gaps in tokens are points; explicit `set_layout(gap=...)` uses logical +document pixels. All panel rectangles are computed from the same accepted +layout, including after moving or adding panels. + +## Correspondence and incompatible charts + +A single captured panel style broadcasts to destination panels. Multiple panels +match by order, or use `panel_map={source_index: destination_panel_id}`. +Series styles match the existing render kind (line, scatter, histogram, etc.) +and cycle within that kind in source order. No chart kind is converted. For +explicit correspondence use `series_map={source_index: destination_series_id}`; +this also addresses opt-in legend text. Only mapped series receive captured +series overrides; common typography/background still apply to the Figure. +Bounded series/slice styles cycle within the same chart family. Annotation +appearance cycles by kind/order and keeps destination IDs, anchors and text. + +Unmatched style components produce `StyleWarning` and appear in +`StyleReport.skipped`. `strict=True` rejects such a partial application before +committing anything. Renderer validation failures always abort the entire edit. +No extra GPU data upload is needed for a style edit. + +The renderer's existing restrictions still apply. For example, Milkyway requires +line-only series and Constellation requires marker lines. Categorical, box and +radar base fonts must be 8–32 logical pixels, and radial base fonts 8–64; an +out-of-range DPI/size combination raises an explicit error instead of clamping. + +Per-datum overrides, binning, contour levels, fill-to-series/baseline references, +vector magnitude scales, polar coordinate conventions, category order and +annotation positions are intentionally not portable appearance. Destination +values for those fields are preserved. The token is a reusable style, not a +replacement for a complete chart checkpoint. diff --git a/docs/images/layout-plot-controls.png b/docs/images/layout-plot-controls.png new file mode 100644 index 0000000..92a14ff Binary files /dev/null and b/docs/images/layout-plot-controls.png differ diff --git a/docs/images/review-element-comment-cjk.png b/docs/images/review-element-comment-cjk.png new file mode 100644 index 0000000..830ed10 Binary files /dev/null and b/docs/images/review-element-comment-cjk.png differ diff --git a/docs/images/style-profiles-publication.png b/docs/images/style-profiles-publication.png new file mode 100644 index 0000000..80dae9a Binary files /dev/null and b/docs/images/style-profiles-publication.png differ diff --git a/docs/images/style-profiles-screens.png b/docs/images/style-profiles-screens.png new file mode 100644 index 0000000..c5f8aca Binary files /dev/null and b/docs/images/style-profiles-screens.png differ diff --git a/docs/measurements/api-composition-20261027.json b/docs/measurements/api-composition-20261027.json new file mode 100644 index 0000000..07c73fb --- /dev/null +++ b/docs/measurements/api-composition-20261027.json @@ -0,0 +1,86 @@ +{ + "date": "2026-10-27", + "base_commit": "664f92031922c37f618a49036cf607399edc5b11", + "scope": "Public API composition, existing accepted edits, grid geometry, lifecycle and actual native/Jupyter presentation", + "renderer_version": "0.18.1", + "package_version": "0.4.0", + "findings": [ + { + "trigger": "Provide a partial nested bounded/radial style during construction", + "before": "Unspecified themed properties reverted to renderer defaults; partial radial outline dictionaries failed parsing.", + "after": "Restore explicit leaves and merge nested radial defaults; unrelated accepted style and chart data remain unchanged." + }, + { + "trigger": "Add an image or contour after editing a mapped scatter colorbar", + "before": "Existing colorbar font, gap and thickness were reset.", + "after": "All mapped constructors use a common colorbar update path preserving appearance while updating the requested map/domain." + }, + { + "trigger": "Create a named grid under an extracted weighted-grid style", + "before": "Identity retained uniform weights/default gap while some panel rectangles used the captured layout.", + "after": "Resolve explicit, accepted and inherited layout options once; derive every panel rectangle from that layout." + }, + { + "trigger": "Disable Figure style defaults while a constructor waits for the Figure lock", + "before": "The series committed, then the call raised AttributeError.", + "after": "Style selection and construction serialize under the same Figure lock." + } + ], + "regression_cases": { + "before": { + "failed": 25, + "passed": 8 + }, + "after": { + "failed": 0, + "passed": 33 + }, + "test_file": "tests/test_api_composition.py", + "baseline_artifact": { + "wheel": "figgyplot-0.4.0-cp312-cp312-linux_x86_64.whl", + "sha256": "9d8bc49a43c662cee82940d355178b0851b9c6acb533fdc708f5f71ef32bdbb6", + "source_commit": "664f92031922c37f618a49036cf607399edc5b11", + "import_mode": "Installed wheel with PYTHONPATH unset" + } + }, + "validation": { + "installed_wheel_api_chart_cases": 785, + "review_recovery_history_selection_cases": 244, + "actual_native_and_jupyter_cases": 12, + "software_gpu": "Mesa lavapipe native, Chromium 154 SwiftShader browser", + "strict_twine": "passed", + "packaged_source_matching_files": 34, + "release_workflow_cases": 134 + }, + "presentation_oracle": { + "authored_expectations": "Cyan left-panel signal and high-valued yellow magma field must be present on the displayed canvas, in addition to exact model/geometry checks.", + "negative_control": "Without the browser-specific ICD selection, the local browser reported Ready and completed GPU work but captured a black canvas. The content assertion failed.", + "positive_control": "With the existing CI SwiftShader ICD selection, the same scene and nine analytic displayed-feature scenes pass.", + "negative_canvas": { + "sha256": "69c64ee2e92282db400315b4788b656411555ad1fd91d4fea2c881acd98f5a45", + "bytes": 3562 + }, + "positive_canvas": { + "sha256": "0aeefe689ba7d2be2342d6c246a1efaed8f08fdd0e1ddef85706e761be7d6a09", + "bytes": 35707 + }, + "native_export": { + "sha256": "f033a6a9d1257f6e6e921328cab4e5f7f56cd74c1771e5dc270bf3a1fc1a8f0c", + "bytes": 39692 + } + }, + "distribution_artifacts": { + "figgyplot-0.4.0-cp312-cp312-linux_x86_64.whl": { + "sha256": "2afe3efa2ed5696430397b4a1e9368e7050b89da548ff475c90a0b0438943dcd", + "bytes": 16705405 + }, + "figgyplot-0.4.0.tar.gz": { + "sha256": "9c79dc66fcb0c021d5f33e1aaa5b2fe403e8338111046bae7a974bb10523e51c", + "bytes": 11176186 + } + }, + "limits": [ + "This is not proof of defect-free behavior for every API combination.", + "Physical Windows/macOS GUI, physical GPU performance and the 74-platform/interpreter release matrix are not established by these local checks." + ] +} diff --git a/docs/measurements/blind-api-findings-20261028.json b/docs/measurements/blind-api-findings-20261028.json new file mode 100644 index 0000000..3ac29cb --- /dev/null +++ b/docs/measurements/blind-api-findings-20261028.json @@ -0,0 +1,217 @@ +{ + "date": "2026-10-28", + "timezone": "Asia/Seoul", + "baseline_commit": "2e755c7236ce18710b2fbdd84e2ce33bf5c2c801", + "package_version": "0.4.0", + "renderer_version": "0.18.1", + "scope": "Blind-use API defects, rich title adaptation and discovery improvements; no renderer or CI thresholds changed.", + "findings": [ + { + "id": "axis-name-construction-order", + "before": "First bounded construction omitted names set on the same Axes beforehand.", + "after": "The common categorical/box constructor transfers X/Y names by orientation; explicit category/value names, including empty strings, take precedence." + }, + { + "id": "bounded-multiline-title", + "before": "The title helper copied newlines into the legacy single-line field and validation failed.", + "after": "A shared rich-title adapter preserves full wording/segments and derives only the legacy fallback; construction, setters and title-token replay use it." + }, + { + "id": "partial-rich-title-style", + "before": "Extracted rich-title appearance applied to a legacy-only destination produced an incomplete text object without segments.", + "after": "Seed the destination title before merging an appearance patch, preserving its wording and data." + }, + { + "id": "documentation-grid-support", + "before": "README said multi-panel figures were unsupported.", + "after": "Quick-start grid example and matching capability limits distinguish Cartesian/polar grids from separate bounded Figures." + } + ], + "convenience": { + "legend": "Four Cartesian corners through Axes.legend(loc=...) or Figure.update(legend, corner=...); secondary Axes own their legend. Bounded loc requests reject atomically.", + "inversion": "Cartesian X/Y toggle preserves limits/ticks and follows shared axes. Secondary Y stays local; bounded/polar requests reject atomically.", + "discovery": "plot/scatter/imshow/title/axis-name/legend docstrings, tick-spacing alternatives and category-newline error guidance." + }, + "regressions": { + "test_file": "tests/test_blind_api_findings.py", + "cases": 46, + "baseline": { + "failed": 46, + "passed": 0, + "wheel_sha256": "2afe3efa2ed5696430397b4a1e9368e7050b89da548ff475c90a0b0438943dcd", + "installed_package_files_match": 38 + }, + "candidate": { + "passed": 46, + "failed": 0 + }, + "contracts": [ + "Complete bounded-model equality and exact native output for names set before versus after first construction.", + "Rich title text/style preservation before construction, after edits, token JSON and code replay.", + "Legacy-only destination title preserved under appearance-only application.", + "Valid 401-character title kept in full; invalid rich-title lengths/line counts/control characters leave settings and revision unchanged.", + "Panel/shared-axis/secondary-axis inversion and legend ownership; unsupported requests do not mutate state." + ] + }, + "validation": { + "broad_installed_api_style_editor_notebook_cases": 664, + "additional_final_title_cases": 2, + "installed_chart_invalid_state_release_asset_cases": 160, + "previously_ui_skipped_numeric_case": "passed with actual native/Jupyter UI", + "ci_preflight_cases": 134, + "preflight": "actionlint, shellcheck, asset provenance, pinned maturin-action workflow contract and tests passed", + "source_and_packaged_python_files_match": 22, + "installed_package_files_match": 39, + "rust_format": "passed", + "whitespace": "passed" + }, + "presentation": { + "host_test": "tests/test_interaction_parity.py::test_blind_trial_titles_render_two_real_lines_in_both_hosts", + "host_cases_passed": 2, + "pixel_expectation": "Two separate red text bands of at least ten rows and more than 150 colored pixels on a 700x500 canvas, plus accepted axis names and complete title SSOT.", + "negative_control": "The same assertion rejects a black canvas and an actual single-line native title.", + "negative_control_cases_passed": 1, + "measured": [ + { + "backend": "native", + "title_bands": [ + [ + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36 + ], + [ + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65 + ] + ], + "colored_pixels": 826 + }, + { + "backend": "webgpu", + "title_bands": [ + [ + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36 + ], + [ + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65 + ] + ], + "colored_pixels": 821 + } + ], + "environment": "Linux X11, Mesa lavapipe native, Chromium SwiftShader WebGPU; the notebook kernel import path is asserted to match the test runner installed package." + }, + "datasets": { + "independent_domains": 6, + "unchanged_analysis_scripts": 6, + "input_rows": 338927, + "final_pngs_regenerated": 21, + "stderr_bytes": 0, + "runs": [ + { + "domain": "astronomy", + "exit_code": 0, + "pngs": 3 + }, + { + "domain": "finance", + "exit_code": 0, + "pngs": 3 + }, + { + "domain": "clinical", + "exit_code": 0, + "pngs": 3 + }, + { + "domain": "aviation", + "exit_code": 0, + "pngs": 4 + }, + { + "domain": "ecology", + "exit_code": 0, + "pngs": 4 + }, + { + "domain": "seismology", + "exit_code": 0, + "pngs": 4 + } + ] + }, + "artifacts": { + "wheel": "figgyplot-0.4.0-cp312-cp312-manylinux_2_39_x86_64.whl", + "wheel_sha256": "9d66d08dcaa9d472bfdb06132083d3ce2261637fa68139ff17eb30aabf1b3117", + "regression_test_sha256": "4904583c22db0db80688cce8fa6c69f7d63a0b8fb30933fd6790b056201dc587" + }, + "limitations": [ + "No arbitrary-position tick API was added.", + "Bounded category/axis labels remain single-line; rich title limits remain enforced by the upstream model.", + "Raw apply_settings/configure patches must still satisfy both legacy and rich-title model constraints.", + "Static native output and the focused notebook/native checks do not establish Windows GUI correctness, physical GPU performance or every editor interaction.", + "GitHub CI status is tracked separately from these local measurements." + ] +} diff --git a/docs/measurements/blind-api-regressions-20261028.json b/docs/measurements/blind-api-regressions-20261028.json new file mode 100644 index 0000000..3ac1d8a --- /dev/null +++ b/docs/measurements/blind-api-regressions-20261028.json @@ -0,0 +1,63 @@ +{ + "base_commit": "d0bb35cd0f9eda18ae6bd7be98a9bd440b5c58de", + "renderer_version": "0.18.1", + "environment": { + "python": "3.12", + "os": "Linux", + "native": "Vulkan llvmpipe", + "notebook": "Chromium SwiftShader WebGPU" + }, + "fixes": [ + "Requested colorbar labels enable visibility through the common colorbar axis setter; explicit hiding is respected.", + "Default label spacing uses renderer painted text bounds in a shared CPU helper; manual title offsets are preserved.", + "Public code restores automatic/fixed/implicit tick policy after numerical locators without replanning current ticks.", + "Categorical manual ranges are explicitly guarded, and getters do not index nonexistent schema fields.", + "Explicit white backgrounds remain accepted SSOT and replay exactly." + ], + "limits": [ + "Renderer 0.18.1 has no manual categorical axis range; no range implementation is claimed.", + "Windows GUI and physical-GPU performance are outside this validation." + ], + "local_validation": { + "new_regressions_passed": 40, + "new_native_and_jupyter_cases_passed": 10, + "frozen_unfixed_wheel_negative_control": { + "cases": 32, + "expected_failures": 32 + }, + "preflight_cases_passed": 135, + "full_ci_collection_count": 1683, + "full_ci_result": "Reported separately by the required PR checks for the exact pushed commit.", + "replanning_exporter_negative_control": { + "cases": 2, + "expected_failures": 2 + } + }, + "wheel": { + "sha256": "2424c3ded63f8d5b727bb71e43ac99a22afa6f72aec37088b38f9b8ce9fabce4", + "package_files_compared": 51, + "source_and_install_mismatches": 0 + }, + "public_data": { + "domains": 6, + "observations": 2603, + "final_scenes": 22, + "numerical_checks_passed": 22, + "public_replay_complete_ssot_equal": 22, + "public_replay_pixels_equal": 22, + "exact_replay_complete_ssot_equal": 22, + "exact_replay_pixels_equal": 22, + "no_column_reuploads": true + }, + "oracles": { + "state": "Entire accepted snapshots including tick modes and explicit backgrounds; no ignored snapshot fields. Retained automatic spacings (125 linear units and two log decades) differ from a fresh plan; a deliberately replanning exporter is rejected.", + "pixels": "Independent title/tick ink masks and canvas containment for four sides at 72/144 DPI, plus native replay equality.", + "interaction": "Real native and Jupyter wheel input after API/exact replay; automatic ticks adjust and fixed ticks remain fixed.", + "arrays": "Independent destination arrays checked against native GPU selection readback and browser transfer bytes in both export modes." + }, + "coverage": [ + "tests/test_round3_api_regressions.py", + "tests/test_interaction_parity.py", + "tests/test_matplotlib_api.py" + ] +} diff --git a/docs/measurements/ci61-ubuntu-mirror-20261022.json b/docs/measurements/ci61-ubuntu-mirror-20261022.json new file mode 100644 index 0000000..ae8f887 --- /dev/null +++ b/docs/measurements/ci61-ubuntu-mirror-20261022.json @@ -0,0 +1,21 @@ +{ + "run_url": "https://github.com/shim9610/figgy-python/actions/runs/39757910485", + "job_id": 119754629400, + "head_sha": "5bf952a1ec4f61c03babfa7bc22612db406d149f", + "job_started_utc": "2026-10-22T09:46:03Z", + "install_started_utc": "2026-10-22T09:46:21Z", + "install_finished_utc": "2026-10-22T10:01:12Z", + "configured_build_job_timeout_minutes": 20, + "cancellation_log_utc": "2026-10-22T10:06:13.1207037Z", + "source_mirror": "azure.archive.ubuntu.com", + "log_evidence": [ + "2026-10-22T09:46:48.8407856Z Get:4 http://azure.archive.ubuntu.com/ubuntu noble/main amd64 fonts-noto-cjk all 1:20230817+repack1-3 [61.2 MB]", + "2026-10-22T09:57:51.8568173Z Get:5 http://azure.archive.ubuntu.com/ubuntu noble/main amd64 fonts-noto-mono all 20201225-2 [435 kB]", + "2026-10-22T10:01:05.1042319Z Fetched 105 MB in 14min 37s (120 kB/s)", + "2026-10-22T10:01:06.4954100Z Unpacking fonts-noto-cjk (1:20230817+repack1-3) ...", + "2026-10-22T10:01:09.2746424Z Setting up fonts-noto-cjk (1:20230817+repack1-3) ...", + "2026-10-22T10:06:13.1207037Z ##[error]The operation was canceled." + ], + "tests_executed": false, + "change": "Use archive.ubuntu.com instead of the Azure mirror for the same signed Ubuntu packages in both build and integration jobs. Preserve all tests, assertions, execution auditing and time budgets." +} diff --git a/docs/measurements/ci62-image-apt-sources-20261022.json b/docs/measurements/ci62-image-apt-sources-20261022.json new file mode 100644 index 0000000..5f47507 --- /dev/null +++ b/docs/measurements/ci62-image-apt-sources-20261022.json @@ -0,0 +1,18 @@ +{ + "run_url": "https://github.com/shim9610/figgy-python/actions/runs/39761069292", + "job_id": 119767697511, + "head_sha": "879dda6b2105ffcde8df172173b86c1650752f25", + "failed_stage": "Install native software-GPU and X11 runtime", + "log_evidence": [ + "2026-10-22T10:16:38.3953142Z Get:1 file:/etc/apt/apt-mirrors.txt Mirrorlist [144 B]", + "2026-10-22T10:16:38.4323427Z Err:6 https://packages.microsoft.com/ubuntu/24.04/prod noble InRelease", + "2026-10-22T10:16:38.4324404Z 403 Forbidden [IP: 13.107.246.71 443]", + "2026-10-22T10:16:38.4434857Z Hit:2 http://azure.archive.ubuntu.com/ubuntu noble InRelease", + "2026-10-22T10:16:43.0397774Z E: Failed to fetch https://packages.microsoft.com/ubuntu/24.04/prod/dists/noble/InRelease 403 Forbidden [IP: 13.107.246.71 443]", + "2026-10-22T10:16:43.0434864Z ##[error]Process completed with exit code 100." + ], + "tests_executed_in_this_job": false, + "diagnosis": "Replacing a host in ubuntu.sources did not override the image mirrorlist, and apt-get update also consulted an unrelated image vendor repository.", + "change": "Both jobs invoke install_ci_runtime.sh, passing a temporary signed official Ubuntu source list and sourceparts=- to both update and install. Repository errors are not ignored.", + "local_verification": "Actual apt-get --print-uris update includes only official Ubuntu feeds with the scoped arguments. Removing source isolation makes the Microsoft/Azure fixture feeds reappear. An update failure stops before installation." +} diff --git a/docs/measurements/ci65-clipboard-pipe-20261022.json b/docs/measurements/ci65-clipboard-pipe-20261022.json new file mode 100644 index 0000000..42bf3a0 --- /dev/null +++ b/docs/measurements/ci65-clipboard-pipe-20261022.json @@ -0,0 +1,25 @@ +{ + "run": "https://github.com/shim9610/figgy-python/actions/runs/39792868412", + "commit": "271a3bdf6ce5c5fb93daf9fdb8299d8a8b40bbd6", + "linux_test_summaries": {"0": 207, "1": 207, "2": 207, "3": 206}, + "windows_storage_passed": 7, + "shard0": { + "tests_finished_utc": "2026-10-22T14:23:51.9363372Z", + "test_seconds": 679.83, + "cancelled_utc": "2026-10-22T14:56:49.6116542Z", + "artifact_upload_started_utc": "2026-10-22T14:56:50.3033945Z", + "artifact_upload_finished_utc": "2026-10-22T14:56:51.5114246Z", + "aggregate_rejection": "SHARD_RESULT: cancelled; exit 1" + }, + "cause": "An X11 clipboard selection owner forked by xclip inherited the test logging pipe. Python could exit successfully while tee waited for EOF; the shell could not reach Xvfb cleanup, which would have released the clipboard owner.", + "local_control": { + "inherited_streams": {"python_returned": true, "pipeline_exited": false, "timeout_seconds": 5, "exit_code": 124}, + "detached_streams": {"python_returned": true, "pipeline_exited": true, "exit_code": 0}, + "regression": "Confirm writer.wait() == 0 while communicate() still times out, replace the negative-control selection to release it, then confirm the fixed writer closes its logs and its Unicode clipboard remains readable after its process exits.", + "original_shard_with_file_logging": {"passed": 207, "seconds": 616.72, "pytest_returned": true, "interpreter_exit_callback_reached": true, "process_exit_code": 0}, + "fixed_clipboard_and_settings_through_tee": {"passed": 2, "seconds": 29.69, "pipeline_exited": true}, + "fixed_clipboard_and_targeted_comments": {"passed": 2, "seconds": 49.59} + }, + "fix": "Use one UTF-8 clipboard writer helper that redirects only the xclip owner's stdout/stderr to DEVNULL. Preserve the clipboard owner for subsequent paste. Record pytest-return, interpreter-exit and pipeline-exit boundaries.", + "unchanged": ["test assertions", "SSOT and numeric array verification", "execution audit", "job time budgets", "renderer and product runtime"] +} diff --git a/docs/measurements/compact-codegen-20261028.json b/docs/measurements/compact-codegen-20261028.json new file mode 100644 index 0000000..0896539 --- /dev/null +++ b/docs/measurements/compact-codegen-20261028.json @@ -0,0 +1,352 @@ +{ + "date": "2026-10-28", + "timezone": "Asia/Seoul", + "baseline_commit": "96e29af421546ee26ad409cfd0c97e57c6c0b4f8", + "scope": "Lossless compact Python export across ordinary, advanced and bounded charts. Renderer and CI thresholds unchanged.", + "causes": [ + "Advanced Config snapshots and bounded exports bypassed the existing text_segments compaction and dumped one dictionary per character.", + "The complete default Config and repeated foreground/grid colors were serialized for each panel." + ], + "changes": [ + "Route every export branch through shared text/run formatting. Group styled runs only after exact reconstruction checks; otherwise retain explicit segment dictionaries.", + "Omit known renderer defaults and recursively restore root/subplot/overlay/colorbar Config before one advanced-document commit.", + "Bounded/radial exports reset chart-level defaults and patch per-item appearance while preserving destination values.", + "Name repeated RGB/RGBA literal dictionaries without rounding, with Unicode byte offsets and variable-collision protection.", + "Keep expanded diagnostic output via to_code(compact=False); get_settings still returns full SSOT." + ], + "regression": { + "test_file": "tests/test_compact_codegen.py", + "cases": 21, + "baseline": { + "failed": 20, + "passed": 1 + }, + "candidate": { + "passed": 21, + "failed": 0 + }, + "contracts": [ + "Exact document SSOT on dirty destination Config, including nested axes, colorbar and shared topology.", + "Advanced replay is one accepted-settings revision and does not reupload columns.", + "Bounded/radial destination values retained and default appearance restored.", + "Styled Unicode runs, legend rule fields, shared colors and export variable names replay exactly.", + "Rejected compact resets are atomic and do not mutate supplied dictionaries." + ] + }, + "validation": { + "installed_regression_cases": 744, + "ci_preflight_cases": 134, + "actual_copy_button_test": "tests/test_interaction_parity.py::test_compact_python_copy_in_both_hosts_replays_the_complete_document", + "actual_copy_hosts": [ + "native X11 clipboard", + "Jupyter WebGPU code field" + ], + "actual_copy_result": "passed; exported code equals shared API output, replays exact document SSOT, and native pixels match", + "package_files_match": 39, + "packaged_python_files_match": 22, + "rust_format": "passed", + "whitespace": "passed" + }, + "measured": { + "input_source": "Twenty final scenes from six independent public-data analyses; unchanged analysis scripts run from cached inputs with the installed candidate wheel.", + "figures": 20, + "baseline_bytes": 1023242, + "candidate_bytes": 92910, + "reduction_percent": 90.92, + "all_ssot_equal": true, + "all_rgba_equal": true, + "all_pngs_match_frozen_baseline": true, + "no_column_reuploads": true, + "scenes": [ + { + "domain": "education", + "path": "plots/01_score_distribution.png", + "compact_bytes": 2252, + "compact_lines": 48, + "expanded_bytes": 2252, + "expanded_lines": 48, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 2515, + "baseline_lines": 49 + }, + { + "domain": "education", + "path": "plots/02_lunch_and_score.png", + "compact_bytes": 1983, + "compact_lines": 44, + "expanded_bytes": 1983, + "expanded_lines": 44, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 1997, + "baseline_lines": 42 + }, + { + "domain": "education", + "path": "plots/03_subject_gap_by_grade.png", + "compact_bytes": 1519, + "compact_lines": 29, + "expanded_bytes": 43249, + "expanded_lines": 765, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 43249, + "baseline_lines": 765 + }, + { + "domain": "energy", + "path": "plots/01_consumption_history.png", + "compact_bytes": 1852, + "compact_lines": 45, + "expanded_bytes": 1852, + "expanded_lines": 45, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 1866, + "baseline_lines": 43 + }, + { + "domain": "energy", + "path": "plots/02_relative_seasonality.png", + "compact_bytes": 1560, + "compact_lines": 31, + "expanded_bytes": 34805, + "expanded_lines": 619, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 34805, + "baseline_lines": 619 + }, + { + "domain": "energy", + "path": "plots/03_level_and_within_year_range.png", + "compact_bytes": 6161, + "compact_lines": 93, + "expanded_bytes": 126121, + "expanded_lines": 1773, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 126121, + "baseline_lines": 1773 + }, + { + "domain": "nutrition", + "path": "plots/01_calorie_distributions.png", + "compact_bytes": 2205, + "compact_lines": 44, + "expanded_bytes": 45096, + "expanded_lines": 799, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 45096, + "baseline_lines": 799 + }, + { + "domain": "nutrition", + "path": "plots/02_calorie_bands.png", + "compact_bytes": 2649, + "compact_lines": 51, + "expanded_bytes": 44398, + "expanded_lines": 783, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 44398, + "baseline_lines": 783 + }, + { + "domain": "nutrition", + "path": "plots/03_calories_sodium.png", + "compact_bytes": 4067, + "compact_lines": 77, + "expanded_bytes": 4067, + "expanded_lines": 77, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 4279, + "baseline_lines": 75 + }, + { + "domain": "sports", + "path": "plots/01_salary_vs_hits.png", + "compact_bytes": 2035, + "compact_lines": 45, + "expanded_bytes": 2035, + "expanded_lines": 45, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 2049, + "baseline_lines": 43 + }, + { + "domain": "sports", + "path": "plots/02_salary_by_experience.png", + "compact_bytes": 1713, + "compact_lines": 32, + "expanded_bytes": 40600, + "expanded_lines": 718, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 40600, + "baseline_lines": 718 + }, + { + "domain": "sports", + "path": "plots/03_salary_coverage.png", + "compact_bytes": 1822, + "compact_lines": 34, + "expanded_bytes": 37197, + "expanded_lines": 657, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 37197, + "baseline_lines": 657 + }, + { + "domain": "acoustics", + "path": "figures/01_class_profiles.png", + "compact_bytes": 16401, + "compact_lines": 254, + "expanded_bytes": 266381, + "expanded_lines": 3293, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 266381, + "baseline_lines": 3293 + }, + { + "domain": "acoustics", + "path": "figures/02_record_heatmaps.png", + "compact_bytes": 29163, + "compact_lines": 396, + "expanded_bytes": 141514, + "expanded_lines": 1719, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 141514, + "baseline_lines": 1719 + }, + { + "domain": "acoustics", + "path": "figures/03_pca_overlap.png", + "compact_bytes": 6740, + "compact_lines": 113, + "expanded_bytes": 166763, + "expanded_lines": 2242, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 166763, + "baseline_lines": 2242 + }, + { + "domain": "acoustics", + "path": "figures/04_record_mean_distribution.png", + "compact_bytes": 1683, + "compact_lines": 33, + "expanded_bytes": 18913, + "expanded_lines": 344, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 18913, + "baseline_lines": 344 + }, + { + "domain": "materials", + "path": "artifacts/01_strength_distribution.png", + "compact_bytes": 2317, + "compact_lines": 50, + "expanded_bytes": 2317, + "expanded_lines": 50, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 2598, + "baseline_lines": 51 + }, + { + "domain": "materials", + "path": "artifacts/02_strength_age_bands.png", + "compact_bytes": 1577, + "compact_lines": 31, + "expanded_bytes": 37643, + "expanded_lines": 668, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 37643, + "baseline_lines": 668 + }, + { + "domain": "materials", + "path": "artifacts/03_cement_strength.png", + "compact_bytes": 2458, + "compact_lines": 52, + "expanded_bytes": 2458, + "expanded_lines": 52, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 2505, + "baseline_lines": 50 + }, + { + "domain": "materials", + "path": "artifacts/04_exact_age_summaries.png", + "compact_bytes": 2753, + "compact_lines": 49, + "expanded_bytes": 2753, + "expanded_lines": 49, + "ssot_equal": true, + "pixels_equal": true, + "no_reupload": true, + "matches_frozen_png": true, + "baseline_bytes": 2753, + "baseline_lines": 49 + } + ] + }, + "wheel": { + "file": "figgyplot-0.4.0-cp312-cp312-manylinux_2_39_x86_64.whl", + "sha256": "38181d71a838be1581334e95ac24d0fb0eeeb4d75698c75cc5ca0325ef7daf1e" + }, + "limitations": [ + "Compact replay uses renderer defaults and the same DPI; use expanded output when inspecting every serialized field.", + "Compact output preserves all explicit nondefault styling; complex custom grids can still require substantial code.", + "Native/Jupyter Copy Python was exercised locally on Linux software GPUs; no physical GPU performance or Windows GUI claim." + ] +} diff --git a/docs/measurements/layout-review-host-work-20261022.json b/docs/measurements/layout-review-host-work-20261022.json new file mode 100644 index 0000000..80bbe98 --- /dev/null +++ b/docs/measurements/layout-review-host-work-20261022.json @@ -0,0 +1,33 @@ +{ + "native": { + "moves": 6, + "inspector_rebuilds_during": 0, + "elapsed": 1.1460669020016212, + "last_input_to_submit_ms": 15.458193999999999, + "changed_ssot_paths": [ + "/config/annotations/items/0/kind/Arrow/from/Ratio/x", + "/config/annotations/items/0/kind/Arrow/from/Ratio/y", + "/config/annotations/items/0/kind/Arrow/to/Ratio/x", + "/config/annotations/items/0/kind/Arrow/to/Ratio/y" + ], + "additional_column_uploads": 0, + "before_ssot_sha256": "891f834495a93d874ad5f71d490c7fcdd2bb24c1d356df512a0b79960f3e546b", + "after_ssot_sha256": "76eac254fd9b6fb06ba52acc4ecb38877c9714be237ad5cfe97103ee9315ed12" + }, + "browser": { + "moves": 8, + "inspector_rebuilds_during": 1, + "elapsed": 0.8355179329810198, + "changed_ssot_paths": [ + "/config/annotations/items/0/kind/Arrow/from/Ratio/x", + "/config/annotations/items/0/kind/Arrow/from/Ratio/y", + "/config/annotations/items/0/kind/Arrow/to/Ratio/x", + "/config/annotations/items/0/kind/Arrow/to/Ratio/y" + ], + "additional_column_uploads": 0, + "before_ssot_sha256": "f4bb02cee80a9d9fd1b50d26e82557f02fc6299a0c8004042e5b5c1433f084ff", + "after_ssot_sha256": "b315ccdcc5d6bbf1e018168b627baa3f25b691111474090c240085337dc45252" + }, + "scope": "Host property reconstruction during synthetic input; elapsed includes test-driver waits. This is not a physical-GPU FPS benchmark. Renderer drawing and caching are unchanged.", + "wheel_sha256": "c9cbe07af2f5ec504e78dd4ec6a2cdb8645d36561be9fd6e8f5b2185f0243f58" +} diff --git a/docs/measurements/matplotlib-api-20261028.json b/docs/measurements/matplotlib-api-20261028.json new file mode 100644 index 0000000..a04c487 --- /dev/null +++ b/docs/measurements/matplotlib-api-20261028.json @@ -0,0 +1,351 @@ +{ + "date": "2026-10-28", + "baseline_commit": "9bd77e1cab5f19cbf92f6761ae53a03242238fff", + "reference_matplotlib": "3.11.2", + "supported_contract": "docs/MATPLOTLIB_API.md", + "runtime_dependency": "NumPy only; tested in a separate environment without Matplotlib", + "wheel_sha256": "b5ad585cf96f62917521efefa510d533afbfbac199b4e0b6e4f9b657eba402e4", + "installed_source_files_checked": [ + "__init__.py", + "_api_codegen.py", + "_chart_artist.py", + "_chart_text.py", + "_charts.py", + "_codegen.py", + "_colors.py", + "_document.py", + "_features.py", + "_matplotlib.py", + "_review_diff.py", + "_review_font.py", + "_review_runner.py", + "_review_store.py", + "_review_summary.py", + "_style_presets.py", + "_style_runtime.py", + "assets/figgy.js", + "desktop.py", + "editor.css", + "editor.js", + "editor.py", + "figure.py", + "notebook.py", + "prepare.py", + "pyplot.py", + "review-feedback-v1.schema.json", + "review.py", + "skills/figgyplot-review/SKILL.md", + "styles.py", + "ticker.py", + "webgpu.js", + "widget.py" + ], + "matplotlib_contract_cases": 86, + "initial_installed_regression_cases": 868, + "preflight_cases": 135, + "copy_and_native_typing_cases": 9, + "ci_collection": 1502, + "public_data_scenes": 20, + "frozen_png_matches": 20, + "scene_replays": [ + { + "path": "figures/01_class_profiles.png", + "api_bytes": 8777, + "api_lines": 112, + "exact_bytes": 16401, + "uses_advanced_block": true, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "acoustics", + "matches_frozen_png": true + }, + { + "path": "figures/02_record_heatmaps.png", + "api_bytes": 12127, + "api_lines": 186, + "exact_bytes": 29163, + "uses_advanced_block": true, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "acoustics", + "matches_frozen_png": true + }, + { + "path": "figures/03_pca_overlap.png", + "api_bytes": 4432, + "api_lines": 58, + "exact_bytes": 6740, + "uses_advanced_block": true, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "acoustics", + "matches_frozen_png": true + }, + { + "path": "figures/04_record_mean_distribution.png", + "api_bytes": 1077, + "api_lines": 17, + "exact_bytes": 1683, + "uses_advanced_block": true, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "acoustics", + "matches_frozen_png": true + }, + { + "path": "plots/01_score_distribution.png", + "api_bytes": 1496, + "api_lines": 28, + "exact_bytes": 2252, + "uses_advanced_block": false, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "education", + "matches_frozen_png": true + }, + { + "path": "plots/02_lunch_and_score.png", + "api_bytes": 1224, + "api_lines": 25, + "exact_bytes": 1983, + "uses_advanced_block": false, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "education", + "matches_frozen_png": true + }, + { + "path": "plots/03_subject_gap_by_grade.png", + "api_bytes": 727, + "api_lines": 13, + "exact_bytes": 1519, + "uses_advanced_block": false, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "education", + "matches_frozen_png": true + }, + { + "path": "plots/01_consumption_history.png", + "api_bytes": 1338, + "api_lines": 25, + "exact_bytes": 1852, + "uses_advanced_block": false, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "energy", + "matches_frozen_png": true + }, + { + "path": "plots/02_relative_seasonality.png", + "api_bytes": 819, + "api_lines": 16, + "exact_bytes": 1560, + "uses_advanced_block": false, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "energy", + "matches_frozen_png": true + }, + { + "path": "plots/03_level_and_within_year_range.png", + "api_bytes": 4423, + "api_lines": 68, + "exact_bytes": 6161, + "uses_advanced_block": true, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "energy", + "matches_frozen_png": true + }, + { + "path": "artifacts/01_strength_distribution.png", + "api_bytes": 1585, + "api_lines": 30, + "exact_bytes": 2317, + "uses_advanced_block": false, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "materials", + "matches_frozen_png": true + }, + { + "path": "artifacts/02_strength_age_bands.png", + "api_bytes": 832, + "api_lines": 15, + "exact_bytes": 1577, + "uses_advanced_block": false, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "materials", + "matches_frozen_png": true + }, + { + "path": "artifacts/03_cement_strength.png", + "api_bytes": 1758, + "api_lines": 33, + "exact_bytes": 2458, + "uses_advanced_block": false, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "materials", + "matches_frozen_png": true + }, + { + "path": "artifacts/04_exact_age_summaries.png", + "api_bytes": 1211, + "api_lines": 23, + "exact_bytes": 2753, + "uses_advanced_block": false, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "materials", + "matches_frozen_png": true + }, + { + "path": "plots/01_calorie_distributions.png", + "api_bytes": 1220, + "api_lines": 21, + "exact_bytes": 2205, + "uses_advanced_block": false, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "nutrition", + "matches_frozen_png": true + }, + { + "path": "plots/02_calorie_bands.png", + "api_bytes": 1551, + "api_lines": 24, + "exact_bytes": 2649, + "uses_advanced_block": true, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "nutrition", + "matches_frozen_png": true + }, + { + "path": "plots/03_calories_sodium.png", + "api_bytes": 2315, + "api_lines": 49, + "exact_bytes": 4067, + "uses_advanced_block": false, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "nutrition", + "matches_frozen_png": true + }, + { + "path": "plots/01_salary_vs_hits.png", + "api_bytes": 1006, + "api_lines": 22, + "exact_bytes": 2035, + "uses_advanced_block": false, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "sports", + "matches_frozen_png": true + }, + { + "path": "plots/02_salary_by_experience.png", + "api_bytes": 897, + "api_lines": 16, + "exact_bytes": 1713, + "uses_advanced_block": false, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "sports", + "matches_frozen_png": true + }, + { + "path": "plots/03_salary_coverage.png", + "api_bytes": 892, + "api_lines": 16, + "exact_bytes": 1822, + "uses_advanced_block": false, + "api_config_series_pixels_equal": true, + "exact_ssot_pixels_equal": true, + "no_reupload": true, + "domain": "sports", + "matches_frozen_png": true + } + ], + "export_modes": { + "api": "Public object calls; retains destination figure identity; renderer-only state is an explicit advanced block", + "exact": "Complete accepted SSOT and native pixel equality; resident data are not uploaded or replaced" + }, + "limitations": [ + "Not a full Matplotlib replacement", + "Tick and spine color/width are shared by the renderer", + "Arbitrary tick positions/labels and tick padding are guarded", + "Annotations are restricted to the parent panel", + "Scalar-mapped point opacity is guarded" + ], + "validation_scope": "Linux Mesa software GPU and actual native X11/Jupyter WebGPU; no physical-GPU performance claim", + "wheel_description_matches_source": true, + "followup_regressions": { + "startup": "All five native colormaps export and construct a widget; angle/time notation and independent boundary scales preserve exact config and pixels.", + "export_isolation": "A simulated API export exception leaves the accepted chart visible and supplies complete exact recovery. Diagnostic lines cannot execute as code.", + "clipboard": "A real browser clipboard write is held until the final edit acknowledgement; an explicit ACK gate reproduced the old race and verifies the fix.", + "axis_scope": "Six pre-fix failures demonstrated locator/formatter changes escaping their primary, secondary or colorbar owner. Selected owners and untouched peer config/identity are asserted.", + "marker_replay": "Two host cases replay a newly added marker on the original resident Line; settings and native pixels match with no column uploads.", + "short_export": "Only calls proven equal by the native typed serializer are omitted; no rounding of scientific values or source arrays.", + "initial_kernel_error": "A deliberately failed real Jupyter cell reports the actual exception before waiting for GPU readiness." + }, + "full_local_suite": { + "cases": 1497, + "passed": 1497, + "failures": 0, + "skipped": 0, + "execution_chunks": [ + { + "passed": 813, + "api_codegen_sha256": "11980462fb46f50eaf021a87eec391b4d96fc725146a4ed249614c7804dc27c5" + }, + { + "passed": 684, + "final_wheel": true + } + ], + "resumed_after": "A stale radial clipboard syntax assertion was replaced by public API assertions plus real settings/value/pixel replay.", + "final_marker_export_ordering_verified_in_final_focused_suite": true, + "baseline_commit": "547a1ab8a5851bcc2c61878b26bf25983eb7acca", + "scope": "Complete baseline collection before the browser ACK follow-up; final browser paths are checked separately below and in the final-head CI." + }, + "final_focused_suite": { + "cases": 274, + "passed": 274, + "failures": 0, + "skipped": 0 + }, + "browser_ack_followup": { + "ci_trigger": "CI75 shard0: a numeric draft displayed 2 but accepted settings remained 7 after an external Python update.", + "before": "Both an explicitly delayed selection ACK and a delayed Python state notification reproduce 7 instead of 2 locally.", + "cause": "The previous selection ACK installed revision6 after Python accepted revision7. A subsequent full-snapshot edit used revision6 and was rejected.", + "fix": "Serialize form drafts after preceding ACKs; choose the latest accepted head; retry once with actual SSOT leaf changes only when unchanged at that head. Arrays are atomic and figure/panel ownership must match.", + "conflicts": "A conflicting property value or a stale full snapshot remains rejected. Valid edits recover without losing the unrelated Python change.", + "preliminary_paired_property_cases": 18, + "final_ack_and_ownership_cases": 10, + "diagnostic_and_rebase_unit_cases": 10, + "new_regressions": 5 + } +} diff --git a/docs/measurements/native-annotation-drag-20261022.json b/docs/measurements/native-annotation-drag-20261022.json new file mode 100644 index 0000000..73bd2d5 --- /dev/null +++ b/docs/measurements/native-annotation-drag-20261022.json @@ -0,0 +1,986 @@ +{ + "environment": "Linux x86_64, llvmpipe Vulkan, Xvfb/Openbox; installed release wheels", + "baseline_source": "4555597 plus opt-in CPU timing instrumentation; no behavior changes", + "timing": "Inclusive CPU wall time inside named stages; overlapping stages must not be summed. Input-driver waits excluded. Not hardware GPU timestamps or a physical-GPU FPS claim.", + "before_wheel": { + "file": "figgyplot-0.3.0-cp312-cp312-manylinux_2_39_x86_64.whl", + "sha256": "d63cdda0ee4f7fec4b60b02ec9f63504a2bf617446188765ce59a2cbfe88784e" + }, + "after_wheel": { + "file": "figgyplot-0.3.0-cp312-cp312-manylinux_2_39_x86_64.whl", + "sha256": "215603ffceaadac458c3e34a846e91564c10be63ba8ac8b635478133f532440a" + }, + "results": { + "before": [ + { + "width": 960, + "height": 600, + "annotation": "Text", + "moves": 32, + "stages": { + "annotation_view": { + "calls": 32, + "total_ms": 61.24479500000001, + "mean_ms": 1.9138998437500003 + }, + "apply_settings": { + "calls": 32, + "total_ms": 25.832470000000004, + "mean_ms": 0.8072646875000001 + }, + "commit_config": { + "calls": 32, + "total_ms": 27.360379, + "mean_ms": 0.85501184375 + }, + "drag": { + "calls": 32, + "total_ms": 90.330425, + "mean_ms": 2.82282578125 + }, + "native_annotation_refresh": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_axis_refresh": { + "calls": 32, + "total_ms": 13.45272, + "mean_ms": 0.4203975 + }, + "native_frame": { + "calls": 96, + "total_ms": 1055.2045280000002, + "mean_ms": 10.991713833333336 + }, + "native_prepare": { + "calls": 96, + "total_ms": 3.985286000000002, + "mean_ms": 0.041513395833333355 + }, + "native_styles": { + "calls": 32, + "total_ms": 2.9740979999999997, + "mean_ms": 0.09294056249999999 + }, + "native_submit_present": { + "calls": 96, + "total_ms": 993.1759430000002, + "mean_ms": 10.345582739583335 + }, + "native_ui": { + "calls": 96, + "total_ms": 20.695843999999997, + "mean_ms": 0.21558170833333332 + } + } + }, + { + "width": 1440, + "height": 900, + "annotation": "Text", + "moves": 32, + "stages": { + "annotation_view": { + "calls": 32, + "total_ms": 103.020227, + "mean_ms": 3.21938209375 + }, + "apply_settings": { + "calls": 32, + "total_ms": 25.924212999999998, + "mean_ms": 0.8101316562499999 + }, + "commit_config": { + "calls": 32, + "total_ms": 27.387809000000004, + "mean_ms": 0.8558690312500001 + }, + "drag": { + "calls": 32, + "total_ms": 132.30875200000003, + "mean_ms": 4.134648500000001 + }, + "native_annotation_refresh": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_axis_refresh": { + "calls": 32, + "total_ms": 24.765091, + "mean_ms": 0.77390909375 + }, + "native_frame": { + "calls": 96, + "total_ms": 1993.7865119999992, + "mean_ms": 20.768609499999993 + }, + "native_prepare": { + "calls": 96, + "total_ms": 4.018087000000002, + "mean_ms": 0.04185507291666669 + }, + "native_styles": { + "calls": 32, + "total_ms": 3.465683000000001, + "mean_ms": 0.10830259375000004 + }, + "native_submit_present": { + "calls": 96, + "total_ms": 1916.2441019999985, + "mean_ms": 19.960876062499985 + }, + "native_ui": { + "calls": 96, + "total_ms": 21.098935999999995, + "mean_ms": 0.21978058333333328 + } + } + }, + { + "width": 960, + "height": 600, + "annotation": "Arrow", + "moves": 32, + "stages": { + "annotation_view": { + "calls": 32, + "total_ms": 62.150118000000006, + "mean_ms": 1.9421911875000002 + }, + "apply_settings": { + "calls": 32, + "total_ms": 21.39182999999999, + "mean_ms": 0.6684946874999997 + }, + "commit_config": { + "calls": 32, + "total_ms": 22.668884999999992, + "mean_ms": 0.7084026562499998 + }, + "drag": { + "calls": 32, + "total_ms": 86.397568, + "mean_ms": 2.699924 + }, + "native_annotation_refresh": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_axis_refresh": { + "calls": 32, + "total_ms": 14.902797999999995, + "mean_ms": 0.46571243749999985 + }, + "native_frame": { + "calls": 96, + "total_ms": 1226.0683130000002, + "mean_ms": 12.771544927083335 + }, + "native_prepare": { + "calls": 96, + "total_ms": 3.1481630000000003, + "mean_ms": 0.03279336458333334 + }, + "native_styles": { + "calls": 32, + "total_ms": 3.085703999999999, + "mean_ms": 0.09642824999999997 + }, + "native_submit_present": { + "calls": 96, + "total_ms": 1117.458478, + "mean_ms": 11.640192479166666 + }, + "native_ui": { + "calls": 96, + "total_ms": 62.35741600000001, + "mean_ms": 0.6495564166666667 + } + } + } + ], + "after": [ + { + "width": 960, + "height": 600, + "annotation": "Text", + "moves": 32, + "stages": { + "annotation_move": { + "calls": 32, + "total_ms": 25.809513, + "mean_ms": 0.80654728125 + }, + "annotation_view": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "apply_settings": { + "calls": 32, + "total_ms": 24.254286999999994, + "mean_ms": 0.7579464687499998 + }, + "commit_config": { + "calls": 32, + "total_ms": 25.608552, + "mean_ms": 0.80026725 + }, + "native_annotation_refresh": { + "calls": 32, + "total_ms": 17.871858, + "mean_ms": 0.5584955625 + }, + "native_axis_refresh": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_frame": { + "calls": 96, + "total_ms": 977.8893000000005, + "mean_ms": 10.186346875000005 + }, + "native_prepare": { + "calls": 96, + "total_ms": 3.1677520000000037, + "mean_ms": 0.0329974166666667 + }, + "native_styles": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_submit_present": { + "calls": 96, + "total_ms": 921.0539819999995, + "mean_ms": 9.594312312499994 + }, + "native_ui": { + "calls": 96, + "total_ms": 16.670766, + "mean_ms": 0.1736538125 + } + } + }, + { + "width": 1440, + "height": 900, + "annotation": "Text", + "moves": 32, + "stages": { + "annotation_move": { + "calls": 32, + "total_ms": 25.513158000000004, + "mean_ms": 0.7972861875000001 + }, + "annotation_view": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "apply_settings": { + "calls": 32, + "total_ms": 23.86983800000001, + "mean_ms": 0.7459324375000003 + }, + "commit_config": { + "calls": 32, + "total_ms": 25.316332000000003, + "mean_ms": 0.7911353750000001 + }, + "native_annotation_refresh": { + "calls": 32, + "total_ms": 30.327722, + "mean_ms": 0.9477413125 + }, + "native_axis_refresh": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_frame": { + "calls": 96, + "total_ms": 1840.9019270000001, + "mean_ms": 19.176061739583336 + }, + "native_prepare": { + "calls": 96, + "total_ms": 3.24915, + "mean_ms": 0.0338453125 + }, + "native_styles": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_submit_present": { + "calls": 96, + "total_ms": 1765.9576039999995, + "mean_ms": 18.395391708333328 + }, + "native_ui": { + "calls": 96, + "total_ms": 17.872780000000002, + "mean_ms": 0.1861747916666667 + } + } + }, + { + "width": 960, + "height": 600, + "annotation": "Arrow", + "moves": 32, + "stages": { + "annotation_move": { + "calls": 32, + "total_ms": 21.775058, + "mean_ms": 0.6804705625 + }, + "annotation_view": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "apply_settings": { + "calls": 32, + "total_ms": 20.445033999999996, + "mean_ms": 0.6389073124999999 + }, + "commit_config": { + "calls": 32, + "total_ms": 21.592997999999994, + "mean_ms": 0.6747811874999998 + }, + "native_annotation_refresh": { + "calls": 32, + "total_ms": 19.127974000000005, + "mean_ms": 0.5977491875000002 + }, + "native_axis_refresh": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_frame": { + "calls": 96, + "total_ms": 1152.9711009999999, + "mean_ms": 12.010115635416666 + }, + "native_prepare": { + "calls": 96, + "total_ms": 2.9038449999999996, + "mean_ms": 0.030248385416666662 + }, + "native_styles": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_submit_present": { + "calls": 96, + "total_ms": 1046.5153280000002, + "mean_ms": 10.901201333333335 + }, + "native_ui": { + "calls": 96, + "total_ms": 60.17736400000002, + "mean_ms": 0.6268475416666669 + } + } + } + ], + "before_burst": [ + { + "width": 960, + "height": 600, + "annotation": "Text", + "moves": 32, + "stages": { + "annotation_view": { + "calls": 32, + "total_ms": 241.63094000000004, + "mean_ms": 7.550966875000001 + }, + "apply_settings": { + "calls": 32, + "total_ms": 19.092167999999997, + "mean_ms": 0.5966302499999999 + }, + "commit_config": { + "calls": 32, + "total_ms": 20.125779, + "mean_ms": 0.62893059375 + }, + "drag": { + "calls": 32, + "total_ms": 263.040059, + "mean_ms": 8.22000184375 + }, + "native_annotation_refresh": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_axis_refresh": { + "calls": 1, + "total_ms": 1.2802950000000002, + "mean_ms": 1.2802950000000002 + }, + "native_frame": { + "calls": 3, + "total_ms": 453.069519, + "mean_ms": 151.023173 + }, + "native_prepare": { + "calls": 3, + "total_ms": 0.11686200000000113, + "mean_ms": 0.03895400000000038 + }, + "native_styles": { + "calls": 1, + "total_ms": 0.09756399999999998, + "mean_ms": 0.09756399999999998 + }, + "native_submit_present": { + "calls": 3, + "total_ms": 450.17523700000004, + "mean_ms": 150.05841233333334 + }, + "native_ui": { + "calls": 3, + "total_ms": 0.7124709999999999, + "mean_ms": 0.23749033333333328 + } + } + }, + { + "width": 1440, + "height": 900, + "annotation": "Text", + "moves": 32, + "stages": { + "annotation_view": { + "calls": 32, + "total_ms": 557.0487579999999, + "mean_ms": 17.407773687499997 + }, + "apply_settings": { + "calls": 32, + "total_ms": 22.582147000000003, + "mean_ms": 0.7056920937500001 + }, + "commit_config": { + "calls": 32, + "total_ms": 23.833500999999995, + "mean_ms": 0.7447969062499998 + }, + "drag": { + "calls": 32, + "total_ms": 582.453085, + "mean_ms": 18.20165890625 + }, + "native_annotation_refresh": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_axis_refresh": { + "calls": 1, + "total_ms": 4.019158999999999, + "mean_ms": 4.019158999999999 + }, + "native_frame": { + "calls": 3, + "total_ms": 673.837034, + "mean_ms": 224.61234466666667 + }, + "native_prepare": { + "calls": 3, + "total_ms": 0.14272000000000018, + "mean_ms": 0.04757333333333339 + }, + "native_styles": { + "calls": 1, + "total_ms": 0.10661699999999996, + "mean_ms": 0.10661699999999996 + }, + "native_submit_present": { + "calls": 3, + "total_ms": 668.084512, + "mean_ms": 222.69483733333334 + }, + "native_ui": { + "calls": 3, + "total_ms": 0.7168160000000015, + "mean_ms": 0.23893866666666716 + } + } + }, + { + "width": 960, + "height": 600, + "annotation": "Arrow", + "moves": 32, + "stages": { + "annotation_view": { + "calls": 32, + "total_ms": 79.46106099999999, + "mean_ms": 2.4831581562499996 + }, + "apply_settings": { + "calls": 32, + "total_ms": 15.329462, + "mean_ms": 0.4790456875 + }, + "commit_config": { + "calls": 32, + "total_ms": 16.08154, + "mean_ms": 0.502548125 + }, + "drag": { + "calls": 32, + "total_ms": 96.682203, + "mean_ms": 3.02131884375 + }, + "native_annotation_refresh": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_axis_refresh": { + "calls": 1, + "total_ms": 0.7906539999999997, + "mean_ms": 0.7906539999999997 + }, + "native_frame": { + "calls": 3, + "total_ms": 102.58167799999998, + "mean_ms": 34.19389266666666 + }, + "native_prepare": { + "calls": 3, + "total_ms": 0.12329099999999993, + "mean_ms": 0.041096999999999974 + }, + "native_styles": { + "calls": 1, + "total_ms": 0.09611199999999998, + "mean_ms": 0.09611199999999998 + }, + "native_submit_present": { + "calls": 3, + "total_ms": 98.37068500000001, + "mean_ms": 32.79022833333334 + }, + "native_ui": { + "calls": 3, + "total_ms": 2.376029000000001, + "mean_ms": 0.792009666666667 + } + } + } + ], + "after_burst": [ + { + "width": 960, + "height": 600, + "annotation": "Text", + "moves": 32, + "stages": { + "annotation_move": { + "calls": 32, + "total_ms": 16.127386999999995, + "mean_ms": 0.5039808437499999 + }, + "annotation_view": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "apply_settings": { + "calls": 32, + "total_ms": 15.411095999999997, + "mean_ms": 0.4815967499999999 + }, + "commit_config": { + "calls": 32, + "total_ms": 16.04918, + "mean_ms": 0.501536875 + }, + "native_annotation_refresh": { + "calls": 1, + "total_ms": 0.48954099999999995, + "mean_ms": 0.48954099999999995 + }, + "native_axis_refresh": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_frame": { + "calls": 3, + "total_ms": 31.49781299999998, + "mean_ms": 10.499270999999993 + }, + "native_prepare": { + "calls": 3, + "total_ms": 0.10616600000000176, + "mean_ms": 0.035388666666667255 + }, + "native_styles": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_submit_present": { + "calls": 3, + "total_ms": 29.06097600000001, + "mean_ms": 9.686992000000004 + }, + "native_ui": { + "calls": 3, + "total_ms": 0.5951150000000007, + "mean_ms": 0.19837166666666692 + } + } + }, + { + "width": 1440, + "height": 900, + "annotation": "Text", + "moves": 32, + "stages": { + "annotation_move": { + "calls": 32, + "total_ms": 17.339455999999995, + "mean_ms": 0.5418579999999998 + }, + "annotation_view": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "apply_settings": { + "calls": 32, + "total_ms": 16.576268000000002, + "mean_ms": 0.5180083750000001 + }, + "commit_config": { + "calls": 32, + "total_ms": 17.256738999999996, + "mean_ms": 0.5392730937499999 + }, + "native_annotation_refresh": { + "calls": 1, + "total_ms": 1.2995830000000002, + "mean_ms": 1.2995830000000002 + }, + "native_axis_refresh": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_frame": { + "calls": 3, + "total_ms": 72.86925600000001, + "mean_ms": 24.289752000000004 + }, + "native_prepare": { + "calls": 3, + "total_ms": 0.13615100000000002, + "mean_ms": 0.045383666666666676 + }, + "native_styles": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_submit_present": { + "calls": 3, + "total_ms": 69.73414000000002, + "mean_ms": 23.24471333333334 + }, + "native_ui": { + "calls": 3, + "total_ms": 0.7063410000000001, + "mean_ms": 0.23544700000000005 + } + } + }, + { + "width": 960, + "height": 600, + "annotation": "Arrow", + "moves": 32, + "stages": { + "annotation_move": { + "calls": 32, + "total_ms": 13.320491999999998, + "mean_ms": 0.41626537499999994 + }, + "annotation_view": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "apply_settings": { + "calls": 32, + "total_ms": 12.726700999999998, + "mean_ms": 0.39770940624999995 + }, + "commit_config": { + "calls": 32, + "total_ms": 13.262473, + "mean_ms": 0.41445228125 + }, + "native_annotation_refresh": { + "calls": 1, + "total_ms": 0.522489, + "mean_ms": 0.522489 + }, + "native_axis_refresh": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_frame": { + "calls": 3, + "total_ms": 41.65029300000003, + "mean_ms": 13.88343100000001 + }, + "native_prepare": { + "calls": 3, + "total_ms": 0.13443700000000003, + "mean_ms": 0.04481233333333334 + }, + "native_styles": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_submit_present": { + "calls": 3, + "total_ms": 37.62983099999997, + "mean_ms": 12.54327699999999 + }, + "native_ui": { + "calls": 3, + "total_ms": 2.1363350000000008, + "mean_ms": 0.7121116666666669 + } + } + } + ], + "after_review": [ + { + "width": 960, + "height": 600, + "annotation": "Text", + "moves": 32, + "stages": { + "annotation_move": { + "calls": 32, + "total_ms": 28.478897999999997, + "mean_ms": 0.8899655624999999 + }, + "annotation_view": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "apply_settings": { + "calls": 32, + "total_ms": 26.92554100000001, + "mean_ms": 0.8414231562500003 + }, + "commit_config": { + "calls": 32, + "total_ms": 28.27446799999999, + "mean_ms": 0.8835771249999997 + }, + "native_annotation_refresh": { + "calls": 32, + "total_ms": 10.258887999999999, + "mean_ms": 0.32059024999999997 + }, + "native_axis_refresh": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_frame": { + "calls": 96, + "total_ms": 700.6096989999996, + "mean_ms": 7.298017697916663 + }, + "native_prepare": { + "calls": 96, + "total_ms": 3.1857859999999993, + "mean_ms": 0.03318527083333333 + }, + "native_styles": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_submit_present": { + "calls": 96, + "total_ms": 649.3902029999999, + "mean_ms": 6.764481281249999 + }, + "native_ui": { + "calls": 96, + "total_ms": 19.60493099999999, + "mean_ms": 0.2042180312499999 + } + }, + "durable_writes": { + "count": 32, + "total_ms": 4.296126833651215, + "max_ms": 0.19857200095430017 + } + }, + { + "width": 1440, + "height": 900, + "annotation": "Text", + "moves": 32, + "stages": { + "annotation_move": { + "calls": 32, + "total_ms": 34.622457, + "mean_ms": 1.08195178125 + }, + "annotation_view": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "apply_settings": { + "calls": 32, + "total_ms": 32.56906399999999, + "mean_ms": 1.0177832499999997 + }, + "commit_config": { + "calls": 32, + "total_ms": 34.361818, + "mean_ms": 1.0738068125 + }, + "native_annotation_refresh": { + "calls": 32, + "total_ms": 23.062213, + "mean_ms": 0.72069415625 + }, + "native_axis_refresh": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_frame": { + "calls": 96, + "total_ms": 1626.1719510000005, + "mean_ms": 16.939291156250004 + }, + "native_prepare": { + "calls": 96, + "total_ms": 5.298603, + "mean_ms": 0.05519378125 + }, + "native_styles": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_submit_present": { + "calls": 96, + "total_ms": 1543.5574870000003, + "mean_ms": 16.07872382291667 + }, + "native_ui": { + "calls": 96, + "total_ms": 25.574385000000007, + "mean_ms": 0.2663998437500001 + } + }, + "durable_writes": { + "count": 32, + "total_ms": 5.092853010864928, + "max_ms": 0.2254619903396815 + } + }, + { + "width": 960, + "height": 600, + "annotation": "Arrow", + "moves": 32, + "stages": { + "annotation_move": { + "calls": 32, + "total_ms": 28.010426000000006, + "mean_ms": 0.8753258125000002 + }, + "annotation_view": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "apply_settings": { + "calls": 32, + "total_ms": 26.659370999999997, + "mean_ms": 0.8331053437499999 + }, + "commit_config": { + "calls": 32, + "total_ms": 27.824853000000004, + "mean_ms": 0.8695266562500001 + }, + "native_annotation_refresh": { + "calls": 32, + "total_ms": 13.143468, + "mean_ms": 0.410733375 + }, + "native_axis_refresh": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_frame": { + "calls": 96, + "total_ms": 1078.1163240000003, + "mean_ms": 11.230378375000003 + }, + "native_prepare": { + "calls": 96, + "total_ms": 3.2247270000000006, + "mean_ms": 0.033590906250000004 + }, + "native_styles": { + "calls": 0, + "total_ms": 0.0, + "mean_ms": null + }, + "native_submit_present": { + "calls": 96, + "total_ms": 969.6885800000002, + "mean_ms": 10.100922708333336 + }, + "native_ui": { + "calls": 96, + "total_ms": 68.16133600000003, + "mean_ms": 0.710013916666667 + } + }, + "durable_writes": { + "count": 32, + "total_ms": 4.57673697383143, + "max_ms": 0.2616760029923171 + } + } + ] + }, + "verification": "Before/after complete SSOT compared for each gesture: only the selected annotation kind coordinates changed. Timing regression test fails against the baseline wheel on annotation_view counts. Both normal and burst inputs retain total movement." +} diff --git a/docs/measurements/native-review-package-20261022.json b/docs/measurements/native-review-package-20261022.json new file mode 100644 index 0000000..6901644 --- /dev/null +++ b/docs/measurements/native-review-package-20261022.json @@ -0,0 +1,4 @@ +{ + "verified_files": 34, + "wheel_sha256": "215603ffceaadac458c3e34a846e91564c10be63ba8ac8b635478133f532440a" +} diff --git a/docs/measurements/native-typing-20261022.json b/docs/measurements/native-typing-20261022.json new file mode 100644 index 0000000..fccd82d --- /dev/null +++ b/docs/measurements/native-typing-20261022.json @@ -0,0 +1,464 @@ +{ + "baseline_commit": "c83f42b8b1e696cf6a720cf98e0a5f2c689bf8d0", + "candidate_wheel_sha256": "ce06d9d9a8fea3ff0c4bce7eac8283afb9a39b27c1e056f4337f387693db4793", + "environment": "Linux x86_64, Python 3.12.14, native llvmpipe Vulkan, Xvfb/Openbox", + "workload": "24 separately presented real X11 character inputs; one 1200x720 Figure with 12 series and 16 annotations; durable Session.poll active", + "interpretation": "Host stage CPU wall times exclude the input-driver waits. Submit/present includes software rendering and surface waits. These are single-run diagnostics, not hardware GPU FPS or a Windows IME latency guarantee.", + "before": { + "cases": [ + { + "mode": "inline", + "stages": { + "native_frame": { + "calls": 24, + "total_ms": 276.38934000000006, + "p95_ms": 22.841997 + }, + "native_prepare": { + "calls": 24, + "total_ms": 2.510702999999994, + "p95_ms": 2.516084 + }, + "native_submit_present": { + "calls": 24, + "total_ms": 257.09189499999985, + "p95_ms": 17.242281000000002 + }, + "native_ui": { + "calls": 24, + "total_ms": 10.417399000000003, + "p95_ms": 1.1882 + } + }, + "driver_poll_ms": [ + 37.56612300639972, + 38.19836399634369, + 41.720382985658944, + 43.70656399987638, + 42.50289298943244, + 40.10501698940061, + 46.29437898984179, + 46.00793801364489, + 39.247395005077124, + 26.070239022374153, + 35.13043202110566, + 23.114852025173604, + 32.91332101798616, + 35.68016100325622, + 31.83602701756172, + 35.83640998112969, + 45.100532996002585, + 33.73585100052878, + 41.94438300328329, + 48.85355199803598, + 41.919985989807174, + 39.38283500610851, + 24.66599299805239, + 38.66280600777827 + ], + "revision_changed": false + }, + { + "mode": "inspector", + "stages": { + "apply_settings": { + "calls": 24, + "total_ms": 45.868932999999984, + "p95_ms": 1.81569 + }, + "native_annotation_refresh": { + "calls": 24, + "total_ms": 16.736593000000003, + "p95_ms": 1.391298 + }, + "native_frame": { + "calls": 24, + "total_ms": 459.99702000000025, + "p95_ms": 21.999653000000002 + }, + "native_prepare": { + "calls": 24, + "total_ms": 2.516622, + "p95_ms": 0.164092 + }, + "native_submit_present": { + "calls": 24, + "total_ms": 321.1383830000001, + "p95_ms": 17.457089 + }, + "native_ui": { + "calls": 24, + "total_ms": 110.36133799999999, + "p95_ms": 4.68001 + } + }, + "driver_poll_ms": [ + 70.62110499828123, + 45.98860800615512, + 62.15081998379901, + 59.84088600962423, + 54.74790700827725, + 35.69538900046609, + 62.17186601134017, + 48.979776998749, + 54.02122699888423, + 52.48956198920496, + 35.6351509981323, + 35.73347500059754, + 35.69482799503021, + 56.949568999698386, + 52.072088001295924, + 53.542403009487316, + 60.944939992623404, + 52.69585599307902, + 48.03060897393152, + 45.56195400073193, + 48.422876017866656, + 43.703138013370335, + 43.15389902330935, + 39.1570650099311 + ], + "revision_changed": true + }, + { + "mode": "review", + "stages": { + "native_frame": { + "calls": 24, + "total_ms": 261.0277249999999, + "p95_ms": 13.21124 + }, + "native_prepare": { + "calls": 24, + "total_ms": 2.0464849999999997, + "p95_ms": 0.1293 + }, + "native_submit_present": { + "calls": 24, + "total_ms": 243.70852100000005, + "p95_ms": 11.830617 + }, + "native_ui": { + "calls": 24, + "total_ms": 8.732021999999995, + "p95_ms": 0.48173900000000003 + } + }, + "driver_poll_ms": [ + 23.08819000609219, + 21.984785998938605, + 21.318719984265044, + 21.612056996673346, + 22.836768999695778, + 22.910408006282523, + 23.18958000978455, + 22.731503995601088, + 33.89915599836968, + 23.618831008207053, + 23.573314014356583, + 23.442140984116122, + 23.577985994052142, + 23.534496984211728, + 22.709367010975257, + 21.049348986707628, + 21.367541019571945, + 21.488403988769278, + 22.594122012378648, + 21.28223198815249, + 21.256042004097253, + 21.240480011329055, + 21.563354996033013, + 21.58624798175879 + ], + "revision_changed": false + }, + { + "mode": "target", + "stages": { + "native_frame": { + "calls": 24, + "total_ms": 305.252472, + "p95_ms": 14.619052 + }, + "native_prepare": { + "calls": 24, + "total_ms": 2.2731149999999998, + "p95_ms": 0.164012 + }, + "native_submit_present": { + "calls": 24, + "total_ms": 284.75766800000014, + "p95_ms": 13.473796 + }, + "native_ui": { + "calls": 24, + "total_ms": 10.288915999999986, + "p95_ms": 0.73368 + } + }, + "driver_poll_ms": [ + 27.03899602056481, + 25.23485998972319, + 24.89900597720407, + 24.036555987549946, + 23.54244899470359, + 25.031460012542084, + 23.5860179818701, + 24.74019699729979, + 22.32075500069186, + 21.96584697230719, + 21.7280579963699, + 21.43349399557337, + 21.806222008308396, + 23.29801899031736, + 24.99167202040553, + 23.592541983816773, + 22.8948260191828, + 23.23769000940956, + 23.208958009490743, + 23.01632400485687, + 22.358354995958507, + 25.494893023278564, + 24.51197599293664, + 24.33797001140192 + ], + "revision_changed": false + } + ] + }, + "after": { + "kind": "CPU wall time; driver_poll_ms includes the driver and is not frame time", + "cases": [ + { + "mode": "inline", + "stages": { + "native_frame": { + "calls": 24, + "total_ms": 359.15919699999984, + "p95_ms": 26.276365 + }, + "native_prepare": { + "calls": 24, + "total_ms": 2.311634999999997, + "p95_ms": 2.6104740000000004 + }, + "native_submit_present": { + "calls": 24, + "total_ms": 339.4190460000001, + "p95_ms": 19.858815 + }, + "native_ui": { + "calls": 24, + "total_ms": 10.059204000000001, + "p95_ms": 1.185786 + } + }, + "driver_poll_ms": [ + 27.677297999616712, + 26.57341299345717, + 26.529221999226138, + 29.009279009187594, + 27.31084800325334, + 28.39244098868221, + 24.57829497870989, + 24.946255987742916, + 26.932827982818708, + 27.857492998009548, + 40.12600000714883, + 25.945858971681446, + 26.094667002325878, + 23.92399002565071, + 23.710516979917884, + 24.277482007164508, + 24.71139098634012, + 24.95986598660238, + 23.946222994709387, + 24.64132700697519, + 27.62017399072647, + 24.231924006016925, + 27.72067702608183, + 24.914469016948715 + ], + "revision_changed": false + }, + { + "mode": "inspector", + "stages": { + "commit_config": { + "calls": 24, + "total_ms": 31.863843999999997, + "p95_ms": 1.487781 + }, + "native_annotation_refresh": { + "calls": 24, + "total_ms": 14.324294999999996, + "p95_ms": 1.176773 + }, + "native_frame": { + "calls": 24, + "total_ms": 417.5182399999999, + "p95_ms": 18.758935 + }, + "native_navigation": { + "calls": 23, + "total_ms": 6.631526, + "p95_ms": 0.456272 + }, + "native_prepare": { + "calls": 24, + "total_ms": 1.5925769999999986, + "p95_ms": 0.133277 + }, + "native_submit_present": { + "calls": 24, + "total_ms": 318.2207990000003, + "p95_ms": 15.10229 + }, + "native_ui": { + "calls": 24, + "total_ms": 74.47782699999999, + "p95_ms": 3.1618459999999997 + } + }, + "driver_poll_ms": [ + 31.168380024610087, + 29.736621014308184, + 29.823268996551633, + 32.796426996355876, + 28.879460005555302, + 30.625279992818832, + 29.253460001200438, + 28.600608027772978, + 29.05978399212472, + 29.869797988794744, + 28.507951996289194, + 28.441013011615723, + 28.073421999579296, + 28.47898899926804, + 27.89106301497668, + 28.084727993700653, + 31.17454901803285, + 29.681119020096958, + 28.372872999170795, + 31.11883599194698, + 31.552705011563376, + 29.740205995040014, + 27.417785982834175, + 28.81792699918151 + ], + "revision_changed": true + }, + { + "mode": "review", + "stages": { + "native_frame": { + "calls": 24, + "total_ms": 280.1250129999999, + "p95_ms": 12.702736 + }, + "native_prepare": { + "calls": 24, + "total_ms": 1.9561910000000002, + "p95_ms": 0.12839899999999999 + }, + "native_submit_present": { + "calls": 24, + "total_ms": 263.6886660000001, + "p95_ms": 11.852412999999999 + }, + "native_ui": { + "calls": 24, + "total_ms": 8.507168000000002, + "p95_ms": 0.578292 + } + }, + "driver_poll_ms": [ + 32.77914100908674, + 21.777815010864288, + 20.781995001016185, + 20.819558994844556, + 20.665283023845404, + 20.156081998720765, + 22.806435998063534, + 21.90437601529993, + 31.66972301551141, + 21.914773009484634, + 21.761015988886356, + 23.139064986025915, + 21.31938500679098, + 21.333946991944686, + 22.84502302063629, + 22.492772986879572, + 23.606723989360034, + 22.558219003258273, + 24.071978987194598, + 22.76115902350284, + 22.260210011154413, + 22.28630901663564, + 21.285545983118936, + 20.714484009658918 + ], + "revision_changed": false + }, + { + "mode": "target", + "stages": { + "native_frame": { + "calls": 24, + "total_ms": 324.836056, + "p95_ms": 16.426751 + }, + "native_prepare": { + "calls": 24, + "total_ms": 1.6915500000000003, + "p95_ms": 0.151843 + }, + "native_submit_present": { + "calls": 24, + "total_ms": 307.72189699999996, + "p95_ms": 15.46209 + }, + "native_ui": { + "calls": 24, + "total_ms": 8.527176999999995, + "p95_ms": 0.61067 + } + }, + "driver_poll_ms": [ + 23.153276997618377, + 23.433800000930205, + 22.472665004897863, + 22.479974024463445, + 22.08210900425911, + 22.220452985493466, + 24.962620984297246, + 24.259544996311888, + 24.663069983944297, + 23.580214998219162, + 24.80610998463817, + 27.006866002921015, + 24.192316981498152, + 24.050328007433563, + 23.462242010282353, + 23.205503006465733, + 22.540543985087425, + 22.551449976162985, + 23.068089998560026, + 23.265922005521134, + 22.369001002516598, + 22.292718989774585, + 25.477399001829326, + 22.080967988586053 + ], + "revision_changed": false + } + ] + }, + "regressions": { + "actual_native_typing": 4, + "existing_native_and_jupyter": 6, + "settings_history_validation_review": 240, + "workflow_and_asset_checks": 72, + "properties": "Exact per-character content, CJK paste, Unicode superscript structure, unchanged unrelated SSOT and source uploads, Undo/Redo, durable accepted text/comment recovery; no temporary views, axis rasters, style rebuilds or full-document string parsing while typing; unchanged drafts reuse navigation and property descriptions." + } +} diff --git a/docs/measurements/native-unselected-form-20261022.json b/docs/measurements/native-unselected-form-20261022.json new file mode 100644 index 0000000..ba9a461 --- /dev/null +++ b/docs/measurements/native-unselected-form-20261022.json @@ -0,0 +1,20 @@ +{ + "run_url": "https://github.com/shim9610/figgy-python/actions/runs/39762726735", + "failed_job_id": 119773577339, + "failed_test": "tests/test_radial_desktop.py::test_radial_native_edit_copy_selection_and_no_readback", + "before": { + "layout_mode": false, + "selected": null, + "title_input": false, + "plot_width_input": true + }, + "after": { + "layout_mode": false, + "selected": null, + "title_input": true, + "plot_width_input": true + }, + "mode_switch_complete_ssot_unchanged": true, + "local_ui_cases_passed": 12, + "previously_unexecuted_cases_passed": 48 +} diff --git a/docs/measurements/public-appearance-api-20261028.json b/docs/measurements/public-appearance-api-20261028.json new file mode 100644 index 0000000..a1ec0ed --- /dev/null +++ b/docs/measurements/public-appearance-api-20261028.json @@ -0,0 +1,310 @@ +{ + "date": "2026-10-28", + "renderer": "0.18.1", + "model": "0.12.1", + "environment": "Installed CPython 3.12 wheel, NumPy 2.5.4, Vulkan llvmpipe LLVM 19.1.7. Actual X11 and Jupyter hosts.", + "scope": "Named public visual controls, not a complete Matplotlib replacement. Full internal identity/editor records remain in exact recovery.", + "coverage": [ + "All 26 marker shapes via public symbols/fillstyle", + "Marker/error palettes and ordered sparse overrides; bin overrides", + "Resident color/size/matrix references", + "Vector appearance, scaling and color maps", + "Contour levels, colors and labels with explicit anchors", + "Polar projections and independent secondary axes", + "Angle/Unix timestamp formatters", + "Mapped/standalone colorbar placement and axis styles", + "Per-series and custom rich legends", + "Every native bounded global style leaf", + "Bounded series/cell/slice/detail styles and decoration layout", + "All annotation geometries, labels, frames and mixed coordinates" + ], + "validation": { + "public_appearance_cases": 131, + "matplotlib_api_cases": 86, + "installed_focused_passed": 948, + "installed_focused_ui_skips": 5, + "clean_preflight_passed": 135, + "native_jupyter_clipboard_and_typing_passed": 9, + "full_ci_collection": 1633, + "relaxed_threshold_or_recovery_oracles": 0, + "numpy_only_runtime_without_matplotlib": true, + "paired_wheel_source_files": 41, + "final_public_and_matplotlib_passed": 217, + "additional_fillstyle_cases": 2, + "ci_failure_ui_reproductions_passed": 3 + }, + "independent_checks": [ + "Changed destination palettes/maps/vector/bin/error/overlay styles", + "Remove contour labels, polar and standalone scale absent from source", + "Line-fill ink independent from marker ink", + "Every panel must contain expected red/blue data ink before and after public/exact replay", + "Preserve an actual editor data selection on a fresh destination", + "Restore linear/log colorbar and plot scales across different initial scales", + "Restore wide log spacing and formatting without dependence on the old scale", + "Single-slot panel geometry and per-entry rich alignment" + ], + "backend_calibration": { + "native_binary_sha256": "28b342055240f95649487486bfa6f9738e55899606370f41f5a288884c7d402d", + "baseline_and_current_native_binary_identical": true, + "gl": { + "adapter": "llvmpipe LLVM 19.1.7", + "opaque_pixels": 282240, + "expected": 576000, + "top_sample_rgba": [ + 0, + 0, + 0, + 0 + ], + "baseline_reproduces": true + }, + "vulkan": { + "opaque_pixels": 576000, + "all_frozen_pngs_identical": true + }, + "conclusion": "The software OpenGL path omits the first panel on both baseline and current wheels. Vulkan is the controlled CI/reference backend; no renderer workaround or threshold relaxation added.", + "reproduce": [ + "import figgyplot as fg, numpy as np", + "with fg.Figure(grid=[['top'], ['bottom']], backend='native') as fig:", + " fig.subplots['top'].axes.plot([0, 1], [1, 2])", + " fig.subplots['bottom'].axes.plot([0, 1], [2, 1])", + " image = fig.render_rgba()", + " print(fig.info['adapter'], np.count_nonzero(image[..., 3]), image[100, 100])" + ], + "environment_control": "Run once with WGPU_BACKEND=gl and once with WGPU_BACKEND=vulkan, selecting the matching software ICD." + }, + "frozen_scenes": [ + { + "domain": "acoustics", + "scene": "figures/01_class_profiles.png", + "png_sha256": "fd0e04ec76af917ee8546203cc6ebeb2725e31b7d743f4d994de4bd52ea616fb", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 96 + }, + { + "domain": "acoustics", + "scene": "figures/02_record_heatmaps.png", + "png_sha256": "1436240cbe69eebc158a2f15f6dff5ed2d066ee4ceeebf64388d395f9e85a180", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 87 + }, + { + "domain": "acoustics", + "scene": "figures/03_pca_overlap.png", + "png_sha256": "d0968198529e0f58fdc51af15f0bce9db6f47e24316a13410749f838ae7365b0", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 58 + }, + { + "domain": "acoustics", + "scene": "figures/04_record_mean_distribution.png", + "png_sha256": "490bc986defb4cf3baa83bb1d372ec8229953aaad9a4bf3be02f978d1b883e0c", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 13 + }, + { + "domain": "education", + "scene": "plots/01_score_distribution.png", + "png_sha256": "b0b87a884ed941c95f7d08d493f870dd03244113f9aaa8974aa0ecf85448da41", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 33 + }, + { + "domain": "education", + "scene": "plots/02_lunch_and_score.png", + "png_sha256": "b798285774553f3695a430b5c83490c9a72e993b8f0c0d26e77ad4151d3ff4cf", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 39 + }, + { + "domain": "education", + "scene": "plots/03_subject_gap_by_grade.png", + "png_sha256": "96ae7994aabbeee933a62a261df186a4e776ff80c27b33b8c186958e38e7da15", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 13 + }, + { + "domain": "energy", + "scene": "plots/01_consumption_history.png", + "png_sha256": "4fd0c33726acaa228239621234994869f0fd2b2ee44f7a9b53aa13596df3b4bb", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 30 + }, + { + "domain": "energy", + "scene": "plots/02_relative_seasonality.png", + "png_sha256": "747937dcc534ec7e2ddfd5ff25fd50a45f4e95852ca376ce6d06cb9b1ddeffa1", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 16 + }, + { + "domain": "energy", + "scene": "plots/03_level_and_within_year_range.png", + "png_sha256": "b1a780285c3c0d532a20e32ed44f822321e402c484201c402ec5e33c922d8383", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 58 + }, + { + "domain": "materials", + "scene": "artifacts/01_strength_distribution.png", + "png_sha256": "e2cf988e0bb3f3194cfff385b6c503d56ec22be22a0c042a60eb4f94a1cd9c96", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 38 + }, + { + "domain": "materials", + "scene": "artifacts/02_strength_age_bands.png", + "png_sha256": "c5fabc8529c1fb021497c4ffcc3e433e17a038f8e2a1db08fbb4c3b38c8f1913", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 15 + }, + { + "domain": "materials", + "scene": "artifacts/03_cement_strength.png", + "png_sha256": "8f9e7da7b1410e7d47bc8c35a8ab3b9566d151d2aaea17e7689b78ed9e1fd3ce", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 51 + }, + { + "domain": "materials", + "scene": "artifacts/04_exact_age_summaries.png", + "png_sha256": "e81e03bc41eb8dbe7e17868e019dc0c960b5f461b9087e8075e52da697afed4f", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 39 + }, + { + "domain": "nutrition", + "scene": "plots/01_calorie_distributions.png", + "png_sha256": "f52dddb023f0433878fd9515f0aa8ff53555759db473389f297d21ffce9cd665", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 21 + }, + { + "domain": "nutrition", + "scene": "plots/02_calorie_bands.png", + "png_sha256": "c723ec2e8438e3bb11d4451611510f8a122e1eb1e4396ef8e34476f9cf357b99", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 23 + }, + { + "domain": "nutrition", + "scene": "plots/03_calories_sodium.png", + "png_sha256": "69c41ef86203c8aaa1db289d4bc3c1d6faac8646cbd258544ba1ba1b12310447", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 92 + }, + { + "domain": "sports", + "scene": "plots/01_salary_vs_hits.png", + "png_sha256": "4b9a8f8656209c1b66f10d00a133217e29750d0bcb3cb2239df0de9b1e2cf83f", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 43 + }, + { + "domain": "sports", + "scene": "plots/02_salary_by_experience.png", + "png_sha256": "d8f6d2f2c359a21382166fa027635079a6de4514e47dd083d930407f9c81ae8c", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 16 + }, + { + "domain": "sports", + "scene": "plots/03_salary_coverage.png", + "png_sha256": "bd743f0216200cca9d95e979edcdb9fcd51575672b27c88877f9935468c3cd84", + "frozen_png_identical": true, + "api_settings_series_pixels_equal": true, + "exact_full_ssot_pixels_equal": true, + "no_source_uploads": true, + "raw_settings_calls": 0, + "api_lines": 16 + } + ], + "ci78_followup": { + "failures": [ + "Native heatmap test asserted the renderer enum spelling in public Python code", + "Fresh linear destination received LogLocator before its missing log-scale setter" + ], + "changes": [ + "Replace spelling assertion with full accepted config/series comparison, no-reupload check and RGBA equality, after retaining the zero-image-transport UI assertion", + "Always emit the colorbar scale and normalize plot scaling before restoring bounds/formatting", + "Preserve explicit picked_points/picked_data diagnostics that the source-based compiler previously considered unchanged" + ], + "local_reproduction": "Both failed UI scenarios and native/Jupyter clipboard replay passed on the updated installed wheel" + } +} diff --git a/docs/measurements/review-images-20261023.json b/docs/measurements/review-images-20261023.json new file mode 100644 index 0000000..5b18346 --- /dev/null +++ b/docs/measurements/review-images-20261023.json @@ -0,0 +1,43 @@ +{ + "date": "2026-10-23", + "scope": "Review image attachment UI, durable comment references and agent feedback", + "wheel_sha256": "b828139efff096dec27b82de096bc0729baa5cfab7375564f916643d547fbead", + "runtime": { + "python": "3.12.14", + "native": "Linux X11; Mesa lavapipe Vulkan", + "browser": "Chromium 154.0.8037.57; SwiftShader WebGPU" + }, + "tests": { + "review_images_storage_summary_lifecycle_and_existing_review": 89, + "native_and_browser_images_existing_CJK_comments_and_typing": 12, + "total_passed": 101 + }, + "verified": [ + "PNG, JPEG and WebP normalize to separately stored PNG references", + "JPEG EXIF orientation agrees with the reference preview", + "Native line and donut: real X11 clipboard paste, preview, removal and image-only element-comment submission", + "Jupyter: file input and real browser clipboard paste, preview, removal and image-only element-comment submission", + "Jupyter context menu does not overlap the review dialog", + "Figure-level and stable-target image references appear in summary and detail with absolute file paths, without inline image data", + "Chart Settings and source arrays remain separate from reference images", + "Draft/review recovery detects corrupted image files before cache cleanup", + "Failed source regeneration retains images; committed successors collect unreferenced images", + "Windows sharing-denial cleanup retry exercised by injected PermissionError", + "Eight native typing cases cover inline/inspector/general-review/element-review fields with and without an attachment" + ], + "checks": [ + "cargo check --lib", + "cargo fmt --check", + "JavaScript module syntax", + "packaged wheel/source/schema/skill equality", + "installed package gate", + "upstream asset provenance", + "editor-control inventory", + "native/browser paired-change gate" + ], + "not_exercised": [ + "Windows and macOS native GUI", + "Operating-system native file-picker automation", + "Browser OS file drag-and-drop automation" + ] +} diff --git a/docs/measurements/review-popup-occlusion-20261023.json b/docs/measurements/review-popup-occlusion-20261023.json new file mode 100644 index 0000000..713da59 --- /dev/null +++ b/docs/measurements/review-popup-occlusion-20261023.json @@ -0,0 +1,118 @@ +{ + "date": "2026-10-23", + "host": "Linux X11, Vulkan lavapipe", + "cause": "Native host appended panel focus outline after egui popup shapes; outline painted over opaque dialog backgrounds and text inputs.", + "fix": "Insert chart-stage focus outline before egui UI shapes.", + "regression": "tests/test_review_popup_desktop.py::test_review_popup_occludes_chart_focus_outline", + "old_wheel_result": "failed: focus outline visible inside popup white padding", + "old_pixels": [ + [ + [ + 38, + 99, + 173 + ], + [ + 255, + 255, + 255 + ], + [ + 255, + 255, + 255 + ] + ], + [ + [ + 38, + 99, + 173 + ], + [ + 255, + 255, + 255 + ], + [ + 255, + 255, + 255 + ] + ], + [ + [ + 38, + 99, + 173 + ], + [ + 255, + 255, + 255 + ], + [ + 255, + 255, + 255 + ] + ] + ], + "fixed_wheel_result": "passed; white popup padding occludes outline; outline remains visible outside popup", + "fixed_pixels": [ + [ + [ + 255, + 255, + 255 + ], + [ + 255, + 255, + 255 + ], + [ + 255, + 255, + 255 + ] + ], + [ + [ + 255, + 255, + 255 + ], + [ + 255, + 255, + 255 + ], + [ + 255, + 255, + 255 + ] + ], + [ + [ + 255, + 255, + 255 + ], + [ + 255, + 255, + 255 + ], + [ + 255, + 255, + 255 + ] + ] + ], + "related_validation": "9 passed (popup regression + 8 native typing/image modes), then strengthened popup visibility check passed", + "wheel_sha256": "241bb82c2527159b2720380ea8e350ef0ec9ba6abbcf280328f5da994f17f5c6", + "ci_run": false +} diff --git a/docs/measurements/review-summary-20261022.json b/docs/measurements/review-summary-20261022.json new file mode 100644 index 0000000..3b05a18 --- /dev/null +++ b/docs/measurements/review-summary-20261022.json @@ -0,0 +1,168 @@ +{ + "base_commit": "c36378ac2ed7b070d28ddbac3f6a0c44841aa0d8", + "wheel_sha256": "7299dc1fd69ff4f572646f78bd60ffc7cfb1694d76d5a6e1bd742f25c6c388e6", + "installed_package_files": 35, + "raw_bytes": 42610, + "summary_bytes": 1794, + "summary": { + "protocol": 1, + "view": "summary", + "session_id": "6429e10bb10e49d2afc125a1f55f8360", + "round_id": "e0b24f7d055543df8c50c8151aedecba", + "work_id": "320876d2241c4e8db372210e4ff98666", + "intent": "revise", + "source": { + "arguments": {}, + "cwd": "/workspace/figgy-python", + "function": "build" + }, + "figures": [ + { + "id": "donut", + "disposition": "request_changes", + "intent": "revise", + "change_count": 0, + "comment_count": 1, + "data_changed": false, + "comments": [ + { + "channel": "ui", + "id": "deef0762a9d64405aec32c19479fff96", + "target": null, + "text": "두 번째 조각의 색을 더 연하게 해주세요." + } + ], + "objects": [], + "details": { + "figure_id": "donut" + } + }, + { + "id": "line", + "disposition": "accept", + "intent": "revise", + "change_count": 6, + "comment_count": 0, + "data_changed": false, + "comments": [], + "objects": [ + { + "target": "panel/main/chart_title", + "label": "Chart title", + "label_truncated": false, + "scope": "element", + "change_count": 6, + "properties": [ + { + "path": "/text/segments", + "kind": "rich_text", + "before_exists": true, + "after_exists": true, + "before": { + "value": "Before", + "truncated": false + }, + "after": { + "value": "AFTER!", + "truncated": false + }, + "operation_ids": [ + "5e6e71c18ab2c366d8eef59e5aa2b2e7c83c86b001ddd11be69d9a066674ddf9", + "8bf2a7cb35307d37b3c1138ca4bbb15861c60312d69897792c8ea59f40ed6440", + "27769533d82d9bccd1e07c67893b0526b9990bdcd52c9ffb5f96c770e630cecd", + "a5d21f054434d4df6993123c8b9d1af54443d2bbcb00f7b8ca4609505a52684e", + "74d73ba2b00ecd5d0866cfba08d7fca00798251fb1a62c8f6a26aafb5f08c071", + "8de4850b699524120bb96f50e4015940272b48365d929d69b15243f5b365b145" + ], + "text_changed": true, + "formatting_changed": false + } + ], + "comments": [], + "details": { + "figure_id": "line", + "target": "panel/main/chart_title" + } + } + ], + "details": { + "figure_id": "line" + } + } + ], + "counts": { + "figures": 2, + "objects": 1, + "changes": 6, + "comments": 1 + } + }, + "exact_changes": [ + { + "after": "A", + "after_exists": true, + "before": "B", + "before_exists": true, + "id": "5e6e71c18ab2c366d8eef59e5aa2b2e7c83c86b001ddd11be69d9a066674ddf9", + "object_id": "figure/line", + "op": "replace", + "path": "/config/chart_title/text/segments/0/text", + "selectors": {} + }, + { + "after": "F", + "after_exists": true, + "before": "e", + "before_exists": true, + "id": "8bf2a7cb35307d37b3c1138ca4bbb15861c60312d69897792c8ea59f40ed6440", + "object_id": "figure/line", + "op": "replace", + "path": "/config/chart_title/text/segments/1/text", + "selectors": {} + }, + { + "after": "T", + "after_exists": true, + "before": "f", + "before_exists": true, + "id": "27769533d82d9bccd1e07c67893b0526b9990bdcd52c9ffb5f96c770e630cecd", + "object_id": "figure/line", + "op": "replace", + "path": "/config/chart_title/text/segments/2/text", + "selectors": {} + }, + { + "after": "E", + "after_exists": true, + "before": "o", + "before_exists": true, + "id": "a5d21f054434d4df6993123c8b9d1af54443d2bbcb00f7b8ca4609505a52684e", + "object_id": "figure/line", + "op": "replace", + "path": "/config/chart_title/text/segments/3/text", + "selectors": {} + }, + { + "after": "R", + "after_exists": true, + "before": "r", + "before_exists": true, + "id": "74d73ba2b00ecd5d0866cfba08d7fca00798251fb1a62c8f6a26aafb5f08c071", + "object_id": "figure/line", + "op": "replace", + "path": "/config/chart_title/text/segments/4/text", + "selectors": {} + }, + { + "after": "!", + "after_exists": true, + "before": "e", + "before_exists": true, + "id": "8de4850b699524120bb96f50e4015940272b48365d929d69b15243f5b365b145", + "object_id": "figure/line", + "op": "replace", + "path": "/config/chart_title/text/segments/5/text", + "selectors": {} + } + ] +} diff --git a/docs/measurements/style-preset-audit-20261026.json b/docs/measurements/style-preset-audit-20261026.json new file mode 100644 index 0000000..26638c8 --- /dev/null +++ b/docs/measurements/style-preset-audit-20261026.json @@ -0,0 +1,255 @@ +{ + "checked_on": "2026-10-26", + "source_sha256": "b0c107e930d5d65d3fca76547f4ddce3133eb4561a3b05f19a751eca80c090c3", + "method": "WCAG relative sRGB luminance; alpha composite each palette color against its preset solid background. Threshold comparisons use unrounded ratios.", + "scope": "Standalone essential thin marks against the background; not a claim that every complete chart fails or passes WCAG. Does not test CVD distinguishability, adjacency between series, or text accessibility.", + "source": "https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html", + "presets": { + "paper": { + "base_font_pt": 11, + "tick_directions": { + "bottom_x": "Outside", + "top_x": "Outside", + "left_y": "Outside", + "right_y": "Outside" + }, + "palette": [ + { + "color": "#0072B2", + "contrast_to_background": 5.185446345779855, + "at_least_3_to_1": true + }, + { + "color": "#E69F00", + "contrast_to_background": 2.252287600800611, + "at_least_3_to_1": false + }, + { + "color": "#009E73", + "contrast_to_background": 3.421139939945999, + "at_least_3_to_1": true + }, + { + "color": "#CC79A7", + "contrast_to_background": 3.06103403764584, + "at_least_3_to_1": true + }, + { + "color": "#D55E00", + "contrast_to_background": 3.8671837898669135, + "at_least_3_to_1": true + }, + { + "color": "#56B4E9", + "contrast_to_background": 2.3074822416402463, + "at_least_3_to_1": false + }, + { + "color": "#F0E442", + "contrast_to_background": 1.3223279112195188, + "at_least_3_to_1": false + } + ] + }, + "report": { + "base_font_pt": 11, + "tick_directions": { + "bottom_x": "Outside", + "top_x": "Outside", + "left_y": "Outside", + "right_y": "Outside" + }, + "palette": [ + { + "color": "#0072B2", + "contrast_to_background": 5.185446345779855, + "at_least_3_to_1": true + }, + { + "color": "#E69F00", + "contrast_to_background": 2.252287600800611, + "at_least_3_to_1": false + }, + { + "color": "#009E73", + "contrast_to_background": 3.421139939945999, + "at_least_3_to_1": true + }, + { + "color": "#CC79A7", + "contrast_to_background": 3.06103403764584, + "at_least_3_to_1": true + }, + { + "color": "#D55E00", + "contrast_to_background": 3.8671837898669135, + "at_least_3_to_1": true + }, + { + "color": "#56B4E9", + "contrast_to_background": 2.3074822416402463, + "at_least_3_to_1": false + }, + { + "color": "#F0E442", + "contrast_to_background": 1.3223279112195188, + "at_least_3_to_1": false + } + ] + }, + "presentation": { + "base_font_pt": 16, + "tick_directions": { + "bottom_x": "Outside", + "top_x": "Outside", + "left_y": "Outside", + "right_y": "Outside" + }, + "palette": [ + { + "color": "#0072B2", + "contrast_to_background": 5.185446345779855, + "at_least_3_to_1": true + }, + { + "color": "#E69F00", + "contrast_to_background": 2.252287600800611, + "at_least_3_to_1": false + }, + { + "color": "#009E73", + "contrast_to_background": 3.421139939945999, + "at_least_3_to_1": true + }, + { + "color": "#CC79A7", + "contrast_to_background": 3.06103403764584, + "at_least_3_to_1": true + }, + { + "color": "#D55E00", + "contrast_to_background": 3.8671837898669135, + "at_least_3_to_1": true + }, + { + "color": "#56B4E9", + "contrast_to_background": 2.3074822416402463, + "at_least_3_to_1": false + }, + { + "color": "#F0E442", + "contrast_to_background": 1.3223279112195188, + "at_least_3_to_1": false + } + ] + }, + "dark": { + "base_font_pt": 11, + "tick_directions": { + "bottom_x": "Outside", + "top_x": "Outside", + "left_y": "Outside", + "right_y": "Outside" + }, + "palette": [ + { + "color": "#56B4E9", + "contrast_to_background": 7.46777632564844, + "at_least_3_to_1": true + }, + { + "color": "#F0B45A", + "contrast_to_background": 9.338952678722267, + "at_least_3_to_1": true + }, + { + "color": "#5AD1A6", + "contrast_to_background": 9.112772655562065, + "at_least_3_to_1": true + }, + { + "color": "#DF95CB", + "contrast_to_background": 7.630332916738434, + "at_least_3_to_1": true + }, + { + "color": "#FF9072", + "contrast_to_background": 7.782425104682086, + "at_least_3_to_1": true + } + ] + }, + "monochrome": { + "base_font_pt": 11, + "tick_directions": { + "bottom_x": "Outside", + "top_x": "Outside", + "left_y": "Outside", + "right_y": "Outside" + }, + "palette": [ + { + "color": "#161616", + "contrast_to_background": 18.096211979655724, + "at_least_3_to_1": true + }, + { + "color": "#666666", + "contrast_to_background": 5.741836481454148, + "at_least_3_to_1": true + }, + { + "color": "#A0A0A0", + "contrast_to_background": 2.6149807046958116, + "at_least_3_to_1": false + } + ] + }, + "colorblind": { + "base_font_pt": 11, + "tick_directions": { + "bottom_x": "Outside", + "top_x": "Outside", + "left_y": "Outside", + "right_y": "Outside" + }, + "palette": [ + { + "color": "#0072B2", + "contrast_to_background": 5.185446345779855, + "at_least_3_to_1": true + }, + { + "color": "#E69F00", + "contrast_to_background": 2.252287600800611, + "at_least_3_to_1": false + }, + { + "color": "#009E73", + "contrast_to_background": 3.421139939945999, + "at_least_3_to_1": true + }, + { + "color": "#CC79A7", + "contrast_to_background": 3.06103403764584, + "at_least_3_to_1": true + }, + { + "color": "#D55E00", + "contrast_to_background": 3.8671837898669135, + "at_least_3_to_1": true + }, + { + "color": "#56B4E9", + "contrast_to_background": 2.3074822416402463, + "at_least_3_to_1": false + }, + { + "color": "#F0E442", + "contrast_to_background": 1.3223279112195188, + "at_least_3_to_1": false + } + ] + } + } +} diff --git a/docs/measurements/style-profiles-20261026.json b/docs/measurements/style-profiles-20261026.json new file mode 100644 index 0000000..d787ccb --- /dev/null +++ b/docs/measurements/style-profiles-20261026.json @@ -0,0 +1,724 @@ +{ + "publication.nature": { + "purpose": "Small final-size scientific figures", + "font": 6.5, + "title": 7, + "print": true, + "tick": "Inside", + "widths_mm": [ + 89, + 183 + ], + "name": "publication.nature", + "checked": "2026-10-26", + "sources": [ + "https://www.nature.com/nature/for-authors/final-submission", + "https://research-figure-guide.nature.com/figures/preparing-figures-our-specifications/", + "https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html", + "https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html" + ], + "verified_guidance": [ + "89/183 mm typical final widths; ordinary lettering 5\u20137 pt; editable vector/font handling required." + ], + "default_theme": "light", + "tick_provenance": "product design choice; not a journal requirement", + "category_capacity": 24, + "limits": [ + "Appearance only; no thresholds, units, scale domains or text inferred.", + "PNG export does not meet editable vector/font submission requirements.", + "Default host font must be checked or explicitly replaced; no bundled journal font guarantee.", + "Color contrast audit is not whole-chart accessibility or CVD certification.", + "Mobile navigation, reflow and wallboard viewing distance belong to the host." + ], + "image": "publication.nature.png", + "dpi": 300.0, + "size_px": [ + 1051, + 732 + ], + "category_contrast": [ + 6.028555280407447, + 5.97268826571777, + 7.047938004175022, + 4.971984258030481, + 5.24180584476409, + 5.962063909275079 + ], + "semantic_contrast": { + "focus": 6.028555280407447, + "context": 5.962063909275079, + "reference": 5.962063909275079, + "warning": 5.24180584476409, + "critical": 6.507329548273491, + "positive": 4.971984258030481 + }, + "inherited_after_scope": true, + "renderer": "Figgy native" + }, + "publication.ieee-proceedings": { + "purpose": "Engineering figures for Proceedings of the IEEE", + "font": 9, + "title": 10, + "print": true, + "tick": "Inside", + "widths_mm": [ + 88.9, + 182 + ], + "mono": true, + "name": "publication.ieee-proceedings", + "checked": "2026-10-26", + "sources": [ + "https://proceedingsoftheieee.ieee.org/resources/guidelines-for-figures-and-tables/", + "https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html", + "https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html" + ], + "verified_guidance": [ + "Proceedings of the IEEE recommends approximately 9\u201310 pt lettering." + ], + "default_theme": "light", + "tick_provenance": "product design choice; not a journal requirement", + "category_capacity": 12, + "limits": [ + "Appearance only; no thresholds, units, scale domains or text inferred.", + "PNG export does not meet editable vector/font submission requirements.", + "Default host font must be checked or explicitly replaced; no bundled journal font guarantee.", + "Color contrast audit is not whole-chart accessibility or CVD certification.", + "Mobile navigation, reflow and wallboard viewing distance belong to the host." + ], + "image": "publication.ieee-proceedings.png", + "dpi": 300.0, + "size_px": [ + 1050, + 768 + ], + "category_contrast": [ + 17.9278402530276, + 8.063288656552828, + 4.542224959605253 + ], + "semantic_contrast": { + "focus": 17.9278402530276, + "context": 5.962063909275079, + "reference": 5.962063909275079, + "warning": 5.24180584476409, + "critical": 6.507329548273491, + "positive": 4.971984258030481 + }, + "inherited_after_scope": true, + "renderer": "Figgy native" + }, + "publication.kfas": { + "purpose": "Four-sided scientific frame with inward ticks", + "font": 9, + "title": 10, + "print": true, + "tick": "Inside", + "name": "publication.kfas", + "checked": "2026-10-26", + "sources": [ + "https://www.e-kfas.org/intro/instructions.asp", + "https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html", + "https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html" + ], + "verified_guidance": [ + "Four-sided frames and inward ticks are specified; this profile's 9 pt size is a product choice." + ], + "default_theme": "light", + "tick_provenance": "journal requirement", + "category_capacity": 24, + "limits": [ + "Appearance only; no thresholds, units, scale domains or text inferred.", + "PNG export does not meet editable vector/font submission requirements.", + "Default host font must be checked or explicitly replaced; no bundled journal font guarantee.", + "Color contrast audit is not whole-chart accessibility or CVD certification.", + "Mobile navigation, reflow and wallboard viewing distance belong to the host." + ], + "image": "publication.kfas.png", + "dpi": 300.0, + "size_px": [ + 1050, + 768 + ], + "category_contrast": [ + 6.028555280407447, + 5.97268826571777, + 7.047938004175022, + 4.971984258030481, + 5.24180584476409, + 5.962063909275079 + ], + "semantic_contrast": { + "focus": 6.028555280407447, + "context": 5.962063909275079, + "reference": 5.962063909275079, + "warning": 5.24180584476409, + "critical": 6.507329548273491, + "positive": 4.971984258030481 + }, + "inherited_after_scope": true, + "renderer": "Figgy native" + }, + "scientific.precision": { + "purpose": "Dense quantitative signals with a full measurement frame", + "font": 10, + "title": 12, + "tick": "Inside", + "grid": "both", + "name": "scientific.precision", + "checked": "2026-10-26", + "sources": [ + "https://learn.microsoft.com/en-us/power-bi/create-reports/service-dashboards-design-tips", + "https://www.carbondesignsystem.com/building-blocks/data-visualization/color-palettes", + "https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html", + "https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html" + ], + "verified_guidance": [ + "Task taxonomy, typography, spacing and default colors are product choices informed by the cited guidance." + ], + "default_theme": "light", + "tick_provenance": "product design choice; not a journal requirement", + "category_capacity": 24, + "limits": [ + "Appearance only; no thresholds, units, scale domains or text inferred.", + "PNG export does not meet editable vector/font submission requirements.", + "Default host font must be checked or explicitly replaced; no bundled journal font guarantee.", + "Color contrast audit is not whole-chart accessibility or CVD certification.", + "Mobile navigation, reflow and wallboard viewing distance belong to the host." + ], + "image": "scientific.precision.png", + "dpi": 100.0, + "size_px": [ + 820, + 460 + ], + "category_contrast": [ + 6.028555280407447, + 5.97268826571777, + 7.047938004175022, + 4.971984258030481, + 5.24180584476409, + 5.962063909275079 + ], + "semantic_contrast": { + "focus": 6.028555280407447, + "context": 5.962063909275079, + "reference": 5.962063909275079, + "warning": 5.24180584476409, + "critical": 6.507329548273491, + "positive": 4.971984258030481 + }, + "inherited_after_scope": true, + "renderer": "Figgy native" + }, + "print.monochrome": { + "purpose": "Grayscale printing and photocopying", + "font": 9, + "title": 11, + "print": true, + "tick": "Inside", + "mono": true, + "name": "print.monochrome", + "checked": "2026-10-26", + "sources": [ + "https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html", + "https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html" + ], + "verified_guidance": [ + "Task taxonomy, typography, spacing and default colors are product choices informed by the cited guidance." + ], + "default_theme": "light", + "tick_provenance": "product design choice; not a journal requirement", + "category_capacity": 12, + "limits": [ + "Appearance only; no thresholds, units, scale domains or text inferred.", + "PNG export does not meet editable vector/font submission requirements.", + "Default host font must be checked or explicitly replaced; no bundled journal font guarantee.", + "Color contrast audit is not whole-chart accessibility or CVD certification.", + "Mobile navigation, reflow and wallboard viewing distance belong to the host." + ], + "image": "print.monochrome.png", + "dpi": 240.0, + "size_px": [ + 840, + 614 + ], + "category_contrast": [ + 17.9278402530276, + 8.063288656552828, + 4.542224959605253 + ], + "semantic_contrast": { + "focus": 17.9278402530276, + "context": 5.962063909275079, + "reference": 5.962063909275079, + "warning": 5.24180584476409, + "critical": 6.507329548273491, + "positive": 4.971984258030481 + }, + "inherited_after_scope": true, + "renderer": "Figgy native" + }, + "presentation.stage": { + "purpose": "Projected charts with few large marks", + "font": 17, + "title": 22, + "dark": true, + "thick": 2.6, + "name": "presentation.stage", + "checked": "2026-10-26", + "sources": [ + "https://learn.microsoft.com/en-us/power-bi/create-reports/service-dashboards-design-tips", + "https://www.carbondesignsystem.com/building-blocks/data-visualization/color-palettes", + "https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html", + "https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html" + ], + "verified_guidance": [ + "Task taxonomy, typography, spacing and default colors are product choices informed by the cited guidance." + ], + "default_theme": "dark", + "tick_provenance": "product design choice; not a journal requirement", + "category_capacity": 24, + "limits": [ + "Appearance only; no thresholds, units, scale domains or text inferred.", + "PNG export does not meet editable vector/font submission requirements.", + "Default host font must be checked or explicitly replaced; no bundled journal font guarantee.", + "Color contrast audit is not whole-chart accessibility or CVD certification.", + "Mobile navigation, reflow and wallboard viewing distance belong to the host." + ], + "image": "presentation.stage.png", + "dpi": 100.0, + "size_px": [ + 820, + 460 + ], + "category_contrast": [ + 9.157485336341699, + 10.878771269996422, + 8.355145728320768, + 9.97086861878996, + 7.141449431606433, + 10.678578853458895 + ], + "semantic_contrast": { + "focus": 9.157485336341699, + "context": 8.780227775442686, + "reference": 8.780227775442686, + "warning": 10.878771269996422, + "critical": 7.141449431606433, + "positive": 9.97086861878996 + }, + "inherited_after_scope": true, + "renderer": "Figgy native" + }, + "dashboard.executive": { + "purpose": "Sparse performance overview with comparison context", + "font": 12, + "title": 16, + "thick": 2, + "name": "dashboard.executive", + "checked": "2026-10-26", + "sources": [ + "https://learn.microsoft.com/en-us/power-bi/create-reports/service-dashboards-design-tips", + "https://www.carbondesignsystem.com/building-blocks/data-visualization/color-palettes", + "https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html", + "https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html" + ], + "verified_guidance": [ + "Task taxonomy, typography, spacing and default colors are product choices informed by the cited guidance." + ], + "default_theme": "light", + "tick_provenance": "product design choice; not a journal requirement", + "category_capacity": 24, + "limits": [ + "Appearance only; no thresholds, units, scale domains or text inferred.", + "PNG export does not meet editable vector/font submission requirements.", + "Default host font must be checked or explicitly replaced; no bundled journal font guarantee.", + "Color contrast audit is not whole-chart accessibility or CVD certification.", + "Mobile navigation, reflow and wallboard viewing distance belong to the host." + ], + "image": "dashboard.executive.png", + "dpi": 100.0, + "size_px": [ + 820, + 460 + ], + "category_contrast": [ + 6.028555280407447, + 5.97268826571777, + 7.047938004175022, + 4.971984258030481, + 5.24180584476409, + 5.962063909275079 + ], + "semantic_contrast": { + "focus": 6.028555280407447, + "context": 5.962063909275079, + "reference": 5.962063909275079, + "warning": 5.24180584476409, + "critical": 6.507329548273491, + "positive": 4.971984258030481 + }, + "inherited_after_scope": true, + "renderer": "Figgy native" + }, + "dashboard.operations": { + "purpose": "Operational trends with caller-supplied alert roles", + "font": 11, + "title": 14, + "dark": true, + "name": "dashboard.operations", + "checked": "2026-10-26", + "sources": [ + "https://grafana.com/docs/grafana/latest/visualizations/dashboards/build-dashboards/best-practices/", + "https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html", + "https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html" + ], + "verified_guidance": [ + "Task taxonomy, typography, spacing and default colors are product choices informed by the cited guidance." + ], + "default_theme": "dark", + "tick_provenance": "product design choice; not a journal requirement", + "category_capacity": 24, + "limits": [ + "Appearance only; no thresholds, units, scale domains or text inferred.", + "PNG export does not meet editable vector/font submission requirements.", + "Default host font must be checked or explicitly replaced; no bundled journal font guarantee.", + "Color contrast audit is not whole-chart accessibility or CVD certification.", + "Mobile navigation, reflow and wallboard viewing distance belong to the host." + ], + "image": "dashboard.operations.png", + "dpi": 100.0, + "size_px": [ + 1220, + 470 + ], + "category_contrast": [ + 9.157485336341699, + 8.355145728320768, + 9.97086861878996, + 10.678578853458895, + 10.044568253567032, + 9.736866244511031 + ], + "semantic_contrast": { + "focus": 9.157485336341699, + "context": 8.780227775442686, + "reference": 8.780227775442686, + "warning": 10.878771269996422, + "critical": 7.141449431606433, + "positive": 9.97086861878996 + }, + "inherited_after_scope": true, + "renderer": "Figgy native" + }, + "dashboard.analysis": { + "purpose": "Comparisons, uncertainty and focus versus context", + "font": 10, + "title": 13, + "grid": "both", + "name": "dashboard.analysis", + "checked": "2026-10-26", + "sources": [ + "https://learn.microsoft.com/en-us/power-bi/create-reports/service-dashboards-design-tips", + "https://www.carbondesignsystem.com/building-blocks/data-visualization/color-palettes", + "https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html", + "https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html" + ], + "verified_guidance": [ + "Task taxonomy, typography, spacing and default colors are product choices informed by the cited guidance." + ], + "default_theme": "light", + "tick_provenance": "product design choice; not a journal requirement", + "category_capacity": 24, + "limits": [ + "Appearance only; no thresholds, units, scale domains or text inferred.", + "PNG export does not meet editable vector/font submission requirements.", + "Default host font must be checked or explicitly replaced; no bundled journal font guarantee.", + "Color contrast audit is not whole-chart accessibility or CVD certification.", + "Mobile navigation, reflow and wallboard viewing distance belong to the host." + ], + "image": "dashboard.analysis.png", + "dpi": 100.0, + "size_px": [ + 1220, + 470 + ], + "category_contrast": [ + 6.028555280407447, + 5.97268826571777, + 7.047938004175022, + 4.971984258030481, + 5.24180584476409, + 5.962063909275079 + ], + "semantic_contrast": { + "focus": 6.028555280407447, + "context": 5.962063909275079, + "reference": 5.962063909275079, + "warning": 5.24180584476409, + "critical": 6.507329548273491, + "positive": 4.971984258030481 + }, + "inherited_after_scope": true, + "renderer": "Figgy native" + }, + "dashboard.instrument": { + "purpose": "Dense measurement traces, units and references", + "font": 10, + "title": 12, + "dark": true, + "tick": "Inside", + "grid": "both", + "name": "dashboard.instrument", + "checked": "2026-10-26", + "sources": [ + "https://learn.microsoft.com/en-us/power-bi/create-reports/service-dashboards-design-tips", + "https://www.carbondesignsystem.com/building-blocks/data-visualization/color-palettes", + "https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html", + "https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html" + ], + "verified_guidance": [ + "Task taxonomy, typography, spacing and default colors are product choices informed by the cited guidance." + ], + "default_theme": "dark", + "tick_provenance": "product design choice; not a journal requirement", + "category_capacity": 24, + "limits": [ + "Appearance only; no thresholds, units, scale domains or text inferred.", + "PNG export does not meet editable vector/font submission requirements.", + "Default host font must be checked or explicitly replaced; no bundled journal font guarantee.", + "Color contrast audit is not whole-chart accessibility or CVD certification.", + "Mobile navigation, reflow and wallboard viewing distance belong to the host." + ], + "image": "dashboard.instrument.png", + "dpi": 100.0, + "size_px": [ + 1220, + 470 + ], + "category_contrast": [ + 9.157485336341699, + 8.355145728320768, + 9.97086861878996, + 10.678578853458895, + 10.044568253567032, + 9.736866244511031 + ], + "semantic_contrast": { + "focus": 9.157485336341699, + "context": 8.780227775442686, + "reference": 8.780227775442686, + "warning": 10.878771269996422, + "critical": 7.141449431606433, + "positive": 9.97086861878996 + }, + "inherited_after_scope": true, + "renderer": "Figgy native" + }, + "dashboard.wallboard": { + "purpose": "Distant viewing with large labels and strokes", + "font": 17, + "title": 24, + "dark": true, + "thick": 2.8, + "name": "dashboard.wallboard", + "checked": "2026-10-26", + "sources": [ + "https://learn.microsoft.com/en-us/power-bi/create-reports/service-dashboards-design-tips", + "https://www.carbondesignsystem.com/building-blocks/data-visualization/color-palettes", + "https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html", + "https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html" + ], + "verified_guidance": [ + "Task taxonomy, typography, spacing and default colors are product choices informed by the cited guidance." + ], + "default_theme": "dark", + "tick_provenance": "product design choice; not a journal requirement", + "category_capacity": 24, + "limits": [ + "Appearance only; no thresholds, units, scale domains or text inferred.", + "PNG export does not meet editable vector/font submission requirements.", + "Default host font must be checked or explicitly replaced; no bundled journal font guarantee.", + "Color contrast audit is not whole-chart accessibility or CVD certification.", + "Mobile navigation, reflow and wallboard viewing distance belong to the host." + ], + "image": "dashboard.wallboard.png", + "dpi": 100.0, + "size_px": [ + 820, + 460 + ], + "category_contrast": [ + 9.157485336341699, + 8.355145728320768, + 9.97086861878996, + 10.678578853458895, + 10.044568253567032, + 9.736866244511031 + ], + "semantic_contrast": { + "focus": 9.157485336341699, + "context": 8.780227775442686, + "reference": 8.780227775442686, + "warning": 10.878771269996422, + "critical": 7.141449431606433, + "positive": 9.97086861878996 + }, + "inherited_after_scope": true, + "renderer": "Figgy native" + }, + "dashboard.public": { + "purpose": "Broad-audience charts with clear redundant encodings", + "font": 13, + "title": 17, + "thick": 2, + "name": "dashboard.public", + "checked": "2026-10-26", + "sources": [ + "https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html", + "https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html" + ], + "verified_guidance": [ + "Task taxonomy, typography, spacing and default colors are product choices informed by the cited guidance." + ], + "default_theme": "light", + "tick_provenance": "product design choice; not a journal requirement", + "category_capacity": 24, + "limits": [ + "Appearance only; no thresholds, units, scale domains or text inferred.", + "PNG export does not meet editable vector/font submission requirements.", + "Default host font must be checked or explicitly replaced; no bundled journal font guarantee.", + "Color contrast audit is not whole-chart accessibility or CVD certification.", + "Mobile navigation, reflow and wallboard viewing distance belong to the host." + ], + "image": "dashboard.public.png", + "dpi": 100.0, + "size_px": [ + 820, + 460 + ], + "category_contrast": [ + 6.028555280407447, + 5.97268826571777, + 7.047938004175022, + 4.971984258030481, + 5.24180584476409, + 5.962063909275079 + ], + "semantic_contrast": { + "focus": 6.028555280407447, + "context": 5.962063909275079, + "reference": 5.962063909275079, + "warning": 5.24180584476409, + "critical": 6.507329548273491, + "positive": 4.971984258030481 + }, + "inherited_after_scope": true, + "renderer": "Figgy native" + }, + "dashboard.mobile": { + "purpose": "Small-screen single-chart reading", + "font": 11, + "title": 14, + "thick": 1.8, + "name": "dashboard.mobile", + "checked": "2026-10-26", + "sources": [ + "https://learn.microsoft.com/en-us/power-bi/create-reports/service-dashboards-design-tips", + "https://www.carbondesignsystem.com/building-blocks/data-visualization/color-palettes", + "https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html", + "https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html" + ], + "verified_guidance": [ + "Task taxonomy, typography, spacing and default colors are product choices informed by the cited guidance." + ], + "default_theme": "light", + "tick_provenance": "product design choice; not a journal requirement", + "category_capacity": 24, + "limits": [ + "Appearance only; no thresholds, units, scale domains or text inferred.", + "PNG export does not meet editable vector/font submission requirements.", + "Default host font must be checked or explicitly replaced; no bundled journal font guarantee.", + "Color contrast audit is not whole-chart accessibility or CVD certification.", + "Mobile navigation, reflow and wallboard viewing distance belong to the host." + ], + "image": "dashboard.mobile.png", + "dpi": 100.0, + "size_px": [ + 410, + 520 + ], + "category_contrast": [ + 6.028555280407447, + 5.97268826571777, + 7.047938004175022, + 4.971984258030481, + 5.24180584476409, + 5.962063909275079 + ], + "semantic_contrast": { + "focus": 6.028555280407447, + "context": 5.962063909275079, + "reference": 5.962063909275079, + "warning": 5.24180584476409, + "critical": 6.507329548273491, + "positive": 4.971984258030481 + }, + "inherited_after_scope": true, + "renderer": "Figgy native" + }, + "education.explainer": { + "purpose": "Teaching with a clear frame, labels and emphasis", + "font": 12, + "title": 16, + "tick": "Outside", + "grid": "both", + "thick": 1.8, + "name": "education.explainer", + "checked": "2026-10-26", + "sources": [ + "https://learn.microsoft.com/en-us/power-bi/create-reports/service-dashboards-design-tips", + "https://www.carbondesignsystem.com/building-blocks/data-visualization/color-palettes", + "https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html", + "https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html" + ], + "verified_guidance": [ + "Task taxonomy, typography, spacing and default colors are product choices informed by the cited guidance." + ], + "default_theme": "light", + "tick_provenance": "product design choice; not a journal requirement", + "category_capacity": 24, + "limits": [ + "Appearance only; no thresholds, units, scale domains or text inferred.", + "PNG export does not meet editable vector/font submission requirements.", + "Default host font must be checked or explicitly replaced; no bundled journal font guarantee.", + "Color contrast audit is not whole-chart accessibility or CVD certification.", + "Mobile navigation, reflow and wallboard viewing distance belong to the host." + ], + "image": "education.explainer.png", + "dpi": 100.0, + "size_px": [ + 820, + 460 + ], + "category_contrast": [ + 6.028555280407447, + 5.97268826571777, + 7.047938004175022, + 4.971984258030481, + 5.24180584476409, + 5.962063909275079 + ], + "semantic_contrast": { + "focus": 6.028555280407447, + "context": 5.962063909275079, + "reference": 5.962063909275079, + "warning": 5.24180584476409, + "critical": 6.507329548273491, + "positive": 4.971984258030481 + }, + "inherited_after_scope": true, + "renderer": "Figgy native" + } +} diff --git a/docs/measurements/wrapper-edit-paths-20261028.json b/docs/measurements/wrapper-edit-paths-20261028.json new file mode 100644 index 0000000..aaab75e --- /dev/null +++ b/docs/measurements/wrapper-edit-paths-20261028.json @@ -0,0 +1,151 @@ +{ + "baseline_commit": "d3ec3dc785fdecc7eb3c69fab7bd8f71e3f3bafd", + "scope": "Python wrapper edits: ticks, shared color scales, series ownership, snapshot transactions, linked-axis traversal and grid placement", + "changes": [ + "Plot/overlay/colorbar ticks use one node translator; ordinary edits no longer construct a code-generation Figure.", + "Tick arguments are converted once per operation and mutable colors remain independent between axes.", + "Colorbar text layout runs before the one snapshot commit, without a nested batch and second edit.", + "Constructors, resident color bindings and artist/scale edits share complete-domain validation and label fitting; palette-only updates skip geometry fitting.", + "Panel ownership resolution is shared by Figure.update, colorbar lookup and marker/vector color binding; no snapshot-per-axis search.", + "One detached copy is retained at apply_settings boundaries; redundant reset/staging copies and duplicate reads are removed while isolation and nested rollback remain.", + "Linked groups use linear membership traversal; grid weight sums and panel lookup are computed once per placement.", + "Numeric argument validation no longer depends on the plotting-object module.", + "Colorbar grid options now raise before committing instead of silently discarding the request." + ], + "validation": { + "new_regression_cases": 29, + "installed_wheel_targeted_passed": 839, + "preflight_passed": 135, + "frozen_baseline_negative_control": { + "failed_as_expected": 23, + "preservation_cases_passed": 6 + }, + "wheel_python_files": 37, + "wheel_source_mismatches": [], + "commands": [ + "python -m pytest -q tests/test_wrapper_edit_paths.py tests/test_matplotlib_api.py tests/test_round3_api_regressions.py tests/test_public_appearance.py tests/test_renderer_018_features.py tests/test_compact_codegen.py tests/test_styles.py tests/test_style_inheritance.py tests/test_invalid_state.py tests/test_linked_axis_edits.py", + "bash scripts/ci-preflight.sh" + ], + "actual_native_and_jupyter_cases_passed": 10 + }, + "benchmark": { + "command": "python scripts/benchmark_wrapper_edits.py", + "script_sha256": "b5238446756409d4b0916bdf50b49509ef292cfa61eae11790eb9dafea48bb33", + "method": "Same script and CPU notebook backend, nine warmed samples (three for linked groups); allocation peaks and operation counts measured separately. 25-panel edits and synthetic 100/1000-element layout/group inputs.", + "limitations": "Diagnostic wall-clock measurements, not FPS or physical-GPU performance. Concurrent local UI checks can affect timings. Linear membership lookup uses extra temporary memory, shown in the allocation peaks. No thresholds were relaxed.", + "before": { + "colorbar_ticks_25_panels": { + "median_ms": 37.40328699495876, + "peak_python_bytes": 2624639, + "snapshots": 2, + "full_document_deepcopies": 2, + "temporary_figures": 1 + }, + "colorbar_label_25_panels": { + "median_ms": 71.47265700041316, + "peak_python_bytes": 2450576, + "snapshots": 6, + "full_document_deepcopies": 9, + "temporary_figures": 0 + }, + "colorbar_lookup_25_panels": { + "median_ms": 80.60446199669968, + "peak_python_bytes": 2453747, + "snapshots": 30, + "full_document_deepcopies": 5, + "temporary_figures": 0 + }, + "mapped_artist_update_25_panels": { + "median_ms": 19.979158998467028, + "peak_python_bytes": 2095869, + "snapshots": 3, + "full_document_deepcopies": 1, + "temporary_figures": 0 + }, + "batch_snapshot_25_panels": { + "median_ms": 32.472538005094975, + "peak_python_bytes": 1912702, + "snapshots": 3, + "full_document_deepcopies": 4, + "temporary_figures": 0 + }, + "linked_chain_100": { + "median_ms": 2.094479998049792, + "peak_python_bytes": 10576 + }, + "linked_chain_1000": { + "median_ms": 205.04405999963637, + "peak_python_bytes": 41324 + }, + "grid_place_100": { + "median_ms": 0.43729799654101953, + "peak_python_bytes": 15272 + }, + "grid_place_1000": { + "median_ms": 29.864856995118316, + "peak_python_bytes": 202000 + } + }, + "after": { + "colorbar_ticks_25_panels": { + "median_ms": 26.3473370068823, + "peak_python_bytes": 2071359, + "snapshots": 2, + "full_document_deepcopies": 1, + "temporary_figures": 0 + }, + "colorbar_label_25_panels": { + "median_ms": 23.439022006641608, + "peak_python_bytes": 2073064, + "snapshots": 2, + "full_document_deepcopies": 1, + "temporary_figures": 0 + }, + "colorbar_lookup_25_panels": { + "median_ms": 32.636536001518834, + "peak_python_bytes": 2445347, + "snapshots": 4, + "full_document_deepcopies": 4, + "temporary_figures": 0 + }, + "mapped_artist_update_25_panels": { + "median_ms": 21.002118002797943, + "peak_python_bytes": 2071227, + "snapshots": 2, + "full_document_deepcopies": 1, + "temporary_figures": 0 + }, + "batch_snapshot_25_panels": { + "median_ms": 27.743155995267443, + "peak_python_bytes": 1917606, + "snapshots": 3, + "full_document_deepcopies": 3, + "temporary_figures": 0 + }, + "linked_chain_100": { + "median_ms": 0.05948000034550205, + "peak_python_bytes": 27560 + }, + "linked_chain_1000": { + "median_ms": 0.5637880021822639, + "peak_python_bytes": 213500 + }, + "grid_place_100": { + "median_ms": 0.2006619979511015, + "peak_python_bytes": 18488 + }, + "grid_place_1000": { + "median_ms": 2.0814099989365786, + "peak_python_bytes": 227860 + } + } + }, + "regression_guards": [ + "Complete snapshots on failed edits and nested rollback", + "Direct expected tick lengths/colors and unchanged other panels", + "Same scale owner in subplots and overlays", + "One measurement of complete color domain; none on palette-only change", + "Snapshot read count independent of panel count", + "Linear member/weight/panel visits rather than timing thresholds" + ] +} diff --git a/examples/README.md b/examples/README.md index 7666ec0..e15f30e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -90,6 +90,7 @@ is a selection of examples, not the complete API surface. | [Desktop editor](desktop_editor.py) | `python examples/desktop_editor.py` | Open the native editor with an adjusted layout | | [Multiple windows](multiple_windows.py) | `python examples/multiple_windows.py` | Independent figures on a shared main-thread event loop | | [Quick start](quickstart.py) | `python examples/quickstart.py` | Write `signal.png`, then open a chart window | +| [Style profiles](style_gallery.py) | `python examples/style_gallery.py --output output/styles` | Fourteen task-specific native renders, tokens, source/contrast manifest and overview sheets (requires Pillow) | | [Jupyter notebook](interactive.ipynb) | `jupyter lab examples/interactive.ipynb` | Edit in the browser using WASM/WebGPU | | [Notebook cells](notebook.py) | Run cells in an IPython kernel | Script-form notebook example with async export | diff --git a/examples/review_workflow.py b/examples/review_workflow.py index e101bad..9705585 100644 --- a/examples/review_workflow.py +++ b/examples/review_workflow.py @@ -1,4 +1,4 @@ -"""Native review controller; an external agent polls the printed cache path.""" +"""Native review controller; an external agent subscribes to the printed cache path.""" import argparse from pathlib import Path @@ -19,12 +19,13 @@ def build(session): def main(): parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--cache',help='Resume an existing session cache') + parser.add_argument('--review-font',help='Installed family supporting the reviewer language, e.g. Noto Sans CJK KR') args=parser.parse_args() - session=Session.open(args.cache) if args.cache else Session(source={'entrypoint':str(Path(__file__).resolve())}) + session=Session.open(args.cache) if args.cache else Session(source={'entrypoint':str(Path(__file__).resolve())},review_font=args.review_font) with session: if not args.cache:build(session) print('Review cache:',session.cache_dir,flush=True) - print('Agent command: python -m figgyplot.review events '+str(session.cache_dir),flush=True) + print('Agent command: python -m figgyplot.review subscribe '+str(session.cache_dir)+' --timeout 50',flush=True) session.present();session.run_events(on_status=lambda status:print(status,flush=True)) diff --git a/examples/style_gallery.py b/examples/style_gallery.py new file mode 100644 index 0000000..9bcc19e --- /dev/null +++ b/examples/style_gallery.py @@ -0,0 +1,130 @@ +"""Render task-specific built-in styles, with execution-local inheritance. + +Run: python examples/style_gallery.py --output /tmp/figgy-style-gallery +Requires Pillow only to compose the overview; every chart is rendered by Figgy. +""" +from pathlib import Path +import argparse +import json +import numpy as np +from PIL import Image, ImageDraw, ImageFont +import figgyplot as fg +from figgyplot._style_presets import PROFILES + + +def make(name): + p=PROFILES[name];printed=p.get('print',False) + dpi=300 if printed else 100 + # Bounded boxplot text has an upstream 32 logical-pixel ceiling. At 9 pt, + # 240 DPI fits; 300 DPI is rejected rather than silently shrinking text. + if name=='print.monochrome':dpi=240 + size=((89/25.4,62/25.4) if name=='publication.nature' else (88.9/25.4,65/25.4)) if printed else ((4.1,5.2) if name=='dashboard.mobile' else (8.2,4.6)) + if name in ('dashboard.operations','dashboard.analysis','dashboard.instrument'):size=(12.2,4.7) + fig=fg.Figure(figsize=size,dpi=dpi);ax=fig.axes[0] + x=np.linspace(0,12,121) + if name=='publication.nature': + t=np.arange(6);a=.3+.55*(1-np.exp(-t/2));b=.25+.4*(1-np.exp(-t/3)) + ax.errorbar(t,a,yerr=.035,marker='o',label='Control');ax.errorbar(t,b,yerr=.045,marker='s',label='Treatment') + ax.set_xlabel('Time (h)');ax.set_ylabel('Response (a.u.)');ax.set_xlim(-.2,5.3);ax.set_ylim(.1,1) + elif name=='publication.ieee-proceedings': + x=np.geomspace(1,1e4,121) + for i in range(3):ax.plot(x,-10*np.log10(1+(x/(30*(i+1)))**2),linestyle=['-','--',':'][i],label=f'Filter {i+1}') + ax.set_xscale('log');ax.set_xlabel('Frequency (Hz)');ax.set_ylabel('Gain (dB)');ax.set_xlim(1,1e4);ax.set_ylim(-55,2) + elif name=='publication.kfas': + x=np.arange(6) + ax.errorbar(x,4+1.4*x,yerr=.6,marker='o',label='Site A');ax.errorbar(x,3+1.1*x,yerr=.8,marker='s',label='Site B') + ax.set_xlabel('Age (year)');ax.set_ylabel('Length (cm)');ax.set_xlim(-.2,5.3);ax.set_ylim(0,13) + elif name=='print.monochrome': + ax.boxplot([[3,4,4.5,5,5.2,6,8],[4,5,5.2,6,7,7.2,9],[2,3,3.5,4,4.2,5,6]],labels=['Control','Batch A','Batch B']) + ax.set_ylabel('Strength (MPa)') + elif name in ('dashboard.executive','dashboard.public'): + ax.barh(['North','South','East','West'],[[72,64,85,59],[68,67,78,63]],label=['Current','Previous']) + ax.set_title('Regional delivery performance');ax.set_xlabel('Completed orders (thousands)') + elif name=='dashboard.operations': + fig.set_layout([['latency','errors','traffic']]);xx=np.arange(30) + specs=[('latency','Request latency','p95 latency (ms)',110+14*np.sin(xx/3),150), + ('errors','Error rate','Failed requests (%)',.6+.25*np.sin(xx/4),.9), + ('traffic','Request volume','Requests / second',320+30*np.sin(xx/5),None)] + for key,title,unit,y,threshold in specs: + a=fig.subplots[key].axes;a.plot(xx,y,style_key=key,label='Measured') + if threshold is not None:a.plot([0,29],[threshold]*2,style_key='warning',label='Supplied limit') + a.set_title(title);a.set_ylabel(unit);a.set_xlabel('Elapsed time (min)');a.set_xlim(0,29);a.legend() + elif name=='dashboard.analysis': + fig.set_layout([['comparison','deviation']]);a=fig.subplots['comparison'].axes + for i in range(4):a.plot(x,.4*x+np.sin(x+i)*.45+i*.12,style_key='context') + a.plot(x,.4*x+np.sin(x)*.45+1,style_key='focus',label='Selected cohort') + a.set_title('Focus in cohort context');a.set_xlabel('Time (h)');a.set_ylabel('Response (a.u.)');a.legend() + a=fig.subplots['deviation'].axes + z=np.outer(np.sin(np.linspace(-2,2,16)),np.cos(np.linspace(-2,2,18)))*12 + a.imshow(z,cmap='RdBu',vmin=-12,vmax=12,origin='lower') + a.set_title('Deviation from supplied reference');a.set_xlabel('Position X (mm)');a.set_ylabel('Position Y (mm)') + elif name=='dashboard.instrument': + fig.set_layout([['wave','spectrum']]);a=fig.subplots['wave'].axes + a.plot(x,np.sin(8*x)*np.exp(-x/7),style_key='sensor',label='Sensor A');a.set_title('Damped oscillation') + a.set_xlabel('Time (ms)');a.set_ylabel('Voltage (V)');a.set_xlim(0,12);a.set_ylim(-1.1,1.1) + a=fig.subplots['spectrum'].axes;f=np.geomspace(10,1e4,200) + a.plot(f,1/np.sqrt(1+(f/250)**4),style_key='sensor');a.set_xscale('log');a.set_yscale('log') + a.set_title('Frequency response');a.set_xlabel('Frequency (Hz)');a.set_ylabel('Amplitude (V)');a.set_xlim(10,1e4);a.set_ylim(.0001,2) + elif name=='dashboard.wallboard': + y=135+10*np.sin(x/2);ax.plot(x,y,style_key='focus',label='Measured') + ax.plot([0,12],[180,180],style_key='warning',label='Supplied limit') + ax.set_title('Latency · 132 ms');ax.set_xlabel('Elapsed time (min)');ax.set_ylabel('p95 latency (ms)');ax.set_ylim(0,220);ax.set_xlim(0,12) + elif name=='dashboard.mobile': + ax.plot(np.arange(7),[42,48,45,59,65,61,73],marker='o',style_key='focus') + ax.set_title('Weekly activity');ax.set_xlabel('Day');ax.set_ylabel('Completed tasks');ax.set_xlim(-.3,6.3);ax.set_ylim(0,85) + elif name=='scientific.precision': + for i in range(3):ax.plot(x,np.sin(x*(i+1))*.7+i*2,label=f'Channel {i+1}') + ax.set_title('Three calibrated measurement channels');ax.set_xlabel('Time (s)');ax.set_ylabel('Voltage + offset (V)');ax.set_xlim(0,12);ax.set_ylim(-1,5) + elif name=='education.explainer': + y=.5*x+np.sin(x)*.3 + ax.fill_between(x,.5*x-.7,.5*x+.7,alpha=.12,style_key='focus') + ax.scatter(x[::6],y[::6],style_key='focus',label='Observed');ax.plot(x,.5*x,style_key='reference',label='Model') + ax.arrow(3,4,2,-1.5);ax.text(1,4.5,'Compare data and model') + ax.set_title('Trend and uncertainty');ax.set_xlabel('Input');ax.set_ylabel('Response');ax.set_xlim(0,12);ax.set_ylim(-1,7) + else: + x=x[::6] + for i,name_ in enumerate(['Baseline','Candidate A','Candidate B']): + ax.plot(x,1-np.exp(-x/(2+i)),marker=['o','s','D'][i],label=name_) + ax.set_title('Response over time');ax.set_xlabel('Time (s)');ax.set_ylabel('Normalized response');ax.set_xlim(0,12);ax.set_ylim(0,1.1) + if name not in ('print.monochrome','dashboard.executive','dashboard.public','dashboard.operations','dashboard.analysis','dashboard.instrument','dashboard.mobile'):ax.legend() + return fig + + +def contrast(c,b): + def lum(x): + v=[x[k] for k in ('r','g','b')];return sum(w*(z/12.92 if z<=.04045 else ((z+.055)/1.055)**2.4) for w,z in zip((.2126,.7152,.0722),v)) + a,d=lum(c),lum(b);return (max(a,d)+.05)/(min(a,d)+.05) + + +def main(output): + output.mkdir(parents=True,exist_ok=True);manifest={} + for name in PROFILES: + with fg.style_context(name):fig=make(name) + try: + token=fig.style_defaults;path=output/(name+'.png');fig.savefig(path) + image=Image.open(path).convert('RGBA');image.save(path,dpi=(fig.dpi,fig.dpi)) + token.save(output/(name+'.style.json')) + a=token.appearance + manifest[name]=dict(fg.styles.describe(name),image=path.name,dpi=fig.dpi, + size_px=image.size,category_contrast=[contrast(c,a['background']) for c in a['palette']], + semantic_contrast={key:contrast(v['line']['line_color'],a['background']) for key,v in a['roles'].items()}, + inherited_after_scope=True,renderer='Figgy native') + finally:fig.close() + font=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',17) + # Captions and thumbnails are the only composed pixels; standalone exports + # retain the exact native rendering and include physical DPI metadata. + for group,names in [('publication',[n for n in PROFILES if n.startswith(('publication.','print.'))]), + ('screens',[n for n in PROFILES if not n.startswith(('publication.','print.'))])]: + tilew,tileh=680,340;sheet=Image.new('RGB',(tilew*2,tileh*((len(names)+1)//2)),'#eff2f6');draw=ImageDraw.Draw(sheet) + for i,name in enumerate(names): + x=(i%2)*tilew;y=(i//2)*tileh;draw.text((x+16,y+8),name,fill='#202733',font=font) + im=Image.open(output/(name+'.png')).convert('RGB');im.thumbnail((tilew-30,tileh-45),Image.Resampling.LANCZOS) + sheet.paste(im,(x+(tilew-im.width)//2,y+37)) + sheet.save(output/(group+'-overview.png')) + (output/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n') + print(output) + + +if __name__=='__main__': + parser=argparse.ArgumentParser();parser.add_argument('--output',type=Path,default=Path('/tmp/figgy-style-gallery')) + main(parser.parse_args().output) diff --git a/pyproject.toml b/pyproject.toml index cf08eae..d46b002 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ notebook = ["anywidget>=0.9,<1"] desktop = [] benchmark = ["matplotlib>=3.7; python_version < '3.9'", "matplotlib>=3.8; python_version >= '3.9'", "psutil>=5.9", "pillow>=10", "pyqtgraph>=0.13"] benchmark-gpu = ["fastplotlib>=0.7", "vispy>=0.17", "PySide6>=6.8,<7", "PyOpenGL>=3.1"] -test = ["pytest>=8", "pillow>=10", "anywidget>=0.9,<1"] +test = ["pytest>=8", "pillow>=10", "anywidget>=0.9,<1", "jsonschema>=4.18,<5", "matplotlib>=3.7"] [tool.maturin] python-source = "python" diff --git a/python/figgyplot/__init__.py b/python/figgyplot/__init__.py index 04c3f71..17e809c 100644 --- a/python/figgyplot/__init__.py +++ b/python/figgyplot/__init__.py @@ -3,5 +3,16 @@ from ._native import __version__, release_gpu from ._document import Subplot from ._features import Annotation +from . import styles +from .styles import StyleToken, StyleReport, StyleWarning +from ._style_runtime import use_style, style_context -__all__ = ["Subplot", "Annotation", "Figure", "Axes", "Artist", "RadialArtist", "RenderWarning", "figure", "subplots", "text_segments", "release_gpu"] +__all__ = ["Subplot", "Annotation", "Figure", "Axes", "Artist", "RadialArtist", "RenderWarning", "figure", "subplots", "text_segments", "release_gpu", "styles", "StyleToken", "StyleReport", "StyleWarning"] +__all__ += ['use_style', 'style_context'] + +from ._artist_appearance import MarkerStyle, ErrorBarStyle, BinStyle, ColumnReference, GridReference +__all__ += ["MarkerStyle", "ErrorBarStyle", "BinStyle", "ColumnReference", "GridReference"] +from ._field_api import ContourLabel +__all__ += ["ContourLabel"] +from ._richtext import TextRun +__all__ += ["TextRun"] diff --git a/python/figgyplot/_annotation_api.py b/python/figgyplot/_annotation_api.py new file mode 100644 index 0000000..92161c7 --- /dev/null +++ b/python/figgyplot/_annotation_api.py @@ -0,0 +1,195 @@ +"""Named annotation editing; coordinate conversion stays explicit.""" + +import copy + + +def anchor(xy, coords): + from ._validation import number as _number + + x, y = (_number(v, "coordinate") for v in xy) + if coords == "axes fraction": + return {"Ratio": dict(x=x, y=1 - y)} + if coords not in ("data", "ratio"): + raise ValueError( + "coords must be data, ratio (top-left), or axes fraction (bottom-left)" + ) + return {coords.title(): dict(x=x, y=y)} + + +class AnnotationEditing: + def _rich_text(self): + kind, body = next( + iter(self._find(self.figure.get_settings())[2]["kind"].items()) + ) + rich = body.get("label" if kind == "ReferenceLine" else "text") + if rich is None: + raise AttributeError("annotation has no text label") + return rich + + def get_color(self): + color = self._rich_text()["color"] + return tuple(color[k] for k in "rgba") + + def get_fontfamily(self): + return [self._rich_text()["font"]] + + def _edit(self, operation): + state = self.figure.get_settings() + item = self._find(state)[2] + name, body = next(iter(item["kind"].items())) + operation(name, body) + self.figure.apply_settings(state) + return self + + def set_style( + self, + *, + color=None, + linewidth=None, + linestyle=None, + head_length=None, + head_width=None, + ): + """Edit a reference-line/arrow stroke or a span fill; lengths are points.""" + from .figure import _color, _LINES + from ._validation import number as _number + + def edit(name, body): + if name == "Span": + if any( + v is not None + for v in (linewidth, linestyle, head_length, head_width) + ): + raise AttributeError("spans support a fill color only") + if color is not None: + body["fill"] = _color(color) + return + if name not in ("ReferenceLine", "Arrow"): + raise AttributeError("text frames use set_bbox") + style = body["style"] + line = style["line"] if name == "Arrow" else style + if color is not None: + line["color"] = _color(color) + if linewidth is not None: + line["width"] = _number(linewidth, "linewidth") * self.figure.dpi / 72 + if linestyle is not None: + if linestyle not in _LINES: + raise ValueError("linestyle must be -, --, : or -.") + line["style"] = _LINES[linestyle] + for key, value in ( + ("head_length", head_length), + ("head_width", head_width), + ): + if value is not None: + if name != "Arrow": + raise AttributeError("arrow heads require an arrow") + style[key] = _number(value, key) * self.figure.dpi / 72 + + return self._edit(edit) + + def set_bbox( + self, *, facecolor="white", edgecolor="black", linewidth=1, pad=4, visible=True + ): + """Set/remove a text frame. Padding is in points, independent of font size.""" + from .figure import _color + from ._validation import number as _number + + def edit(name, body): + if name != "Text": + raise AttributeError("only text annotations have a frame") + body["box_style"] = ( + None + if not visible + else dict( + fill=_color(facecolor), + border_color=_color(edgecolor), + border_width=_number(linewidth, "linewidth") * self.figure.dpi / 72, + padding=_number(pad, "padding") * self.figure.dpi / 72, + ) + ) + + return self._edit(edit) + + def set_endpoints(self, start, end, *, coords="data", endcoords=None): + """Move both arrow endpoints, including mixed data/fraction coordinates.""" + + def edit(name, body): + if name != "Arrow": + raise AttributeError("endpoints require an arrow") + body["from"] = anchor(start, coords) + body["to"] = anchor(end, coords if endcoords is None else endcoords) + + return self._edit(edit) + + def set_value(self, value): + from ._validation import number as _number + + def edit(name, body): + if name != "ReferenceLine": + raise AttributeError("set_value requires a reference line") + body["value"] = _number(value, "value") + + return self._edit(edit) + + def set_bounds(self, low, high): + from ._validation import number as _number + + def edit(name, body): + if name != "Span": + raise AttributeError("set_bounds requires a span") + body["from"], body["to"] = _number(low, "low"), _number(high, "high") + + return self._edit(edit) + + def set_label( + self, + text, + *, + fontsize=11, + color="black", + fontfamily=None, + multialignment="left", + ): + """Set optional text on a reference line or arrow; None removes it.""" + from .figure import _color, _segments + from ._validation import number as _number + + def edit(name, body): + if name not in ("Arrow", "ReferenceLine"): + raise AttributeError("use set_text for text annotations") + key = "label" if name == "ReferenceLine" else "text" + if text is None: + body[key] = None + return + if multialignment not in ("left", "center", "right"): + raise ValueError("multialignment must be left, center or right") + rich = copy.deepcopy(self.figure._default_config["legend"]["content"]) + rich.update( + segments=_segments(text, dpi=self.figure.dpi), + color=_color(color), + font_size=_number(fontsize, "fontsize", positive=True) + * self.figure.dpi + / 72, + ) + rich.pop("align", None) + if multialignment != "left": + rich["align"] = multialignment.title() + if fontfamily is not None: + rich["font"] = self.figure._check_font(fontfamily) + body[key] = rich + + return self._edit(edit) + + def set_multialignment(self, alignment): + if alignment not in ("left", "center", "right"): + raise ValueError("alignment must be left, center or right") + + def edit(name, body): + rich = body.get("label" if name == "ReferenceLine" else "text") + if rich is None: + raise AttributeError("annotation has no label") + rich.pop("align", None) + if alignment != "left": + rich["align"] = alignment.title() + + return self._edit(edit) diff --git a/python/figgyplot/_annotation_codegen.py b/python/figgyplot/_annotation_codegen.py new file mode 100644 index 0000000..4f520f2 --- /dev/null +++ b/python/figgyplot/_annotation_codegen.py @@ -0,0 +1,59 @@ +"""Public annotation recipes shared by Cartesian and bounded exporters.""" + + +def annotation_calls(layer, axes, variable, dpi, rich_arg): + from ._api_codegen import _color_literal as color, _points + + def points(value): + return _points(value, dpi) + + for item in layer.get("items", []): + kind, body = next(iter(item["kind"].items())) + id = item["id"] + handle = f"{variable}.annotation({id!r})" + if kind == "Text": + coords, point = next(iter(body["anchor"].items())) + rich = body["text"] + yield ( + f"{axes}.text({point['x']!r}, {point['y']!r}, {rich_arg(rich)}, coords={coords.lower()!r}, " + f"fontsize={points(rich['font_size'])!r}, color={color(rich['color'])!r}, fontfamily={rich['font']!r}, id={id!r})" + ) + yield f"{handle}.set_multialignment({rich.get('align', 'Left').lower()!r})" + box = body.get("box_style") + if box: + yield ( + f"{handle}.set_bbox(facecolor={color(box['fill'])!r}, edgecolor={color(box['border_color'])!r}, " + f"linewidth={points(box['border_width'])!r}, pad={points(box['padding'])!r})" + ) + elif kind == "ReferenceLine": + method = "axvline" if body["axis"] == "X" else "axhline" + yield f"{axes}.{method}({body['value']!r}, id={id!r})" + elif kind == "Span": + method = "axvspan" if body["axis"] == "X" else "axhspan" + yield f"{axes}.{method}({body['from']!r}, {body['to']!r}, color={color(body['fill'])!r}, alpha=1, id={id!r})" + elif kind == "Arrow": + yield f"{axes}.arrow(0, 0, 0, 0, coords='ratio', id={id!r})" + source, start = next(iter(body["from"].items())) + destination, end = next(iter(body["to"].items())) + yield ( + f"{handle}.set_endpoints({(start['x'], start['y'])!r}, {(end['x'], end['y'])!r}, " + f"coords={source.lower()!r}, endcoords={destination.lower()!r})" + ) + else: + raise ValueError("unknown annotation kind; use exact recovery") + if kind in ("Arrow", "ReferenceLine"): + line = body["style"]["line"] if kind == "Arrow" else body["style"] + ls = dict(Solid="-", Dash="--", Dot=":", DashDot="-.")[line["style"]] + args = f"color={color(line['color'])!r}, linewidth={points(line['width'])!r}, linestyle={ls!r}" + if kind == "Arrow": + args += f", head_length={points(body['style']['head_length'])!r}, head_width={points(body['style']['head_width'])!r}" + yield f"{handle}.set_style({args})" + rich = body.get("text" if kind == "Arrow" else "label") + if rich: + yield ( + f"{handle}.set_label({rich_arg(rich)}, fontsize={points(rich['font_size'])!r}, " + f"color={color(rich['color'])!r}, fontfamily={rich['font']!r}, multialignment={rich.get('align', 'Left').lower()!r})" + ) + if not item.get("visible", True): + yield f"{handle}.set_visible(False)" + yield f"{variable}.set_annotations_visible({layer['visible']!r})" diff --git a/python/figgyplot/_api_codegen.py b/python/figgyplot/_api_codegen.py new file mode 100644 index 0000000..d457828 --- /dev/null +++ b/python/figgyplot/_api_codegen.py @@ -0,0 +1,1396 @@ +"""Export public plotting calls; keep exact, renderer-only recovery explicit. + +The CPU state view executes the actual setters while compiling. Residual fields +are computed against their real effects rather than a second guessed model. +""" + +import copy +import json +import keyword +import pprint +import numpy as np + + +def _points(pixels, dpi): + """Shortest point constant that reproduces the exact renderer f32 length.""" + value = pixels * 72 / dpi + for digits in range(10): + candidate = round(value, digits) + if np.float32(candidate * dpi / 72) == np.float32(pixels): + return candidate + return value + + +def _prune_empty(value): + if not isinstance(value, dict): + return value + return { + key: part for key, item in value.items() if (part := _prune_empty(item)) != {} + } + + +def _color_literal(value): + channels = tuple(value[k] for k in "rgba") + encoded = tuple(round(c * 255) for c in channels) + if all(np.float32(c) == np.float32(v / 255) for c, v in zip(channels, encoded)): + return "#" + "".join( + f"{c:02x}" for c in (encoded[:3] if encoded[3] == 255 else encoded) + ) + return channels + + +def _formatter_expression(label, names): + native = label["format"] + precision = label["significant_digits"] + if isinstance(native, str): + notation = { + "Decimal": "plain", + "Scientific": "scientific", + "Power": "power", + "Degrees": "degrees", + "Radians": "radians", + }[native] + return f"{names['SignificantFormatter']}({precision!r}, style={notation!r})" + spec = native["Timestamp"] + unit = { + "Seconds": "s", + "Milliseconds": "ms", + "Microseconds": "us", + "Nanoseconds": "ns", + }[spec.get("unit", "Seconds")] + zone = spec.get("timezone", "Utc") + offset = 0 if zone == "Utc" else zone["FixedOffsetMinutes"] + pattern = spec.get("label", "Auto") + pattern = None if pattern == "Auto" else pattern["Pattern"] + fractional = spec.get("fractional", "Auto") + fractional = None if fractional == "Auto" else fractional["Fixed"] + calendar = spec.get("tick_policy", "AutoCalendar") == "AutoCalendar" + return f"{names['TimestampFormatter']}({pattern!r}, unit={unit!r}, timezone={offset!r}, fractional_digits={fractional!r}, calendar={calendar!r}, precision={precision!r})" + + +def settings_to_api_code(settings, defaults, dpi=120, variable="fig"): + from .figure import _diff, _text, _code_tree + from ._matplotlib import _StateFigure + from .ticker import ( + MultipleLocator, + LogLocator, + AutoMinorLocator, + SignificantFormatter, + TimestampFormatter, + ) + from ._charts import family + from ._native import font_family_available, canonical_style_state_json + + if ( + not isinstance(variable, str) + or not variable.isidentifier() + or keyword.iskeyword(variable) + ): + raise ValueError("variable must be a Python identifier") + settings = ( + json.loads(settings) if isinstance(settings, str) else copy.deepcopy(settings) + ) + defaults = json.loads(defaults) if isinstance(defaults, str) else defaults + if family(settings): + return _bounded_api_code(settings, defaults, dpi, variable) + model = _StateFigure(settings, defaults, dpi) + ticker_classes = { + "MultipleLocator": MultipleLocator, + "LogLocator": LogLocator, + "AutoMinorLocator": AutoMinorLocator, + "SignificantFormatter": SignificantFormatter, + "TimestampFormatter": TimestampFormatter, + } + ticker_names = { + name: ("_fg_" + name if variable == name else name) for name in ticker_classes + } + namespace = { + variable: model, + **{ticker_names[name]: cls for name, cls in ticker_classes.items()}, + } + from ._artist_appearance import MarkerStyle, ErrorBarStyle, BinStyle, _PARTS + + from ._field_api import ContourLabel + + style_classes = { + "ContourLabel": ContourLabel, + "MarkerStyle": MarkerStyle, + "ErrorBarStyle": ErrorBarStyle, + "BinStyle": BinStyle, + } + style_names = { + name: ("_fg_" + name if variable == name else name) for name in style_classes + } + namespace.update({style_names[name]: cls for name, cls in style_classes.items()}) + from .figure import text_segments + from ._richtext import TextRun, run_expression + + run_name = "TextRun" if variable != "TextRun" else "_fg_TextRun" + namespace[run_name] = TextRun + + segments_name = ( + "_fg_text_segments" if variable != "_fg_text_segments" else "_fg_text_runs" + ) + namespace[segments_name] = text_segments + lines = [] + + def emit(statement, force=False): + before = model._state + exec(statement, namespace) + after = model._state + keys = ("config", "series", "background", "identity") + changed = any(before.get(k) != after.get(k) for k in keys) + if changed and not force and " = " not in statement: + # Point conversions can differ in Python's final binary digit while + # producing identical typed renderer state. Omit only proven native + # no-ops, without rounding scientific data or weakening replay. + original = json.loads(canonical_style_state_json(json.dumps(before))) + updated = json.loads(canonical_style_state_json(json.dumps(after))) + changed = any(original.get(k) != updated.get(k) for k in keys) + if not changed: + model._state = before + if force or changed or " = " in statement: + lines.append(statement) + + def color(value): + return _color_literal(value) + + unit = 72 / dpi + + def fmt(value): + return pprint.pformat( + _code_tree(value, segments_name), width=96, sort_dicts=False + ) + + def rich_arg(rich): + from .figure import _segments + + text = _text(rich) + if rich["segments"] == _segments(text): + return repr(text) + return run_expression(rich["segments"], dpi, run_name) + + def style_expression(entry, part): + from dataclasses import fields + from ._artist_appearance import _style + + _, cls, _, _ = _PARTS[part] + value = _style(entry, part, dpi, decode=True) + args = [] + for field in fields(value): + item = getattr(value, field.name) + if item is None: + continue + if field.name in ("color", "facecolor", "edgecolor"): + item = _color_literal(dict(zip("rgba", item))) + if field.name in ("markersize", "linewidth", "capsize", "capthick", "gap"): + pixels = item * dpi / 72 + item = _points(pixels, dpi) + args.append(f"{field.name}={item!r}") + return style_names[cls.__name__] + "(" + ", ".join(args) + ")" + + def column_expression(name): + return f"{variable}.data.column({name!r})" + + def grid_expression(grid): + axis = "y" if grid["orientation"] == "ColumnsAreY" else "x" + return f"{variable}.data.grid({grid['columns']!r}, axis={axis!r}, coordinates={grid['grid_layout'].lower()!r})" + + def text(target, method, node): + from .figure import _segments + + rich = node["text"] + plain = _text(rich) + rebuilt = _segments(plain) + style = [] + for flag, prop, value in [ + ("bold", "fontweight", "bold"), + ("italic", "fontstyle", "italic"), + ]: + if rich["segments"] and all(s.get(flag) is True for s in rich["segments"]): + for segment in rebuilt: + segment[flag] = True + style.append(f"{prop}={value!r}") + args = [repr(plain) if rebuilt == rich["segments"] else rich_arg(rich)] + if rebuilt == rich["segments"]: + args += style + handle = { + "set_title": "title", + "set_xlabel": "xaxis.label", + "set_ylabel": "yaxis.label", + }[method] + baseline = eval(target + "." + handle, namespace)._node(model.get_settings()) + for field, prop, value in [ + ("font_size", "fontsize", _points(rich["font_size"], dpi)), + ("color", "color", color(rich["color"])), + ("font", "fontfamily", rich["font"]), + ]: + if field == "font" and not font_family_available(value): + continue + if rich[field] != baseline["text"][field]: + args.append(f"{prop}={value!r}") + if node["visible"] != baseline["visible"]: + args.append(f"visible={node['visible']!r}") + if rich.get("align") != baseline["text"].get("align") and "align" in rich: + args.append(f"multialignment={rich['align'].lower()!r}") + emit(f"{target}.{method}({', '.join(args)})") + + emit(f"{variable}.reset_style()", force=True) + layout = settings["identity"].get("layout") + if layout: + args = ", ".join( + f"{key}={layout[key]!r}" + for key in ("placement", "row_weights", "column_weights", "gap") + ) + emit(f"{variable}.set_layout({layout['grid']!r}, {args})", force=True) + from ._matplotlib import canvas_size + + width, height = canvas_size(settings) + emit(f"{variable}.set_size_inches({width / dpi!r}, {height / dpi!r})", force=True) + if "background" in settings: + emit(f"{variable}.set_facecolor({color(settings['background'])!r})") + panels = [settings["config"]] + [ + p["config"] for p in settings["config"].get("subplots", []) + ] + for index, cfg in enumerate(panels): + ax = "_ax" if variable != "_ax" else "_axes" + if index: + emit( + f"{ax} = {variable}.subplots[{settings['identity']['panels'][index]['id']!r}].axes" + ) + else: + emit(f"{ax} = {variable}.axes[0]") + area = cfg["chart_area"] + bounds = ( + area["x"] / width, + 1 - (area["y"] + area["height"]) / height, + area["width"] / width, + area["height"] / height, + ) + emit(f"{ax}.set_panel_position({bounds!r})", force=True) + if cfg.get("draw_style"): + options = dict(cfg["draw_style"]) + mode = options.pop("mode") + args = ", ".join(f"{key}={value!r}" for key, value in options.items()) + emit(f"{ax}.set_draw_style({mode!r}" + (", " + args if args else "") + ")") + # Express paired ranges/scales first, then preserve independent boundaries. + for dim, key in [("x", "bottom_x"), ("y", "left_y")]: + node = cfg[key] + low, high = node["min"], node["max"] + if node["inverted"]: + low, high = high, low + # Normalize the same scale before fitting bounds on both source + # and destination, so auto formatting cannot depend on old scale. + emit(f"{ax}.set_{dim}scale('linear')", force=True) + emit(f"{ax}.set_{dim}lim({low!r}, {high!r})", force=True) + if node["scale"] == "Logarithmic": + emit(f"{ax}.set_{dim}scale('log')", force=True) + for dim, key in [("x", "bottom_x"), ("y", "left_y")]: + node = cfg[key] + if node["scale"] == "Linear": + emit( + f"{ax}.{dim}axis.set_major_locator({ticker_names['MultipleLocator']}({node['major_spacing']!r}))", + force=True, + ) + elif node["scale"] == "Logarithmic": + emit( + f"{ax}.{dim}axis.set_major_locator({ticker_names['LogLocator']}(base={10 ** node['major_spacing']!r}))", + force=True, + ) + text(ax, "set_title", cfg["chart_title"]) + text(ax, "set_xlabel", cfg["bottom_x"]["title_option"]) + text(ax, "set_ylabel", cfg["left_y"]["title_option"]) + emit( + f"{ax}.title.set_layout_height({_points(cfg['chart_title']['top_margin'], dpi)!r})" + ) + if cfg["chart_title"]["offset_x"] or cfg["chart_title"]["offset_y"]: + emit( + f"{ax}.title.set_offset({(_points(cfg['chart_title']['offset_x'], dpi), _points(cfg['chart_title']['offset_y'], dpi))!r})" + ) + for side, key, dim in [ + ("bottom", "bottom_x", "x"), + ("top", "top_x", "x"), + ("left", "left_y", "y"), + ("right", "right_y", "y"), + ]: + node = cfg[key] + spine = f"{ax}.spines[{side!r}]" + emit( + f"{spine}.set(visible={node['line_visible']!r}, color={color(node['line_color'])!r}, linewidth={_points(node['line_width'], dpi)!r}, linestyle={dict(Solid='-', Dash='--', Dot=':', DashDot='-.').get(node['line_style'], node['line_style'])!r})" + ) + if node["line_offset"]: + emit( + f"{spine}.set_position(('outward', {_points(node['line_offset'], dpi)!r}))" + ) + boundary = f"{ax}.secondary_{dim}axis({side!r})" + axis = boundary + "." + dim + "axis" + low, high = node["min"], node["max"] + if node["inverted"]: + low, high = high, low + emit(f"{axis}.set_view_interval({low!r}, {high!r})") + emit( + f"{axis}.set_scale({'log' if node['scale'] == 'Logarithmic' else 'linear'!r})" + ) + if node["scale"] == "Linear": + emit( + f"{axis}.set_major_locator({ticker_names['MultipleLocator']}({node['major_spacing']!r}))" + ) + else: + emit( + f"{axis}.set_major_locator({ticker_names['LogLocator']}(base={10 ** node['major_spacing']!r}))" + ) + emit( + f"{axis}.set_minor_locator({ticker_names['AutoMinorLocator']}({node['minor_count'] + 1}))" + ) + label = node["label_style"] + emit( + f"{axis}.set_major_formatter({_formatter_expression(label, ticker_names)})" + ) + args = [ + f"labelsize={_points(label['font_size'], dpi)!r}", + f"labelcolor={color(label['color'])!r}", + f"labelfontfamily={label['label_font']!r}", + f"label{side}={bool(label['label_visible'])!r}", + ] + if node["tick"] != "None": + args.append( + f"direction={dict(Inside='in', Outside='out', Both='inout')[node['tick']]!r}" + ) + args.append(f"{side}={node['tick'] != 'None'!r}") + emit( + f"{boundary}.tick_params(which='major', length={_points(node['major_tick_length'], dpi)!r}, {', '.join(args)})" + ) + emit( + f"{boundary}.tick_params(which='minor', length={_points(node['minor_tick_length'], dpi)!r})" + ) + emit(f"{axis}.set_ticklabels_enabled({label['visible']!r})") + emit(f"{axis}.set_layout_margin({_points(node['out_margin'], dpi)!r})") + emit( + f"{axis}.set_ticklabel_offset({(_points(label['label_offset_x'], dpi), _points(label['label_offset_y'], dpi))!r})" + ) + if side in ("top", "right"): + text( + boundary, + "set_xlabel" if dim == "x" else "set_ylabel", + node["title_option"], + ) + title = axis + ".label" + emit( + f"{title}.set_offset({(_points(node['title_option']['offset_x'], dpi), _points(node['title_option']['offset_y'], dpi))!r})" + ) + for dim in ("x", "y"): + for which in ("major", "minor"): + grid = cfg["grid"] + prefix = which + "_" + dim + emit( + f"{ax}.grid({grid['show_' + prefix]!r}, which={which!r}, axis={dim!r}, color={color(grid[prefix + '_color'])!r}, linewidth={_points(grid[prefix + '_width'], dpi)!r}, linestyle={dict(Solid='-', Dash='--', Dot=':', DashDot='-.').get(grid[prefix + '_style'], grid[prefix + '_style'])!r})" + ) + # Stable lookup avoids the label-filtering/index association bug in legends. + for spec in settings["series"]: + artist = f"{variable}.artist({spec['series_id']!r})" + body = next(iter(spec["render_type"].values())) + if "line" in body: + line = body["line"] + emit( + f"{artist}.set(color={color(line['line_color'])!r}, linewidth={_points(line['line_width'], dpi)!r}, linestyle={dict(Solid='-', Dash='--', Dot=':', DashDot='-.').get(line['line_style'], line['line_style'])!r})", + force=True, + ) + if line.get("fill_color") is not None and line.get("fill") is None: + emit( + f"{artist}.set_fill(None, color={color(line['fill_color'])!r})", + force=True, + ) + if line.get("interpolation", "Linear") != "Linear": + draw = { + "StepPre": "steps-pre", + "StepPost": "steps-post", + "StepMid": "steps-mid", + }[line["interpolation"]] + emit(f"{artist}.set_drawstyle({draw!r})") + if "scatter" in body: + scatter = body["scatter"] + from .figure import _MARKERS + + from ._markers import decode_marker + + marker, fillstyle = decode_marker(scatter["point_shape"]) + # Adding markers is a supported resident-Line transition. Emit it + # before size, even when the source model already has that marker. + emit(f"{artist}.set_marker({marker!r})", force=True) + if fillstyle is not None: + emit(f"{artist}.set_fillstyle({fillstyle!r})", force=True) + emit( + f"{artist}.set_markersize({_points(scatter['point_size'] * 2, dpi)!r})", + force=True, + ) + emit( + f"{artist}.markers.set(color={color(scatter['point_color'])!r})", + force=True, + ) + if "bar" in body: + bar = body["bar"] + emit(f"{artist}.set_facecolor({color(bar['fill_color'])!r})", force=True) + emit(f"{artist}.set_edgecolor({color(bar['border_color'])!r})", force=True) + emit( + f"{artist}.set_linewidth({_points(bar['border_width'], dpi)!r})", + force=True, + ) + emit(f"{artist}.set_baseline({bar['baseline']!r})", force=True) + emit( + f"{artist}.set_bar_width({bar['width_ratio']!r}, gap={_points(bar['gap_px'], dpi)!r})", + force=True, + ) + emit( + f"{artist}.set_orientation({bar['orientation'].lower()!r})", force=True + ) + if "err_style" in body: + error = body["err_style"] + emit( + f"{artist}.set_errorbar_style(color={color(error['error_bar_color'])!r}, width={_points(error['error_bar_width'], dpi)!r}, capsize={_points(error['error_bar_cap_size'], dpi)!r}, capthick={_points(error['cap_width'], dpi)!r})", + force=True, + ) + for part, (key, _, _, prefix) in _PARTS.items(): + if key not in body: + continue + node = body[key] + base = artist + "." + part + if part != "bins": + table = node.get(prefix + "_style_table") + index = node.get(prefix + "_style_index_column") + entries = ( + "None" + if table is None + else "[" + ", ".join(style_expression(v, part) for v in table) + "]" + ) + source = "None" if index is None else column_expression(index) + emit(f"{base}.set_palette({entries}, source={source})", force=True) + overrides = node.get(prefix + "_style_overrides") + entries = ( + "None" + if overrides is None + else "[" + + ", ".join( + f"({v['index']!r}, {style_expression(v, part)})" for v in overrides + ) + + "]" + ) + emit(f"{base}.set_overrides({entries})", force=True) + if "scatter" in body: + node = body["scatter"] + mapping = node.get("point_color_map") + if mapping: + owner = next( + a + for a in model.axes + if any( + s["series_id"] == spec["series_id"] + for s in a._owned_series(model.get_settings()) + ) + ) + bar = owner._config(settings)["colorbar"] + clim = (bar["axis"]["min"], bar["axis"]["max"]) + emit( + f"{artist}.markers.map_color({column_expression(mapping['column'])}, clim={clim!r})", + force=True, + ) + else: + emit(f"{artist}.markers.map_color(None)", force=True) + mapping = node.get("point_size_map") + if mapping: + domain = (mapping["domain_min"], mapping["domain_max"]) + sizes = ( + _points(mapping["min_px"] * 2, dpi), + _points(mapping["max_px"] * 2, dpi), + ) + emit( + f"{artist}.markers.map_size({column_expression(mapping['column'])}, domain={domain!r}, markersizes={sizes!r}, scale={mapping['scale'].lower()!r})", + force=True, + ) + else: + emit(f"{artist}.markers.map_size(None)", force=True) + if "matrix" in body: + emit(f"{artist}.set_grid({grid_expression(body['matrix'])})", force=True) + if "contour" in body: + contour = body["contour"] + line = contour["line"] + emit(f"{artist}.contours.set_levels({contour['levels']!r})", force=True) + colors = contour.get("per_level_color") + colors = None if colors is None else [color(v) for v in colors] + emit( + f"{artist}.contours.set_style(color={color(line['line_color'])!r}, linewidth={_points(line['line_width'], dpi)!r}, linestyle={dict(Solid='-', Dash='--', Dot=':', DashDot='-.')[line['line_style']]!r}, colors={colors!r})", + force=True, + ) + label = contour.get("labels") + if label: + positions = ( + "[" + + ", ".join( + f"{style_names['ContourLabel']}({v['level_index']!r}, {v['x']!r}, {v['y']!r}, tangent={(v['tx'], v['ty'])!r})" + for v in label["anchors"] + ) + + "]" + ) + emit( + f"{artist}.contours.set_labels({label['visible']!r}, fontsize={_points(label['font_size'], dpi)!r}, color={color(label['color'])!r}, formatter={_formatter_expression(label, ticker_names)}, spacing={_points(label['spacing_px'], dpi)!r}, positions={positions}, background={color(label['bg_color']) if label.get('bg_color') else None!r}, padding={_points(label['bg_padding_px'], dpi)!r})", + force=True, + ) + else: + emit(f"{artist}.contours.set_labels(None)", force=True) + if "components" in body: + style = body["style"] + units, scale = next(iter(style["scale"].items())) + scale = next(iter(scale.values())) + emit( + f"{artist}.vectors.set(color={color(style['color'])!r}, linewidth={_points(style['width_px'], dpi)!r}, head_length={_points(style['head_length_px'], dpi)!r}, head_width={_points(style['head_width_px'], dpi)!r}, min_length={_points(style.get('min_length_px', 0), dpi)!r}, pivot={style['pivot'].lower()!r}, units={'data' if units == 'Data' else 'pixels'!r}, scale={scale!r})", + force=True, + ) + mapping = style.get("color_map") + if mapping: + kind, value = next(iter(mapping.items())) + source = ( + grid_expression(value) + if kind == "Grid" + else column_expression(value) + ) + owner = next( + a + for a in model.axes + if any( + s["series_id"] == spec["series_id"] + for s in a._owned_series(model.get_settings()) + ) + ) + bar = owner._config(settings)["colorbar"] + clim = (bar["axis"]["min"], bar["axis"]["max"]) + emit(f"{artist}.vectors.map_color({source}, clim={clim!r})", force=True) + else: + emit(f"{artist}.vectors.map_color(None)", force=True) + if ( + "matrix" in body + or body.get("scatter", {}).get("point_color_map") + or body.get("style", {}).get("color_map") + ): + owner = next( + a + for a in model.axes + if any( + s["series_id"] == spec["series_id"] + for s in a._owned_series(model.get_settings()) + ) + ) + bar = owner._config(settings)["colorbar"] + if isinstance(bar["colormap"], str): + name = {"GrayScale": "gray", "RdBu": "RdBu"}.get( + bar["colormap"], bar["colormap"].lower() + ) + emit(f"{artist}.set_cmap({name!r})", force=True) + emit( + f"{artist}.set_clim({bar['axis']['min']!r}, {bar['axis']['max']!r})", + force=True, + ) + emit( + f"{artist}.set_label({rich_arg(spec['label']) if spec.get('label') is not None else 'None'})", + force=True, + ) + if spec.get("label") is not None: + rich = spec["label"] + emit( + f"{artist}.legend_label.set(fontsize={_points(rich['font_size'], dpi)!r}, color={color(rich['color'])!r}, fontfamily={rich['font']!r}, multialignment={rich.get('align', 'Left').lower()!r})", + force=True, + ) + if body.get("line", {}).get("fill") is not None: + fill = body["line"]["fill"] + target = next(iter(fill.values())) + opts = ( + ", color=" + repr(color(body["line"]["fill_color"])) + if body["line"].get("fill_color") is not None + else "" + ) + emit(f"{artist}.set_fill({target!r}{opts})", force=True) + if "fill" in body and "matrix" in body: + fill = body["fill"] + emit( + f"{artist}.set_field_fill(mode={fill['mode'].lower()!r}, shading={fill['shading'].lower()!r}, alpha={fill['opacity']!r})", + force=True, + ) + for index, cfg in enumerate(panels): + ax = "_ax" if variable != "_ax" else "_axes" + if index: + emit( + f"{ax} = {variable}.subplots[{settings['identity']['panels'][index]['id']!r}].axes" + ) + elif len(panels) > 1: + emit(f"{ax} = {variable}.axes[0]") + legend = cfg["legend"] + rich = legend["content"] + loc = { + "TopLeft": "upper left", + "TopRight": "upper right", + "BottomLeft": "lower left", + "BottomRight": "lower right", + }[legend["corner"]] + baseline = eval(ax, namespace)._config(model.get_settings())["legend"] + args = [] if legend["visible"] else ["False"] + if legend["corner"] != baseline["corner"]: + args.append(f"loc={loc!r}") + if rich["font_size"] != baseline["content"]["font_size"]: + args.append(f"fontsize={_points(rich['font_size'], dpi)!r}") + if rich["font"] != baseline["content"]["font"] and font_family_available( + rich["font"] + ): + args.append(f"prop={{'family': {rich['font']!r}}}") + if rich["color"] != baseline["content"]["color"]: + args.append(f"labelcolor={color(rich['color'])!r}") + if legend["padding"] != baseline["padding"]: + args.append(f"borderpad={legend['padding'] / rich['font_size']!r}") + if legend["bg_color"] != baseline["bg_color"]: + args.append(f"facecolor={color(legend['bg_color'])!r}") + if legend["border_color"] != baseline["border_color"]: + args.append(f"edgecolor={color(legend['border_color'])!r}") + emit(f"{ax}.legend({', '.join(args)})") + emit( + f"{ax}.legend_handle.content.set(text={rich_arg(rich)}, multialignment={rich.get('align', 'Left').lower()!r})" + ) + if legend["offset_x"] or legend["offset_y"]: + emit( + f"{ax}.legend_handle.set_offset({(_points(legend['offset_x'], dpi), _points(legend['offset_y'], dpi))!r})" + ) + for index, cfg in enumerate(panels): + parent = ( + f"{variable}.subplots[{settings['identity']['panels'][index]['id']!r}].axes" + ) + for oid, layer in zip( + settings["identity"]["panels"][index]["overlays"], cfg.get("overlays", []) + ): + target = f"{parent}.get_overlay({oid!r})" + node = layer["y_axis"] + axis = target + ".yaxis" + low, high = node["min"], node["max"] + emit(f"{axis}.set_view_interval({low!r}, {high!r})", force=True) + emit( + f"{axis}.set_scale({'log' if node['scale'] == 'Logarithmic' else 'linear'!r})", + force=True, + ) + emit(f"{axis}.set_inverted({node['inverted']!r})", force=True) + locator = ( + f"{ticker_names['MultipleLocator']}({node['major_spacing']!r})" + if node["scale"] == "Linear" + else f"{ticker_names['LogLocator']}(base={10 ** node['major_spacing']!r})" + ) + emit(f"{axis}.set_major_locator({locator})", force=True) + emit( + f"{axis}.set_minor_locator({ticker_names['AutoMinorLocator']}({node['minor_count'] + 1}))", + force=True, + ) + label = node["label_style"] + emit( + f"{axis}.set_major_formatter({_formatter_expression(label, ticker_names)})", + force=True, + ) + direction = dict(Inside="in", Outside="out", Both="inout", None_="in").get( + node["tick"], "in" + ) + emit( + f"{axis}.set_tick_params(which='major', direction={direction!r}, length={_points(node['major_tick_length'], dpi)!r}, labelsize={_points(label['font_size'], dpi)!r}, labelcolor={color(label['color'])!r}, labelfontfamily={label['label_font']!r}, right={node['tick'] != 'None'!r}, labelright={label['label_visible']!r})", + force=True, + ) + emit( + f"{axis}.set_tick_params(which='minor', length={_points(node['minor_tick_length'], dpi)!r})", + force=True, + ) + emit(f"{axis}.set_ticklabels_enabled({label['visible']!r})", force=True) + emit( + f"{axis}.set_layout_margin({_points(node['out_margin'], dpi)!r})", + force=True, + ) + emit( + f"{axis}.set_ticklabel_offset({(_points(label['label_offset_x'], dpi), _points(label['label_offset_y'], dpi))!r})", + force=True, + ) + emit( + f"{axis}.line.set(visible={node['line_visible']!r}, color={color(node['line_color'])!r}, linewidth={_points(node['line_width'], dpi)!r}, linestyle={dict(Solid='-', Dash='--', Dot=':', DashDot='-.')[node['line_style']]!r})", + force=True, + ) + emit( + f"{axis}.line.set_position(('outward', {_points(node['line_offset'], dpi)!r}))", + force=True, + ) + title = node["title_option"] + rich = title["text"] + emit( + f"{target}.set_ylabel({rich_arg(rich)}, fontsize={_points(rich['font_size'], dpi)!r}, color={color(rich['color'])!r}, fontfamily={rich['font']!r}, visible={title['visible']!r}, multialignment={rich.get('align', 'Left').lower()!r})", + force=True, + ) + emit( + f"{axis}.label.set_offset({(_points(title['offset_x'], dpi), _points(title['offset_y'], dpi))!r})", + force=True, + ) + legend = layer.get("legend") + if legend is None: + emit(f"{target}.clear_legend()", force=True) + else: + rich = legend["content"] + loc = dict( + TopLeft="upper left", + TopRight="upper right", + BottomLeft="lower left", + BottomRight="lower right", + )[legend["corner"]] + emit( + f"{target}.legend({legend['visible']!r}, loc={loc!r}, fontsize={_points(rich['font_size'], dpi)!r}, labelcolor={color(rich['color'])!r}, prop={{'family': {rich['font']!r}}}, borderpad={legend['padding'] / rich['font_size']!r}, facecolor={color(legend['bg_color'])!r}, edgecolor={color(legend['border_color'])!r})", + force=True, + ) + emit( + f"{target}.legend_handle.content.set(text={rich_arg(rich)}, multialignment={rich.get('align', 'Left').lower()!r})", + force=True, + ) + emit( + f"{target}.legend_handle.set_offset({(_points(legend['offset_x'], dpi), _points(legend['offset_y'], dpi))!r})", + force=True, + ) + from ._annotation_codegen import annotation_calls + + annotations = settings["config"].get("annotations") + if annotations: + for call in annotation_calls( + annotations, f"{variable}.axes[0]", variable, dpi, rich_arg + ): + emit(call, force=True) + # Color scales use the same public shared object whether mapped or standalone. + for index, cfg in enumerate(panels): + ax = ( + f"{variable}.axes[0]" + if index == 0 + else f"{variable}.subplots[{settings['identity']['panels'][index]['id']!r}].axes" + ) + polar = cfg.get("polar") + if polar: + interpolation = polar["interpolation"] + emit( + f"{ax}.set_polar_options(unit={polar['angle_unit'].lower()!r}, zero={polar['zero_angle_degrees']!r}, clockwise={polar['clockwise']!r}, sector={polar['sector']!r}, inner_radius={polar['inner_radius_fraction']!r}, interpolation={'chord' if interpolation == 'Chord' else 'arc'!r}, pieces={1 if interpolation == 'Chord' else interpolation['Arc']['pieces']!r}, label_angle={polar['radial_label_angle_degrees']!r})", + force=True, + ) + else: + emit(f"{ax}.clear_polar()", force=True) + bar = cfg.get("colorbar") + if not bar: + emit(f"{ax}.clear_colorbar()", force=True) + continue + cmap = {"GrayScale": "gray", "RdBu": "RdBu"}.get( + bar["colormap"], bar["colormap"].lower() + ) + emit( + f"{ax}.add_colorbar(cmap={cmap!r}, vmin={bar['axis']['min']!r}, vmax={bar['axis']['max']!r})", + force=True, + ) + scale = f"{ax}.get_colorbar()" + axis = scale + ".ax.yaxis" + node = bar["axis"] + label = node["label_style"] + title = node["title_option"] + emit( + f"{scale}.set_layout(side={bar['side'].lower()!r}, align={bar['align'].lower()!r}, fraction={bar['length_frac']!r}, thickness={_points(bar['thickness_px'], dpi)!r}, gap={_points(bar['gap_px'], dpi)!r}, offset={(_points(bar['offset_x'], dpi), _points(bar['offset_y'], dpi))!r})" + ) + emit(f"{scale}.set_nan_color({color(bar['nan_color'])!r})") + emit( + f"{scale}.set_outline(color={color(bar['border_color'])!r}, linewidth={_points(bar['border_width'], dpi)!r})" + ) + emit(f"{scale}.set_visible({bar['visible']!r})") + emit( + f"{axis}.set_scale({'log' if node['scale'] == 'Logarithmic' else 'linear'!r})", + force=True, + ) + emit(f"{axis}.set_inverted({node['inverted']!r})") + locator = ( + f"{ticker_names['MultipleLocator']}({node['major_spacing']!r})" + if node["scale"] == "Linear" + else f"{ticker_names['LogLocator']}(base={10 ** node['major_spacing']!r})" + ) + emit(f"{axis}.set_major_locator({locator})") + emit( + f"{axis}.set_minor_locator({ticker_names['AutoMinorLocator']}({node['minor_count'] + 1}))" + ) + emit( + f"{axis}.set_major_formatter({_formatter_expression(label, ticker_names)})" + ) + side = "right" if bar["side"] in ("Left", "Right") else "bottom" + direction = {"Inside": "in", "Outside": "out", "Both": "inout", "None": "in"}[ + node["tick"] + ] + emit( + f"{axis}.set_tick_params(which='major', direction={direction!r}, length={_points(node['major_tick_length'], dpi)!r}, labelsize={_points(label['font_size'], dpi)!r}, labelcolor={color(label['color'])!r}, labelfontfamily={label['label_font']!r}, {side}={node['tick'] != 'None'!r}, label{side}={label['label_visible']!r})" + ) + emit( + f"{axis}.set_tick_params(which='minor', length={_points(node['minor_tick_length'], dpi)!r})" + ) + emit(f"{axis}.set_ticklabels_enabled({label['visible']!r})") + emit( + f"{axis}.set_ticklabel_offset({(_points(label['label_offset_x'], dpi), _points(label['label_offset_y'], dpi))!r})" + ) + emit(f"{axis}.set_layout_margin({_points(node['out_margin'], dpi)!r})") + emit( + f"{axis}.line.set(visible={node['line_visible']!r}, color={color(node['line_color'])!r}, linewidth={_points(node['line_width'], dpi)!r}, linestyle={dict(Solid='-', Dash='--', Dot=':', DashDot='-.')[node['line_style']]!r})" + ) + emit( + f"{axis}.line.set_position(('outward', {_points(node['line_offset'], dpi)!r}))" + ) + rich = title["text"] + emit( + f"{scale}.set_label({rich_arg(rich)}, fontsize={_points(rich['font_size'], dpi)!r}, color={color(rich['color'])!r}, fontfamily={rich['font']!r}, visible={title['visible']!r})" + ) + emit( + f"{axis}.label.set_offset({(_points(title['offset_x'], dpi), _points(title['offset_y'], dpi))!r})" + ) + active = settings["config"].get("active_layer", "Base") + if active == "Annotations": + emit(f"{variable}.axes[0].activate_annotations()") + else: + panel_index, overlay_index = 0, None + if isinstance(active, dict): + name, value = next(iter(active.items())) + if name == "Subplot": + panel_index = value + 1 + elif name == "Overlay": + overlay_index = value + elif name == "SubplotOverlay": + panel_index, overlay_index = value[0] + 1, value[1] + panel = settings["identity"]["panels"][panel_index] + target = f"{variable}.subplots[{panel['id']!r}].axes" + if overlay_index is not None: + target += f".get_overlay({panel['overlays'][overlay_index]!r})" + emit(target + ".activate()") + # Guard fidelity: advanced fields must remain present, never disappear because + # they lack a Matplotlib counterpart. Exact mode is the standalone full dump. + from ._native import canonical_style_state_json + + restored = json.loads( + canonical_style_state_json(json.dumps(model.get_settings(), allow_nan=False)) + ) + config_patch = _prune_empty(_diff(restored["config"], settings["config"])) + series_patch = _prune_empty( + { + s["series_id"]: _diff(a, s) + for a, s in zip(restored["series"], settings["series"]) + if a != s + } + ) + # Fields not represented by the public artist setters must be exported even + # when the compiler's source-based view already contains them. Otherwise a + # destination's edited mapping/override would silently leak into the replay. + for spec in settings["series"]: + kind, body = next(iter(spec["render_type"].items())) + advanced = copy.deepcopy(body) + for part, keys in [ + ( + "line", + ( + "line_color", + "line_width", + "line_style", + "interpolation", + "fill", + "fill_color", + ), + ), + ("scatter", ("point_color", "point_size", "point_shape")), + ( + "bar", + ( + "fill_color", + "border_color", + "border_width", + "baseline", + "width_ratio", + "gap_px", + "orientation", + ), + ), + ( + "err_style", + ( + "error_bar_color", + "error_bar_width", + "error_bar_cap_size", + "cap_width", + ), + ), + ]: + if part in advanced: + for key in keys: + advanced[part].pop(key, None) + if not advanced[part]: + advanced.pop(part) + if "matrix" in body: + advanced.pop("fill", None) + for part, (key, _, _, prefix) in _PARTS.items(): + if key not in advanced: + continue + for name in ( + prefix + "_style_table", + prefix + "_style_index_column", + prefix + "_style_overrides", + ): + advanced[key].pop(name, None) + if key == "scatter": + advanced[key].pop("point_color_map", None) + advanced[key].pop("point_size_map", None) + if not advanced[key]: + advanced.pop(key) + if "components" in body: + advanced.pop("style", None) + for binding in ("matrix", "components", "err_x", "err_y"): + advanced.pop(binding, None) + if "contour" in advanced: + extra = { + key: value + for key, value in advanced["contour"].get("line", {}).items() + if key not in ("line_color", "line_width", "line_style") + } + if extra: + advanced["contour"] = {"line": extra} + else: + advanced.pop("contour") + if advanced: + patch = ( + series_patch.setdefault(spec["series_id"], {}) + .setdefault("render_type", {}) + .setdefault(kind, {}) + ) + from .figure import _merge + + _merge(patch, advanced) + # Editor/identity records and unhandled native extension fields remain + # lossless. All supported visual controls above use public object calls. + config_patch.pop("subplots", None) + panel_patches = [] + restored_panels = [restored["config"]] + [ + p["config"] for p in restored["config"].get("subplots", []) + ] + for index, (baseline, cfg) in enumerate(zip(restored_panels, panels)): + patch = config_patch if index == 0 else _prune_empty(_diff(baseline, cfg)) + # The compiler starts from the source document, so resident editor + # records can look unchanged even when absent from the destination. + # Retain explicit selections as diagnostic state, independent of the + # public visual setters and without copying their scientific values. + for key in ("picked_points", "picked_data"): + if cfg.get(key) is not None: + patch[key] = copy.deepcopy(cfg[key]) + if index and patch: + panel_patches.append((index, patch)) + if config_patch or series_patch: + parts = [] + if config_patch: + parts.append("config=" + fmt(config_patch)) + if series_patch: + parts.append("series=" + fmt(series_patch)) + lines.append("# Exact internal state outside public appearance controls.") + lines.append(f"{variable}.configure({', '.join(parts)})") + for index, patch in panel_patches: + panel_id = settings["identity"]["panels"][index]["id"] + lines.append( + f"{variable}.subplots[{panel_id!r}].axes.configure(**{fmt(patch)})" + ) + # Numeric locators above reproduce the accepted layout, but also select + # fixed future ticks. Restore the source policy last, after residual + # spacing patches, using the public policy setter that does not re-fit. + modes = settings['identity'].get('tick_modes', {}) + for panel in settings['identity']['panels']: + owner = panel['id'] + target = f"{variable}.subplots[{owner!r}].axes" + for side, key, dimension in [('bottom', 'bottom_x', 'x'), ('top', 'top_x', 'x'), + ('left', 'left_y', 'y'), ('right', 'right_y', 'y')]: + policy = modes.get(owner, {}).get(key) + value = None if policy is None else policy == 'Auto' + emit(f"{target}.secondary_{dimension}axis({side!r}).{dimension}axis.set_tick_auto({value!r})") + for overlay in panel['overlays']: + policy = modes.get(overlay, {}).get('y_axis') + value = None if policy is None else policy == 'Auto' + emit(f"{target}.get_overlay({overlay!r}).yaxis.set_tick_auto({value!r})") + used = [ + name + (" as " + ticker_names[name] if ticker_names[name] != name else "") + for name in ticker_classes + if any(ticker_names[name] + "(" in line for line in lines) + ] + imports = ("from figgyplot.ticker import " + ", ".join(used) + "\n") if used else "" + used_styles = [ + name + (" as " + style_names[name] if style_names[name] != name else "") + for name in style_classes + if any(style_names[name] + "(" in line for line in lines) + ] + if any(run_name + "(" in line for line in lines): + imports += f"from figgyplot import TextRun as {run_name}\n" + if used_styles: + imports += "from figgyplot import " + ", ".join(used_styles) + "\n" + if any(segments_name + "(" in line for line in lines): + imports += f"from figgyplot import text_segments as {segments_name}\n" + return ( + imports + + "\n# Apply to existing matching panels and stable artist IDs; source arrays stay resident.\n" + + f"with {variable}.batch_update():\n" + + "\n".join(" " + line.replace("\n", "\n ") for line in lines) + + "\n" + ) + + +def _bounded_api_code(settings, defaults, dpi, variable): + """Keep ordinary bounded appearance editable without exposing chart structs.""" + from ._charts import family + from ._matplotlib import _StateFigure, canvas_size + from .figure import _diff, _code_tree, _text, _segments, text_segments + from ._native import canonical_style_state_json, font_family_available + + kind = family(settings) + chart = copy.deepcopy(settings[kind]) + if kind == "radial": + chart["interaction"] = dict(hovered=None, selected=None, hover_progress=0.0) + else: + chart["hovered"] = chart["selected"] = None + chart["decorations"]["selected"] = None + model = _StateFigure(settings, defaults, dpi) + axname = "_ax" if variable != "_ax" else "_axes" + runs = "_fg_text_segments" if variable != "_fg_text_segments" else "_fg_text_runs" + namespace = {variable: model, runs: text_segments} + from ._richtext import TextRun, run_expression + + run_name = "TextRun" if variable != "TextRun" else "_fg_TextRun" + namespace[run_name] = TextRun + + def rich_arg(rich): + plain = _text(rich) + return ( + repr(plain) + if rich["segments"] == _segments(plain) + else run_expression(rich["segments"], dpi, run_name) + ) + + lines = [] + unit = 72 / dpi + + def fmt(value): + return pprint.pformat(_code_tree(value, runs), width=96, sort_dicts=False) + + def color(value): + return _color_literal(value) + + def emit(code, force=False): + before = model._state + exec(code, namespace) + if force or before != model._state or " = " in code: + lines.append(code) + + emit(f"{axname} = {variable}.axes[0]") + appearance = axname + ".chart" + emit(f"{appearance}.reset_style()", force=True) + width, height = canvas_size(settings) + emit(f"{variable}.set_size_inches({width / dpi!r}, {height / dpi!r})", force=True) + baseline = model.get_settings()[kind] + shared = [] + if chart["font_size"] != baseline["font_size"]: + shared.append(f"fontsize={_points(chart['font_size'], dpi)!r}") + if chart["font_family"] != baseline["font_family"] and font_family_available( + chart["font_family"] + ): + shared.append(f"fontfamily={chart['font_family']!r}") + text_color = "label_color" if kind == "radial" else "text_color" + if chart[text_color] != baseline[text_color]: + shared.append(f"color={color(chart[text_color])!r}") + if shared: + emit(f"{appearance}.set_label_style({', '.join(shared)})") + if chart["background"] != baseline["background"]: + emit(f"{variable}.set_facecolor({color(chart['background'])!r})") + if kind != "radar": + material = {"BrushedMetal": "brushed_metal", "SatinMetal": "satin_metal"}.get( + chart["style"]["material"], chart["style"]["material"].lower() + ) + emit(f"{appearance}.set_material({material!r})") + from ._appearance import style_values + from ._chart_artist import _LABELS + + options = style_values(kind, chart["style"], dpi) + current = eval(appearance, namespace).get_style() + options = {key: value for key, value in options.items() if value != current[key]} + if options: + emit( + f"{appearance}.set_style(" + + ", ".join(f"{key}={value!r}" for key, value in options.items()) + + ")" + ) + label_options = {} + if "label_format" in chart: + from ._chart_artist import _FORMATS + + formats = {v: k for k, v in _FORMATS.items()} + label_options["format"] = formats[chart["label_format"]] + for public, key in [ + ("position", "labels"), + ("decimals", "label_decimals"), + ("suffix", "value_suffix"), + ]: + if key in chart: + label_options[public] = ( + {v: k for k, v in _LABELS[kind].items()}[chart[key]] + if public == "position" + else chart[key] + ) + emit( + f"{appearance}.set_value_labels(" + + ", ".join(f"{key}={value!r}" for key, value in label_options.items()) + + ")" + ) + if kind == "radial": + emit(f"{appearance}.set_startangle({-chart['start_angle_degrees']!r})") + inner = ( + None if chart["kind"] == "Pie" else chart["kind"]["Donut"]["inner_radius"] + ) + emit(f"{appearance}.set_inner_radius({inner!r})") + for index, item in enumerate(chart["slices"]): + emit( + f"{appearance}.set_slice({index}, label={item['label']!r}, color={color(item['color'])!r}, explode={item['explode']!r})", + force=True, + ) + else: + if kind in ("categorical", "boxplot"): + emit(f"{appearance}.set_orientation({chart['direction'].lower()!r})") + emit(f"{appearance}.set_group_width({chart['group_width']!r})") + gap = chart["bar_gap" if kind == "categorical" else "box_gap"] + emit(f"{appearance}.set_gap({_points(gap, dpi)!r})") + if kind == "categorical": + mode = { + "Grouped": "grouped", + "Stacked": "stacked", + "PercentStacked": "percent_stacked", + }[chart["mode"]] + emit(f"{appearance}.set_mode({mode!r})") + xname, yname = ( + (chart["value_title"], chart["category_title"]) + if chart["direction"] == "Horizontal" + else (chart["category_title"], chart["value_title"]) + ) + emit(f"{axname}.set_xlabel({xname!r})") + emit(f"{axname}.set_ylabel({yname!r})") + if chart.get("value_range") is not None: + dimension = "x" if chart.get("direction") == "Horizontal" else "y" + emit(f"{axname}.set_{dimension}lim({tuple(chart['value_range'])!r})") + emit(f"{axname}.grid({chart['grid']!r})") + emit(f"{axname}.legend({chart['legend']!r})") + for item in chart["series"]: + emit( + f"{appearance}.set_series({item['id']!r}, label={item['label']!r}, color={color(item['color'])!r})", + force=True, + ) + for item in chart["categories"]: + emit( + f"{appearance}.set_category_label({item['id']!r}, {item['label']!r})", + force=True, + ) + title = chart.get("chart_title") + if title is not None: + rich = title["text"] + plain = _text(rich) + rebuilt = _segments(plain) + styles = [] + for flag, prop, value in [ + ("bold", "fontweight", "bold"), + ("italic", "fontstyle", "italic"), + ]: + if rich["segments"] and all(s.get(flag) is True for s in rich["segments"]): + for segment in rebuilt: + segment[flag] = True + styles.append(f"{prop}={value!r}") + value = repr(plain) if rebuilt == rich["segments"] else rich_arg(rich) + args = [ + value, + f"fontsize={_points(rich['font_size'], dpi)!r}", + f"color={color(rich['color'])!r}", + f"visible={title['visible']!r}", + ] + if rebuilt == rich["segments"]: + args += styles + if font_family_available(rich["font"]): + args.append(f"fontfamily={rich['font']!r}") + if "align" in rich: + args.append(f"multialignment={rich['align'].lower()!r}") + emit(f"{axname}.set_title({', '.join(args)})") + + # Every item is addressed by stable identity; resident values are never exported. + def element_appearance(target, item): + if item.get("style") is not None: + if kind == "radar": + from ._appearance import convert + + fields = dict( + linewidth=("line_width_px", "length"), + facecolor=("fill", "color"), + marker=("marker", "shape"), + markersize=("marker_size_px", "length"), + ) + options = { + name: None + if item["style"].get(key) is None + else convert(item["style"][key], conversion, dpi, decode=True) + for name, (key, conversion) in fields.items() + } + else: + options = style_values(kind, item["style"], dpi) + shared = style_values(kind, chart["style"], dpi) + options = { + key: value for key, value in options.items() if value != shared[key] + } + if kind == "radar": + options = { + key: value for key, value in options.items() if value is not None + } + options = { + key: _color_literal(dict(zip("rgba", value))) + if isinstance(value, tuple) and len(value) == 4 + else value + for key, value in options.items() + } + emit( + f"{target}.set_style(" + + ", ".join(f"{key}={value!r}" for key, value in options.items()) + + ")", + force=True, + ) + if "labels" in item: + options = dict( + position=None + if item["labels"] is None + else {v: k for k, v in _LABELS[kind].items()}[item["labels"]] + ) + if "label_format" in item: + from ._chart_artist import _FORMATS + + options["format"] = ( + None + if item["label_format"] is None + else {v: k for k, v in _FORMATS.items()}[item["label_format"]] + ) + if "label_color" in item: + options["color"] = ( + None if item["label_color"] is None else color(item["label_color"]) + ) + emit( + f"{target}.set_value_labels(" + + ", ".join(f"{key}={value!r}" for key, value in options.items()) + + ")", + force=True, + ) + + if kind == "radial": + for index, item in enumerate(chart["slices"]): + element_appearance(f"{appearance}.slice({index})", item) + if chart.get("split"): + split = chart["split"] + inner = ( + None + if split["kind"] == "Pie" + else split["kind"]["Donut"]["inner_radius"] + ) + emit(f"{appearance}.set_split_inner_radius({inner!r})", force=True) + for index, item in enumerate(split["children"]): + target = f"{appearance}.slice({index}, detail=True)" + emit(f"{target}.set_label({item['label']!r})", force=True) + emit(f"{target}.set_color({color(item['color'])!r})", force=True) + emit(f"{target}.set_explode({item['explode']!r})", force=True) + element_appearance(target, item) + else: + for item in chart["series"]: + element_appearance(f"{appearance}.series({item['id']!r})", item) + for item in chart.get("overrides", []): + target = item["target"] + handle = ( + f"{appearance}.cell({target['category_id']!r}, {target['series_id']!r})" + ) + emit( + f"{handle}.set_color({None if item['color'] is None else color(item['color'])!r})", + force=True, + ) + element_appearance(handle, item) + if kind == "radar": + emit(f"{appearance}.set_ring_spacing({chart['ring_spacing']!r})") + bounds = chart["decorations"]["data_area"] + if bounds: + emit( + f"{appearance}.set_plot_bounds({tuple(_points(bounds[k], dpi) for k in ('x', 'y', 'width', 'height'))!r})" + ) + for offset in chart["decorations"]["offsets"]: + element = offset["element"] + names = dict(DataArea="data", ChartTitle="title", Legend="legend") + if isinstance(element, str): + args = repr(names[element]) + else: + name, value = next(iter(element.items())) + if name in ("Axis", "AxisLabel", "AxisTitle"): + args = f"{dict(Axis='axis', AxisLabel='ticklabels', AxisTitle='axis_title')[name]!r}, side={value.lower()!r}" + elif name == "DataLabel": + args = f"'value_label', category={value['category_id']!r}, series={value['series_id']!r}" + elif name == "SpokeLabel": + args = f"'spoke_label', category={value['category_id']!r}" + elif name == "RadialLabel": + args = f"'slice_label', index={value['index']!r}, detail={bool(value['group'])!r}" + elif name == "Annotation": + args = f"'annotation', index={value!r}" + else: + raise ValueError("unsupported bounded decoration; use exact recovery") + emit( + f"{appearance}.decoration({args}).set_offset({tuple(_points(v, dpi) for v in offset['offset'])!r})", + force=True, + ) + if chart.get("annotations"): + from ._annotation_codegen import annotation_calls + + for call in annotation_calls( + chart["annotations"], axname, variable, dpi, rich_arg + ): + emit(call, force=True) + if chart.get("active_layer", "Base") == "Annotations": + emit(f"{axname}.activate_annotations()") + restored = json.loads( + canonical_style_state_json(json.dumps(model.get_settings(), allow_nan=False)) + )[kind] + + # Strip resident values before computing any residual. No data values may + # appear in an appearance recipe, even when the destination data differ. + def appearance_only(value): + value = copy.deepcopy(value) + if kind == "radial": + value["slices"] = { + i: {k: v for k, v in item.items() if k != "value"} + for i, item in enumerate(value["slices"]) + } + if value.get("split"): + value["split"]["children"] = { + i: {k: v for k, v in item.items() if k != "value"} + for i, item in enumerate(value["split"]["children"]) + } + else: + value["series"] = { + item["id"]: {k: v for k, v in item.items() if k not in ("id", "values")} + for item in value["series"] + } + return value + + residual = _prune_empty(_diff(appearance_only(restored), appearance_only(chart))) + if residual: + lines.append( + "# Renderer-specific materials/decorations without Matplotlib equivalents." + ) + if kind == "radial": + lines.append(f"{variable}.configure_radial(**{fmt(residual)})") + else: + lines.append(f"{variable}.configure_bounded({kind!r}, **{fmt(residual)})") + imports = ( + f"from figgyplot import text_segments as {runs}\n" + if any(runs + "(" in line for line in lines) + else "" + ) + if any(run_name + "(" in line for line in lines): + imports += f"from figgyplot import TextRun as {run_name}\n" + return ( + imports + + "# Apply appearance to an existing chart with matching category/series IDs.\n" + + f"with {variable}.batch_update():\n" + + "\n".join(" " + line.replace("\n", "\n ") for line in lines) + + "\n" + ) diff --git a/python/figgyplot/_appearance.py b/python/figgyplot/_appearance.py new file mode 100644 index 0000000..37e1447 --- /dev/null +++ b/python/figgyplot/_appearance.py @@ -0,0 +1,156 @@ +"""Private translations for public appearance controls, with point-based lengths.""" + +import copy + + +# Public names are independent of serialized containers and pixel suffixes. +_COMMON = { + "material": ("material", "material"), + "texture_strength": ("texture_strength", "number"), + "texture_scale": ("texture_scale", "number"), + "gloss": ("gloss", "number"), + "emphasis_brightness": ("emphasis_brightness", "number"), +} +_BOUNDED = { + "categorical": dict( + _COMMON, + corner_radius=("corner_radius", "length"), + outline=("outline", "bool"), + linewidth=("outline_width", "length"), + edgecolor=("outline_color", "color"), + ), + "boxplot": dict( + _COMMON, + corner_radius=("corner_radius", "length"), + outline=("outline", "bool"), + linewidth=("outline_width", "length"), + edgecolor=("outline_color", "color"), + median_width=("median_width", "length"), + median_color=("median_color", "color"), + whisker_width=("whisker_width", "length"), + whisker_color=("whisker_color", "color"), + caps=("caps", "bool"), + cap_ratio=("cap_ratio", "number"), + notch=("notched", "bool"), + notch_depth=("notch_depth", "number"), + showmeans=("show_mean", "bool"), + mean_size=("mean_size", "length"), + mean_color=("mean_color", "color"), + showfliers=("show_outliers", "bool"), + fliersize=("outlier_size", "length"), + fliercolor=("outlier_color", "color"), + fliermarker=("outlier_shape", "shape"), + flierfilled=("outlier_filled", "bool"), + ), + "radial": { + **{k: v for k, v in _COMMON.items() if k != "emphasis_brightness"}, + "tilt": ("tilt_degrees", "number"), + "depth": ("depth", "number"), + "bevel": ("bevel", "number"), + "gap": ("gap_degrees", "number"), + "roughness": ("roughness", "number"), + "light": ("light", "vector"), + "shadow": ("shadow", "bool"), + "inner_corner": ("inner_corner", "number"), + "outer_corner": ("outer_corner", "number"), + "rim": ("outline/rim", "bool"), + "separators": ("outline/separators", "bool"), + "emphasis": ("outline/emphasis", "bool"), + "linewidth": ("outline/width", "length"), + "edgecolor": ("outline/color", "color"), + "hover_lift": ("hover_lift", "number"), + "hover_brightness": ("hover_brightness", "number"), + "texture_angle": ("texture_angle_degrees", "number"), + }, + "radar": { + "startangle": ("start_angle_degrees", "angle"), + "clockwise": ("clockwise", "bool"), + "grid_shape": ("grid_shape", "title"), + "spokecolor": ("spoke_color", "color"), + "ringcolor": ("ring_color", "color"), + "linewidth": ("line_width_px", "length"), + "fill_alpha": ("fill_opacity", "number"), + "marker": ("marker", "shape"), + "markersize": ("marker_size_px", "length"), + "emphasis_brightness": ("emphasis_brightness", "number"), + }, +} +_SHAPES = { + "o": "Circle", + "s": "Square", + "^": "Triangle", + "D": "Diamond", + "x": "Cross", + "+": "Plus", + "*": "Star", +} + + +def convert(value, kind, dpi, *, decode=False): + from .figure import _color, _material + from ._validation import number as _number + + if decode: + if kind == "color": + return tuple(value[k] for k in "rgba") + if kind == "length": + return value * 72 / dpi + if kind == "angle": + return -value + if kind == "material": + return {"BrushedMetal": "brushed_metal", "SatinMetal": "satin_metal"}.get( + value, value.lower() + ) + if kind == "shape": + return {v: k for k, v in _SHAPES.items()}.get(value, value.lower()) + if kind == "title": + return value.lower() + return copy.deepcopy(value) + if kind == "color": + return _color(value) + if kind == "material": + return _material(value) + if kind == "bool": + if not isinstance(value, bool): + raise TypeError("visibility and style switches require bool") + return value + if kind == "shape": + return _SHAPES.get(value, str(value).title()) + if kind == "title": + return str(value).title() + if kind == "vector": + values = [_number(v, "light direction") for v in value] + if len(values) != 3: + raise ValueError("light must contain three components") + return values + number = _number(value, "style value") + if kind == "length": + if number < 0: + raise ValueError("style lengths must be nonnegative") + return number * dpi / 72 + return -number if kind == "angle" else number + + +def style_values(kind, style, dpi): + result = {} + for name, (path, conversion) in _BOUNDED[kind].items(): + value = style + for key in path.split("/"): + value = value[key] + result[name] = convert(value, conversion, dpi, decode=True) + return result + + +def edit_style(kind, style, dpi, properties): + unknown = set(properties) - _BOUNDED[kind].keys() + if unknown: + raise ValueError( + "unsupported " + kind + " style options: " + ", ".join(sorted(unknown)) + ) + for name, value in properties.items(): + path, conversion = _BOUNDED[kind][name] + keys = path.split("/") + node = style + for key in keys[:-1]: + node = node[key] + node[keys[-1]] = convert(value, conversion, dpi) diff --git a/python/figgyplot/_artist_appearance.py b/python/figgyplot/_artist_appearance.py new file mode 100644 index 0000000..86fbdbe --- /dev/null +++ b/python/figgyplot/_artist_appearance.py @@ -0,0 +1,611 @@ +"""Named styles, resident data references and per-element appearance controls.""" + +import copy +import json +from dataclasses import dataclass + + +@dataclass(frozen=True) +class MarkerStyle: + color: object = None + marker: object = None + markersize: object = None + fillstyle: object = None + + +@dataclass(frozen=True) +class ErrorBarStyle: + color: object = None + linewidth: object = None + capsize: object = None + capthick: object = None + + +@dataclass(frozen=True) +class BinStyle: + facecolor: object = None + edgecolor: object = None + linewidth: object = None + gap: object = None + width: object = None + + +_PARTS = { + "markers": ( + "scatter", + MarkerStyle, + dict( + color=("point_color", "color"), + marker=("point_shape", "marker"), + markersize=("point_size", "diameter"), + ), + "point", + ), + "errors": ( + "err_style", + ErrorBarStyle, + dict( + color=("error_bar_color", "color"), + linewidth=("error_bar_width", "length"), + capsize=("error_bar_cap_size", "length"), + capthick=("cap_width", "length"), + ), + "error_bar", + ), + "bins": ( + "bar", + BinStyle, + dict( + facecolor=("fill_color", "color"), + edgecolor=("border_color", "color"), + linewidth=("border_width", "length"), + gap=("gap_px", "length"), + width=("width_ratio", "number"), + ), + "bar", + ), +} + + +def _style(value, part, dpi, decode=False): + from .figure import _color, _MARKERS + from ._validation import number as _number + + _, cls, fields, _ = _PARTS[part] + if not decode and not isinstance(value, cls): + raise TypeError("expected " + cls.__name__) + result = {} + for name, (key, kind) in fields.items(): + item = value.get(key) if decode else getattr(value, name) + if item is None: + continue + if decode: + if kind == "color": + item = tuple(item[c] for c in "rgba") + elif kind == "marker": + from ._markers import decode_marker + + item, fillstyle = decode_marker(item) + if fillstyle is not None: + result["fillstyle"] = fillstyle + elif kind in ("length", "diameter"): + item = item * (144 if kind == "diameter" else 72) / dpi + result[name] = item + else: + if kind == "color": + item = _color(item) + elif kind == "marker": + item = _MARKERS.get(item, item) + else: + item = _number(item, name) + if item < 0: + raise ValueError(name + " must be nonnegative") + if kind in ("length", "diameter"): + item *= dpi / (144 if kind == "diameter" else 72) + result[key] = item + if not decode and part == "markers" and value.fillstyle is not None: + from ._markers import encode_marker + + if value.marker is None: + raise ValueError("fillstyle requires an explicit marker") + result["point_shape"] = encode_marker(value.marker, value.fillstyle) + return cls(**result) if decode else result + + +@dataclass(frozen=True) +class ColumnReference: + figure: object + name: str + + def __repr__(self): + return "ColumnReference(" + repr(self.name) + ")" + + +@dataclass(frozen=True) +class GridReference: + figure: object + columns: tuple + axis: str = "y" + coordinates: str = "centers" + + def _native(self): + if self.axis not in ("x", "y") or self.coordinates not in ("centers", "edges"): + raise ValueError("grid axis is x/y; coordinates are centers/edges") + return dict( + columns=[_column(v, self.figure) for v in self.columns], + orientation="ColumnsAreY" if self.axis == "y" else "ColumnsAreX", + grid_layout=self.coordinates.title(), + ) + + +def _column(value, figure): + from ._document import identifier + + if isinstance(value, ColumnReference): + if value.figure is not figure: + raise ValueError("column belongs to another Figure") + return value.name + if not isinstance(value, str): + raise TypeError("source must be a resident column reference or column name") + return identifier(value) + + +def _grid(value, figure): + return GridReference( + figure, + tuple(value["columns"]), + "y" if value["orientation"] == "ColumnsAreY" else "x", + value["grid_layout"].lower(), + ) + + +class DataRegistry: + def __init__(self, figure): + self.figure = figure + + def column(self, name): + return ColumnReference(self.figure, _column(name, self.figure)) + + def __getitem__(self, name): + return self.column(name) + + def grid(self, columns, *, axis="y", coordinates="centers"): + result = GridReference(self.figure, tuple(columns), axis, coordinates) + result._native() + return result + + def keys(self): + return tuple(json.loads(self.figure._native.column_extents_json())) + + def add(self, name, values): + from .figure import _array + + self.figure._require_data_edit() + self.figure._require_open() + name = _column(name, self.figure) + with self.figure._lock: + if name in self.keys(): + raise ValueError("column already exists: " + name) + self.figure._native.upload_column(name, _array(values)) + self.figure._changed() + return self.column(name) + + +class ArtistData: + def __init__(self, artist): + self.artist = artist + + def __getattr__(self, role): + fig = self.artist.figure + spec = self.artist._spec(fig.get_settings()) + body = next(iter(spec["render_type"].values())) + if role in ("x", "y"): + return ColumnReference(fig, spec[role + "_column"]) + if role == "values" and "matrix" in body: + return _grid(body["matrix"], fig) + if role in ("u", "v") and "components" in body: + kind, components = next(iter(body["components"].items())) + value = components[role] + return _grid(value, fig) if kind == "Grid" else ColumnReference(fig, value) + if role == "color": + if body.get("scatter", {}).get("point_color_map"): + return ColumnReference( + fig, body["scatter"]["point_color_map"]["column"] + ) + if body.get("style", {}).get("color_map"): + kind, value = next(iter(body["style"]["color_map"].items())) + return ( + _grid(value, fig) if kind == "Grid" else ColumnReference(fig, value) + ) + if role == "size" and body.get("scatter", {}).get("point_size_map"): + return ColumnReference(fig, body["scatter"]["point_size_map"]["column"]) + if role in ("marker_indices", "error_indices"): + part = "scatter" if role == "marker_indices" else "err_style" + prefix = "point" if role == "marker_indices" else "error_bar" + column = body.get(part, {}).get(prefix + "_style_index_column") + if column: + return ColumnReference(fig, column) + raise AttributeError("this artist has no " + role + " data source") + + +def _domain(figure, column, domain): + from ._validation import number as _number + + if domain is None: + bounds = json.loads(figure._native.column_extents_json()).get(column) + if not bounds or any(v is None for v in bounds): + raise ValueError("source has no finite resident bounds") + low, high = bounds + if low == high: + high = low + max(1.0, abs(low) * 0.01) + else: + low, high = domain + low, high = _number(low, "domain minimum"), _number(high, "domain maximum") + if low >= high: + raise ValueError("mapping domain must be increasing") + return low, high + + +class ElementAppearance: + """Shared marker/error/bin defaults, palettes and sparse overrides.""" + + def __init__(self, artist, part): + self.artist, self.part = artist, part + + def __getitem__(self, index): + return ElementStyle(self, index) + + @property + def palette(self): + if self.part == "bins": + raise AttributeError("histogram bins have sparse overrides only") + return Palette(self) + + def _node(self, state): + key = _PARTS[self.part][0] + body = self.artist._body(state) + if key not in body: + raise AttributeError("this artist has no " + self.part) + return body[key] + + def set(self, **properties): + cls = _PARTS[self.part][1] + state = self.artist.figure.get_settings() + self._node(state).update( + _style(cls(**properties), self.part, self.artist.figure.dpi) + ) + self.artist.figure.apply_settings(state) + return self + + def get_style(self): + return _style( + self._node(self.artist.figure.get_settings()), + self.part, + self.artist.figure.dpi, + True, + ) + + def set_palette(self, entries, *, source=None): + if self.part == "bins": + raise NotImplementedError( + "histograms support bin overrides, not a style-index palette" + ) + state = self.artist.figure.get_settings() + node = self._node(state) + prefix = _PARTS[self.part][3] + if entries is None: + node.pop(prefix + "_style_table", None) + node.pop(prefix + "_style_index_column", None) + else: + node[prefix + "_style_table"] = [ + _style(v, self.part, self.artist.figure.dpi) for v in entries + ] + if source is None: + node.pop(prefix + "_style_index_column", None) + else: + node[prefix + "_style_index_column"] = _column( + source, self.artist.figure + ) + self.artist.figure.apply_settings(state) + return self + + def get_palette(self): + prefix = _PARTS[self.part][3] + entries = self._node(self.artist.figure.get_settings()).get( + prefix + "_style_table" + ) + return ( + None + if entries is None + else [_style(v, self.part, self.artist.figure.dpi, True) for v in entries] + ) + + def set_overrides(self, styles): + state = self.artist.figure.get_settings() + node = self._node(state) + key = _PARTS[self.part][3] + "_style_overrides" + if styles is None: + node.pop(key, None) + else: + pairs = styles.items() if hasattr(styles, "items") else styles + entries = [] + for index, style in pairs: + from numbers import Integral + + if ( + isinstance(index, bool) + or not isinstance(index, Integral) + or index < 0 + ): + raise ValueError("override indices must be nonnegative integers") + entries.append( + dict( + index=int(index), + **_style(style, self.part, self.artist.figure.dpi), + ) + ) + node[key] = entries + self.artist.figure.apply_settings(state) + return self + + def get_overrides(self): + entries = self._node(self.artist.figure.get_settings()).get( + _PARTS[self.part][3] + "_style_overrides" + ) + return ( + None + if entries is None + else [ + (v["index"], _style(v, self.part, self.artist.figure.dpi, True)) + for v in entries + ] + ) + + def map_color(self, source, *, clim=None, cmap=None): + if self.part != "markers": + raise AttributeError("color-value mapping requires point markers") + from ._color_scale import _set_colorbar + + fig = self.artist.figure + state = fig.get_settings() + node = self._node(state) + if source is None: + node.pop("point_color_map", None) + else: + column = _column(source, fig) + low, high = _domain(fig, column, clim) + owner = self.artist._panel_axes(state) + config = owner._config(state) + current = config.get("colorbar", {}).get("colormap", "Viridis") + _set_colorbar(config, current if cmap is None else cmap, low, high) + node["point_color_map"] = {"column": column} + fig.apply_settings(state) + return self + + def map_size(self, source, *, domain=None, markersizes=(4, 16), scale="area"): + if self.part != "markers": + raise AttributeError("size-value mapping requires point markers") + from ._validation import number as _number + + fig = self.artist.figure + state = fig.get_settings() + node = self._node(state) + if source is None: + node.pop("point_size_map", None) + else: + if scale not in ("area", "radius"): + raise ValueError("size scale must be area or radius") + column = _column(source, fig) + low, high = _domain(fig, column, domain) + small, large = (_number(v, "marker size") for v in markersizes) + if not 0 <= small <= large: + raise ValueError("marker size range must be nonnegative and increasing") + node["point_size_map"] = dict( + column=column, + domain_min=low, + domain_max=high, + min_px=small * fig.dpi / 144, + max_px=large * fig.dpi / 144, + scale=scale.title(), + ) + fig.apply_settings(state) + return self + + +class ElementStyle: + def __init__(self, appearance, index, palette=False): + from numbers import Integral + + if isinstance(index, bool) or not isinstance(index, Integral) or index < 0: + raise ValueError("style index must be a nonnegative integer") + self.appearance, self.index, self.palette = appearance, int(index), palette + + def set(self, **properties): + appearance = self.appearance + fig = appearance.artist.figure + state = fig.get_settings() + node = appearance._node(state) + _, cls, fields, prefix = _PARTS[appearance.part] + patch = _style(cls(**properties), appearance.part, fig.dpi) + if self.palette: + entries = node.get(prefix + "_style_table") + if entries is None or self.index >= len(entries): + raise IndexError("palette entry does not exist") + targets = [entries[self.index]] + target = targets[0] + else: + entries = node.setdefault(prefix + "_style_overrides", []) + targets = [v for v in entries if v["index"] == self.index] + if not targets: + target = {"index": self.index} + entries.append(target) + targets = [target] + else: + target = targets[-1] + for name, value in properties.items(): + if value is None: + for entry in targets: + entry.pop( + "point_shape" if name == "fillstyle" else fields[name][0], None + ) + target.update(patch) + fig.apply_settings(state) + return self + + def get_override(self): + appearance = self.appearance + node = appearance._node(appearance.artist.figure.get_settings()) + prefix = _PARTS[appearance.part][3] + if self.palette: + entries = node.get(prefix + "_style_table") or [] + if self.index >= len(entries): + raise IndexError("palette entry does not exist") + value = entries[self.index] + else: + value = {} + for item in node.get(prefix + "_style_overrides") or []: + if item["index"] == self.index: + value.update(item) + return _style(value, appearance.part, appearance.artist.figure.dpi, True) + + def remove(self): + if self.palette: + raise NotImplementedError( + "replace the palette explicitly when changing its slot numbering" + ) + appearance = self.appearance + fig = appearance.artist.figure + state = fig.get_settings() + node = appearance._node(state) + key = _PARTS[appearance.part][3] + "_style_overrides" + node[key] = [v for v in node.get(key) or [] if v["index"] != self.index] + fig.apply_settings(state) + + +class Palette: + def __init__(self, appearance): + self.appearance = appearance + + def __getitem__(self, index): + return ElementStyle(self.appearance, index, True) + + def set(self, entries, *, source=None): + return self.appearance.set_palette(entries, source=source) + + def append(self, style=None): + cls = _PARTS[self.appearance.part][1] + values = self.appearance.get_palette() or [] + values.append(cls() if style is None else style) + self.appearance.set_palette(values, source=self.source) + return self[len(values) - 1] + + @property + def source(self): + app = self.appearance + name = app._node(app.artist.figure.get_settings()).get( + _PARTS[app.part][3] + "_style_index_column" + ) + return None if name is None else ColumnReference(app.artist.figure, name) + + def set_source(self, source): + return self.appearance.set_palette(self.appearance.get_palette(), source=source) + + +class VectorAppearance: + def __init__(self, artist): + self.artist = artist + + def _style(self, state): + body = self.artist._body(state) + if "components" not in body: + raise AttributeError("this artist is not a vector field") + return body["style"] + + def set( + self, + *, + color=None, + linewidth=None, + head_length=None, + head_width=None, + min_length=None, + pivot=None, + scale=None, + units=None, + ): + from .figure import _color + from ._validation import number as _number + + fig = self.artist.figure + state = fig.get_settings() + style = self._style(state) + if color is not None: + style["color"] = _color(color) + for value, key in [ + (linewidth, "width_px"), + (head_length, "head_length_px"), + (head_width, "head_width_px"), + (min_length, "min_length_px"), + ]: + if value is not None: + value = _number(value, key) + if value < 0: + raise ValueError("vector sizes must be nonnegative") + style[key] = value * fig.dpi / 72 + if pivot is not None: + if pivot not in ("tail", "middle", "tip"): + raise ValueError("vector pivot must be tail, middle or tip") + style["pivot"] = pivot.title() + if scale is not None or units is not None: + kind, params = next(iter(style["scale"].items())) + old = next(iter(params.values())) + units = ("data" if kind == "Data" else "pixels") if units is None else units + if units not in ("data", "pixels"): + raise ValueError("vector units must be data or pixels") + number = _number(old if scale is None else scale, "vector scale") + if number == 0: + raise ValueError("vector scale cannot be zero") + style["scale"] = ( + {"Data": dict(factor=number)} + if units == "data" + else {"Pixels": dict(px_per_unit=number)} + ) + fig.apply_settings(state) + return self + + def map_color(self, source, *, clim=None, cmap=None): + from ._color_scale import _set_colorbar + + fig = self.artist.figure + state = fig.get_settings() + style = self._style(state) + if source is None: + style.pop("color_map", None) + else: + if isinstance(source, GridReference): + if source.figure is not fig: + raise ValueError("grid belongs to another Figure") + binding = source._native() + style["color_map"] = {"Grid": binding} + bounds = [] + if clim is None: + extents = json.loads(fig._native.column_extents_json()) + bounds = [extents.get(name) for name in binding["columns"]] + bounds = [v for v in bounds if v and all(x is not None for x in v)] + if not bounds: + raise ValueError("grid has no finite resident bounds") + low, high = min(v[0] for v in bounds), max(v[1] for v in bounds) + if low == high: + high = low + max(1.0, abs(low) * 0.01) + clim = (low, high) + low, high = _domain(fig, "", clim) + else: + name = _column(source, fig) + style["color_map"] = {"Column": name} + low, high = _domain(fig, name, clim) + owner = self.artist._panel_axes(state) + config = owner._config(state) + current = config.get("colorbar", {}).get("colormap", "Viridis") + _set_colorbar(config, current if cmap is None else cmap, low, high) + fig.apply_settings(state) + return self diff --git a/python/figgyplot/_chart_artist.py b/python/figgyplot/_chart_artist.py new file mode 100644 index 0000000..c03dec9 --- /dev/null +++ b/python/figgyplot/_chart_artist.py @@ -0,0 +1,631 @@ +"""Named controls for bounded charts whose materials have no Matplotlib peer.""" + +import copy +import json + +from ._validation import number as _number + +_FORMATS = dict( + value="Value", + percent="Percent", + name="Name", + name_value="NameValue", + name_percent="NamePercent", + value_percent="ValuePercent", + name_value_percent="NameValuePercent", + category_value="CategoryValue", +) + + +_LABELS = dict( + categorical={ + "none": "None", + "inside": "Inside", + "outside": "Outside", + "auto": "Auto", + }, + boxplot={"none": "None", "median": "Median", "median_and_count": "MedianAndCount"}, + radial={"none": "None", "inside": "Inside", "outside": "Outside"}, + radar={"none": "None", "values": "Values", "emphasized": "Emphasized"}, +) + + +def _placement(kind, value): + value = "none" if value is False else value + if value not in _LABELS[kind]: + raise ValueError( + f"{kind} label position must be one of: " + ", ".join(_LABELS[kind]) + ) + return _LABELS[kind][value] + + +class ChartAppearance: + """Figgy extension for shared labels, categorical series and pie slices. + + Data values are never accepted by these appearance setters. Lengths use + points; IDs refer to the existing chart rather than renderer list offsets. + """ + + def __init__(self, axes): + from ._charts import family + + self.axes = axes + self.figure = axes.figure + self.kind = family(self.figure.get_settings()) + if self.kind is None: + raise AttributeError("chart appearance requires a bounded chart") + + def _edit(self, operation): + state = self.figure.get_settings() + operation(state[self.kind]) + self.figure.apply_settings(state) + return self + + def reset_style(self): + from ._native import default_bounded_json, default_radial_json + + state = self.figure.get_settings() + old = state[self.kind] + baseline = json.loads( + default_radial_json() + if self.kind == "radial" + else default_bounded_json(self.kind) + ) + if self.kind == "radial": + baseline["slices"] = copy.deepcopy(old["slices"]) + baseline["split"] = copy.deepcopy(old.get("split")) + items = baseline["slices"] + ( + [] if baseline["split"] is None else baseline["split"]["children"] + ) + for item in items: + for key in ("style", "labels", "label_format", "label_color"): + item[key] = None + else: + baseline.update( + categories=copy.deepcopy(old["categories"]), + series=copy.deepcopy(old["series"]), + ) + for item in baseline["series"]: + item["style"] = None + state[self.kind] = baseline + self.figure.apply_settings(state) + + def set_plot_bounds(self, bounds=None): + """Manual plot rectangle (left, top, width, height) in points; None fits automatically.""" + value = None + if bounds is not None: + numbers = [_number(v, "plot bounds") * self.figure.dpi / 72 for v in bounds] + if len(numbers) != 4 or numbers[2] <= 0 or numbers[3] <= 0: + raise ValueError("bounds require left, top and positive width/height") + value = dict(zip(("x", "y", "width", "height"), numbers)) + return self._edit(lambda chart: chart["decorations"].update(data_area=value)) + + def set_label_style(self, *, fontsize=None, fontfamily=None, color=None): + from .figure import _color + + def edit(chart): + if fontsize is not None: + pixels = ( + _number(fontsize, "fontsize", positive=True) * self.figure.dpi / 72 + ) + maximum = 64 if self.kind == "radial" else 32 + if not 8 <= pixels <= maximum: + raise ValueError( + f"renderer shared label size must be 8..{maximum} logical pixels " + f"({8 * 72 / self.figure.dpi:g}..{maximum * 72 / self.figure.dpi:g} points at this DPI)" + ) + chart["font_size"] = pixels + if fontfamily is not None: + chart["font_family"] = self.figure._check_font(fontfamily) + if color is not None: + chart["label_color" if self.kind == "radial" else "text_color"] = ( + _color(color) + ) + + return self._edit(edit) + + def get_style(self): + """Return reusable style keywords; colors are RGBA tuples, lengths points.""" + from ._appearance import style_values + + return style_values( + self.kind, self.figure.get_settings()[self.kind]["style"], self.figure.dpi + ) + + def set_style(self, **properties): + """Set material, outlines, camera, box statistics or radar appearance. + + get_style() lists this chart family's supported keyword arguments. + Values and registered data bindings are retained. + """ + from ._appearance import edit_style + + return self._edit( + lambda chart: edit_style( + self.kind, chart["style"], self.figure.dpi, properties + ) + ) + + def set_value_labels( + self, *, position=None, format=None, decimals=None, suffix=None + ): + """Choose label placement/content without constructing renderer variants.""" + + def edit(chart): + if position is not None: + chart["labels"] = _placement(self.kind, position) + if format is not None: + names = _FORMATS + if format not in names: + raise ValueError("unknown value label format") + if "label_format" not in chart: + raise NotImplementedError( + "this chart has no value-label format selector" + ) + chart["label_format"] = names[format] + if decimals is not None: + if not isinstance(decimals, int) or not 0 <= decimals <= 15: + raise ValueError("decimals must be an integer from 0 to 15") + chart["label_decimals"] = decimals + if suffix is not None: + chart["value_suffix"] = str(suffix) + + return self._edit(edit) + + def series(self, id): + """Appearance of an existing stable series, independent of its values.""" + return ChartElement(self, "series", id) + + def get_series(self): + """Return stable series IDs and display labels without renderer state.""" + if self.kind == "radial": + raise AttributeError("pies have slices instead of series") + return { + item["id"]: item["label"] + for item in self.figure.get_settings()[self.kind]["series"] + } + + def get_categories(self): + """Return stable category IDs and display labels.""" + if self.kind == "radial": + raise AttributeError("pies have slices instead of categories") + return { + item["id"]: item["label"] + for item in self.figure.get_settings()[self.kind]["categories"] + } + + def get_slice_count(self, *, detail=False): + if self.kind != "radial": + raise AttributeError("slice count requires a pie/donut") + chart = self.figure.get_settings()[self.kind] + return ( + len(chart["split"]["children"]) + if detail and chart.get("split") + else (0 if detail else len(chart["slices"])) + ) + + def cell(self, category, series): + """One categorical bar/box, addressed by category and series IDs.""" + return ChartElement(self, "cell", (category, series)) + + def slice(self, index, *, detail=False): + """One original pie slice, or one child in the detail chart.""" + return ChartElement(self, "detail" if detail else "slice", index) + + def clear_overrides(self): + def edit(chart): + if "overrides" not in chart: + raise AttributeError("this chart has no cell overrides") + chart["overrides"] = [] + + return self._edit(edit) + + def set_ring_spacing(self, spacing=None): + if self.kind != "radar": + raise AttributeError("ring spacing requires radar") + return self._edit( + lambda chart: chart.update( + ring_spacing=None + if spacing is None + else _number(spacing, "ring spacing", positive=True) + ) + ) + + def set_split(self, index, values, *, labels=None, colors=None, inner_radius=None): + """Create a pie detail chart. Child values must sum to the parent slice.""" + from .figure import _color, _COLORS + + if self.kind != "radial": + raise AttributeError("split requires a pie/donut") + values = list(values) + labels = ( + [str(i + 1) for i in range(len(values))] if labels is None else list(labels) + ) + colors = ( + [_COLORS[i % len(_COLORS)] for i in range(len(values))] + if colors is None + else list(colors) + ) + if len(labels) != len(values) or len(colors) != len(values): + raise ValueError("values, labels and colors must have equal length") + children = [ + dict( + label=str(label), + value=_number(value, "slice value"), + color=_color(color), + explode=0.0, + style=None, + labels=None, + label_format=None, + label_color=None, + ) + for label, value, color in zip(labels, values, colors) + ] + return self._edit( + lambda chart: chart.update( + split=dict( + slice_index=index, + children=children, + kind="Pie" + if inner_radius is None + else { + "Donut": dict( + inner_radius=_number(inner_radius, "inner radius") + ) + }, + ) + ) + ) + + def set_split_inner_radius(self, fraction=None): + def edit(chart): + if self.kind != "radial" or chart.get("split") is None: + raise AttributeError("chart has no detail split") + chart["split"]["kind"] = ( + "Pie" + if fraction is None + else {"Donut": dict(inner_radius=_number(fraction, "inner radius"))} + ) + + return self._edit(edit) + + def decoration( + self, name, *, side=None, category=None, series=None, index=None, detail=False + ): + """Position a named chart decoration in points; IDs survive reordering.""" + simple = dict(title="ChartTitle", legend="Legend", data="DataArea") + sides = dict(left="Left", right="Right", top="Top", bottom="Bottom") + if name in simple: + element = simple[name] + elif name in ("axis", "ticklabels", "axis_title"): + if side not in sides: + raise ValueError("side must be left/right/top/bottom") + element = { + dict(axis="Axis", ticklabels="AxisLabel", axis_title="AxisTitle")[ + name + ]: sides[side] + } + elif name == "value_label": + element = {"DataLabel": dict(category_id=category, series_id=series)} + elif name == "spoke_label": + element = {"SpokeLabel": dict(category_id=category)} + elif name == "slice_label": + element = {"RadialLabel": dict(group=int(detail), index=index)} + elif name == "annotation": + element = {"Annotation": index} + else: + raise ValueError("unknown chart decoration") + return ChartDecoration(self, element) + + def set_material(self, name): + from .figure import _material + + if self.kind == "radar": + raise NotImplementedError("radar charts have no material") + return self._edit(lambda chart: chart["style"].update(material=_material(name))) + + def set_series(self, id, *, label=None, color=None): + from .figure import _color + + if self.kind == "radial": + raise AttributeError("pies use set_slice instead of series") + + def edit(chart): + item = next((s for s in chart["series"] if s["id"] == id), None) + if item is None: + raise ValueError("unknown chart series ID") + if label is not None: + item["label"] = str(label) + if color is not None: + item["color"] = _color(color) + + return self._edit(edit) + + def set_category_label(self, id, label): + from ._chart_text import category_label + + def edit(chart): + item = next((c for c in chart["categories"] if c["id"] == id), None) + if item is None: + raise ValueError("unknown category ID") + item["label"] = category_label(label) + + return self._edit(edit) + + def set_slice(self, index, *, label, color, explode=0): + from .figure import _color + + if self.kind != "radial": + raise AttributeError("set_slice requires a pie/donut") + if not isinstance(index, int) or index < 0: + raise ValueError("slice index must be a nonnegative integer") + + def edit(chart): + if index >= len(chart["slices"]): + raise ValueError("slice index is out of range") + chart["slices"][index].update( + label=str(label), + color=_color(color), + explode=_number(explode, "explode"), + ) + + return self._edit(edit) + + def set_startangle(self, angle): + if self.kind != "radial": + raise AttributeError("start angle requires a pie/donut") + return self._edit( + lambda chart: chart.update(start_angle_degrees=-_number(angle, "angle")) + ) + + def set_inner_radius(self, fraction=None): + if self.kind != "radial": + raise AttributeError("inner radius requires a pie/donut") + if fraction is not None: + fraction = _number(fraction, "inner radius") + if not 0 < fraction < 1: + raise ValueError("inner radius must be in (0,1)") + return self._edit( + lambda chart: chart.update( + kind="Pie" + if fraction is None + else {"Donut": {"inner_radius": fraction}} + ) + ) + + def set_mode(self, mode): + modes = { + "grouped": "Grouped", + "stacked": "Stacked", + "percent_stacked": "PercentStacked", + } + if self.kind != "categorical" or mode not in modes: + raise ValueError( + "mode requires categorical bars: grouped, stacked, percent_stacked" + ) + return self._edit(lambda chart: chart.update(mode=modes[mode])) + + def set_orientation(self, orientation): + if self.kind not in ("categorical", "boxplot") or orientation not in ( + "horizontal", + "vertical", + ): + raise ValueError( + "orientation requires categorical bars/boxes: horizontal or vertical" + ) + return self._edit(lambda chart: chart.update(direction=orientation.title())) + + def set_group_width(self, fraction): + value = _number(fraction, "group width") + if self.kind not in ("categorical", "boxplot") or not 0 < value <= 1: + raise ValueError("group width requires bars/boxes and a fraction in (0,1]") + return self._edit(lambda chart: chart.update(group_width=value)) + + def set_gap(self, points): + value = _number(points, "gap") + if self.kind not in ("categorical", "boxplot") or value < 0: + raise ValueError("gap requires bars/boxes and a nonnegative length") + return self._edit( + lambda chart: chart.update( + **{ + "bar_gap" if self.kind == "categorical" else "box_gap": value + * self.figure.dpi + / 72 + } + ) + ) + + +class ChartElement: + def __init__(self, chart, kind, key): + self.chart, self.kind, self.key = chart, kind, key + self._node(chart.figure.get_settings()[chart.kind], create=False) + + def _node(self, chart, *, create=True): + if self.kind in ("slice", "detail"): + if self.chart.kind != "radial": + raise AttributeError("slice appearance requires a pie") + if self.kind == "detail" and not chart.get("split"): + raise ValueError("chart has no split") + items = ( + chart["slices"] if self.kind == "slice" else chart["split"]["children"] + ) + if ( + isinstance(self.key, bool) + or not isinstance(self.key, int) + or not 0 <= self.key < len(items) + ): + raise ValueError("slice index out of range") + return items[self.key] + if self.chart.kind == "radial": + raise AttributeError("pies have slices instead of series") + if self.kind == "series": + item = next((s for s in chart["series"] if s["id"] == self.key), None) + if item is None: + raise ValueError("unknown series ID") + return item + if self.chart.kind not in ("categorical", "boxplot"): + raise AttributeError("cell overrides require categorical bars or boxes") + category, series = self.key + if category not in [c["id"] for c in chart["categories"]] or series not in [ + s["id"] for s in chart["series"] + ]: + raise ValueError("unknown category or series ID") + target = dict(category_id=category, series_id=series) + item = next((s for s in chart["overrides"] if s["target"] == target), None) + if item is None: + item = dict(target=target, color=None, style=None, labels=None) + if self.chart.kind == "categorical": + item["label_format"] = None + if create: + chart["overrides"].append(item) + return item + + def _edit(self, operation): + self.chart._edit(lambda chart: operation(chart, self._node(chart))) + return self + + def get_style(self): + """Return this override's public keywords, or None when it inherits.""" + from ._appearance import style_values, convert + + item = self._node( + self.chart.figure.get_settings()[self.chart.kind], create=False + ) + style = item.get("style") + if style is None: + return None + if self.chart.kind != "radar": + return style_values(self.chart.kind, style, self.chart.figure.dpi) + fields = dict( + linewidth=("line_width_px", "length"), + facecolor=("fill", "color"), + marker=("marker", "shape"), + markersize=("marker_size_px", "length"), + ) + return { + name: None + if style.get(key) is None + else convert(style[key], kind, self.chart.figure.dpi, decode=True) + for name, (key, kind) in fields.items() + } + + def set_style(self, **properties): + """Override this element. Native full styles snapshot chart defaults; + radar per-series fields continue to inherit when set to None. + """ + from ._appearance import edit_style, convert + + def edit(chart, item): + if self.chart.kind == "radar": + fields = dict( + linewidth=("line_width_px", "length"), + facecolor=("fill", "color"), + marker=("marker", "shape"), + markersize=("marker_size_px", "length"), + ) + if set(properties) - fields.keys(): + raise ValueError( + "radar series style supports linewidth, facecolor, marker, markersize" + ) + style = item.setdefault("style", None) or dict.fromkeys( + v[0] for v in fields.values() + ) + for name, value in properties.items(): + key, kind = fields[name] + style[key] = ( + None + if value is None + else convert(value, kind, self.chart.figure.dpi) + ) + item["style"] = style + else: + style = copy.deepcopy( + chart["style"] if item.get("style") is None else item["style"] + ) + edit_style(self.chart.kind, style, self.chart.figure.dpi, properties) + item["style"] = style + + return self._edit(edit) + + def reset_style(self): + return self._edit(lambda chart, item: item.update(style=None)) + + def set_color(self, color): + from .figure import _color + + if color is None and self.kind != "cell": + raise ValueError("only cell colors may inherit") + return self._edit( + lambda chart, item: item.update( + color=None if color is None else _color(color) + ) + ) + + def set_label(self, text): + if self.kind == "cell": + raise AttributeError("cell labels follow chart value formats") + return self._edit(lambda chart, item: item.update(label=str(text))) + + def set_explode(self, fraction): + if self.kind not in ("slice", "detail"): + raise AttributeError("explode requires a pie slice") + return self._edit( + lambda chart, item: item.update(explode=_number(fraction, "explode")) + ) + + def set_value_labels(self, *, position=..., format=..., color=...): + if self.kind == "series": + raise AttributeError( + "series label placement uses chart defaults; override a cell" + ) + from .figure import _color + + def edit(chart, item): + if position is not ...: + item["labels"] = ( + None if position is None else _placement(self.chart.kind, position) + ) + if format is not ...: + if "label_format" not in item: + raise AttributeError("this element has no label format override") + if format is not None and format not in _FORMATS: + raise ValueError("unknown label format") + item["label_format"] = None if format is None else _FORMATS[format] + if color is not ...: + if self.kind not in ("slice", "detail"): + raise AttributeError("label color overrides require a pie slice") + item["label_color"] = None if color is None else _color(color) + + return self._edit(edit) + + +class ChartDecoration: + def __init__(self, chart, element): + self.chart, self.element = chart, element + + def set_offset(self, offset): + values = [ + _number(v, "decoration offset") * self.chart.figure.dpi / 72 for v in offset + ] + if len(values) != 2: + raise ValueError("offset requires two components") + + def edit(chart): + offsets = chart["decorations"]["offsets"] + item = next( + (item for item in offsets if item["element"] == self.element), None + ) + if item is None: + offsets.append(dict(element=copy.deepcopy(self.element), offset=values)) + else: + item["offset"] = values + + return self.chart._edit(edit) + + def remove(self): + def edit(chart): + offsets = chart["decorations"]["offsets"] + offsets[:] = [item for item in offsets if item["element"] != self.element] + + return self.chart._edit(edit) diff --git a/python/figgyplot/_chart_text.py b/python/figgyplot/_chart_text.py new file mode 100644 index 0000000..40c6533 --- /dev/null +++ b/python/figgyplot/_chart_text.py @@ -0,0 +1,43 @@ +"""Adapt public rich titles to the upstream bounded model's legacy fallback. + +``chart_title`` owns the displayed text. ``title`` is only a single-line, +160-character fallback for older consumers; it must never limit the rich text. +Native model validation still enforces the rich title's own limits atomically. +""" +import copy + + +def title_options(chart): + from .figure import _segments + if chart.get('chart_title') is None: + chart['chart_title'] = dict(visible=True, text=dict( + segments=_segments(chart['title']), font_size=chart['font_size'] * 1.5, + font=chart['font_family'], + color=copy.deepcopy(chart.get('text_color', chart.get('label_color'))), + align='Left')) + return chart['chart_title'] + + +def set_title_text(chart, text=None, *, segments=None): + from .figure import _segments, _text + title = title_options(chart) + title['text']['segments'] = _segments(text) if segments is None else copy.deepcopy(segments) + # Unicode super/subscripts retain their original spelling where recoverable. + plain = _text(title['text']) + chart['title'] = plain.replace('\n', ' ').replace('\r', ' ')[:160] + + +def inherit_title(chart, config): + title = config['chart_title'] + # Leave untouched defaults in the bounded renderer's own typography. Any + # existing title (including explicitly hidden text) keeps its rich styles. + if title['text']['segments']: + chart['chart_title'] = dict(visible=title['visible'], text=copy.deepcopy(title['text'])) + set_title_text(chart, segments=title['text']['segments']) + + +def category_label(value): + label = str(value) + if '\n' in label or '\r' in label: + raise ValueError('category labels must be single-line; use a short label and put details in the chart title') + return label diff --git a/python/figgyplot/_charts.py b/python/figgyplot/_charts.py index 030c13c..a654c86 100644 --- a/python/figgyplot/_charts.py +++ b/python/figgyplot/_charts.py @@ -3,6 +3,7 @@ import json import math import numpy as np +from . import _style_runtime, _chart_text def family(settings): @@ -19,7 +20,11 @@ def set(self, **properties): return self def set_style(self, **properties): - return self.set(style=properties) + self.figure.axes[0].chart.set_style(**properties) + return self + + def get_style(self): + return self.figure.axes[0].chart.get_style() def set_values(self, values, *, series=0): with self.figure._lock: @@ -38,22 +43,34 @@ def set_values(self, values, *, series=0): class ChartMethods: def _bounded(self, kind, categories, series, **properties): - from .figure import _color, _text, _merge + from .figure import _text, _merge from ._native import default_bounded_json fig = self.figure + fig._require_data_edit() if len(fig.subplots) != 1 or self.overlay_id or self._config(fig.get_settings()).get('polar'): raise ValueError('bounded charts require a separate single-panel Figure') - chart = json.loads(default_bounded_json(kind)) - chart.update(categories=categories, series=series, - title=_text(fig.get_settings()['config']['chart_title']['text']), - font_size=11 * fig.dpi / 72) - _merge(chart, properties) with fig._lock: fig._require_open() + state = fig.get_settings() + config = self._config(state) + chart = json.loads(default_bounded_json(kind)) + chart.update(categories=categories, series=series, font_size=11 * fig.dpi / 72) + _chart_text.inherit_title(chart, config) + if kind in ('categorical', 'boxplot'): + horizontal = properties.get('direction') == 'Horizontal' + chart['category_title'] = _text(config['left_y' if horizontal else 'bottom_x']['title_option']['text']) + chart['value_title'] = _text(config['bottom_x' if horizontal else 'left_y']['title_option']['text']) + _merge(chart, properties) + if 'title' in properties and 'chart_title' not in properties: + _chart_text.set_title_text(chart, properties['title']) + if fig._style_defaults is not None: + state[kind]=chart + chart=_style_runtime.bounded(fig,state,kind)[kind] fig._native.set_bounded(kind, json.dumps(chart, allow_nan=False)) fig._changed() return BoundedArtist(fig, kind) + @_style_runtime.styled def bar(self, x, height, *, label=None, colors=None, color=None, mode='grouped', orientation='vertical', **properties): """Bounded categorical bars (up to 64 categories / 16 series / 512 bars). @@ -76,17 +93,19 @@ def bar(self, x, height, *, label=None, colors=None, color=None, palette = list(colors) if colors is not None else [_COLORS[i % len(_COLORS)] if color is None else color for i in range(n)] if len(names) != n or len(palette) != n: raise ValueError('labels and colors must match the number of series') - categories = [dict(id=f'category_{i}', label=str(v)) for i, v in enumerate(labels)] + categories = [dict(id=f'category_{i}', label=_chart_text.category_label(v)) for i, v in enumerate(labels)] series = [dict(id=f'series_{i}', label=names[i], values=[None if v is None else float(v) for v in row], color=_color(palette[i]), style=None) for i, row in enumerate(values)] return self._bounded('categorical', categories, series, mode={'grouped': 'Grouped', 'stacked': 'Stacked', 'percent_stacked': 'PercentStacked'}[mode], direction=orientation.title(), **properties) + @_style_runtime.styled def barh(self, y, width, **kwargs): """Horizontal categorical bars; other arguments follow ``bar``.""" return self.bar(y, width, orientation='horizontal', **kwargs) + @_style_runtime.styled def bxp(self, stats, *, labels=None, color=None, orientation='vertical', **properties): """Plot precomputed boxes without recomputing statistics. @@ -116,10 +135,11 @@ def bxp(self, stats, *, labels=None, color=None, orientation='vertical', **prope s.setdefault(key, None) s['outliers'] = [float(v) for v in s.get('outliers', [])] values.append(s) - categories = [dict(id=f'category_{i}', label=str(v)) for i, v in enumerate(names)] + categories = [dict(id=f'category_{i}', label=_chart_text.category_label(v)) for i, v in enumerate(names)] series = [dict(id='series_0', label='Distribution', values=values, color=_color(_COLORS[0] if color is None else color), style=None)] return self._bounded('boxplot', categories, series, direction=orientation.title(), **properties) + @_style_runtime.styled def boxplot(self, x, *, labels=None, whis=1.5, orientation='vertical', **properties): """Compute NumPy linear quartiles and actual IQR whiskers, then call bxp. @@ -147,6 +167,7 @@ def boxplot(self, x, *, labels=None, whis=1.5, orientation='vertical', **propert outliers=a[(a < low) | (a > high)].tolist())) return self.bxp(stats, labels=labels, orientation=orientation, **properties) + @_style_runtime.styled def errorbar(self, x, y, *, xerr=None, yerr=None, color=None, ecolor=None, linewidth=1.5, elinewidth=1.0, capsize=3, marker='o', markersize=4, linestyle='-', label=None, id=None): @@ -193,6 +214,7 @@ def errorbar(self, x, y, *, xerr=None, yerr=None, color=None, ecolor=None, suffix = 'XY' if len(errors) == 2 else 'X' if xerr is not None else 'Y' return self._add(x, y, {prefix+suffix: style}, label, id, extra_columns=columns) + @_style_runtime.styled def stairs(self, values, edges, *, orientation='vertical', color=None, edgecolor='black', linewidth=0.5, baseline=0, gap=1, width=1, label=None, id=None): """Render pre-binned histogram bars; one more edge than values.""" @@ -209,6 +231,7 @@ def stairs(self, values, edges, *, orientation='vertical', color=None, edgecolor baseline=float(baseline), gap_px=float(gap), width_ratio=float(width), orientation=orientation.title())}} return self._add(edges if orientation=='vertical' else counts, counts if orientation=='vertical' else edges, style, label, id) + @_style_runtime.styled def hist(self, x, bins=10, *, range=None, weights=None, density=False, **kwargs): """Bin one finite sample vector with NumPy; return counts, edges, artist.""" from .figure import _array @@ -221,6 +244,7 @@ def hist(self, x, bins=10, *, range=None, weights=None, density=False, **kwargs) counts, edges = np.histogram(x, bins=bins, range=range, weights=weights, density=density) return counts, edges, self.stairs(counts, edges, **kwargs) + @_style_runtime.styled def contour(self, *args, levels=7, color='black', linewidth=1, cmap='viridis', vmin=None, vmax=None, filled=False, id=None): """Contour(Z) or contour(X,Y,Z), with increasing 1-D sample coordinates. @@ -228,8 +252,8 @@ def contour(self, *args, levels=7, color='black', linewidth=1, cmap='viridis', Explicit levels are SSOT values. An integer requests evenly spaced interior levels. ``filled=True`` adds upstream banded field rendering. """ - from .figure import _array, _color, _colormap - from ._native import default_colorbar_json + from .figure import _array, _color + from ._color_scale import _set_colorbar if len(args) == 1: z = np.asarray(args[0]) if z.ndim != 2: @@ -267,15 +291,13 @@ def contour(self, *args, levels=7, color='black', linewidth=1, cmap='viridis', contour=dict(levels=levels.tolist(), line=dict(line_style='Solid', line_color=_color(color), line_width=float(linewidth)*self.figure.dpi/72))) if filled: style['fill'] = dict(mode='Bands', shading='Interpolated', opacity=1.0) - bar = json.loads(default_colorbar_json()) - bar['colormap'] = _colormap(cmap) - bar['axis'].update(min=low, max=high, major_spacing=(high-low)/5) with self.figure._lock: config = self._config(self.figure.get_settings()) - config['colorbar'] = bar + _set_colorbar(config,cmap,low,high) return self._add(x, y, {'HeatmapContour' if filled else 'Contour': style}, id=id, extra_columns=columns, config=config, bounds=(float(x[0]), float(x[-1]), float(y[0]), float(y[-1]))) + @_style_runtime.styled def contourf(self, *args, **kwargs): """Banded field plus optional contours; default line width is zero.""" kwargs.setdefault('linewidth', 0) diff --git a/python/figgyplot/_codegen.py b/python/figgyplot/_codegen.py index 159c2d8..372d58f 100644 --- a/python/figgyplot/_codegen.py +++ b/python/figgyplot/_codegen.py @@ -2,26 +2,147 @@ from __future__ import annotations import copy +import ast +from collections import Counter import json import keyword import pprint -def settings_to_code(settings, defaults, dpi=120, variable="fig"): +def editor_code_payload(settings, defaults, dpi=120, variable='fig'): + """An auxiliary code export must never prevent an accepted chart opening.""" + try: + return dict(code=settings_to_code(settings,defaults,dpi,variable), + code_mode='api',code_error='') + except Exception as error: + # Keep the error visible in the code panel while preserving a complete, + # usable recovery export. Prefix every message line as a Python comment. + message=str(error) + comment='\n'.join('# '+line for line in message.splitlines()) + exact=settings_to_code(settings,defaults,dpi,variable,mode='exact') + return dict(code='# Public API export unavailable; exact recovery code follows.\n'+comment+'\n'+exact, + code_mode='exact',code_error=message) + + +def editor_settings_to_code(settings, defaults, dpi=120, variable='fig'): + """Pure shared entry point for native Copy Python while its lock is held.""" + return editor_code_payload(settings,defaults,dpi,variable)['code'] + + +def _share_colors(code): + """Name repeated literal colors without changing values or hiding settings.""" + tree = ast.parse(code) + names = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)} + matches = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Dict): + continue + try: + value = ast.literal_eval(node) + except (ValueError, TypeError): + continue + if set(value) not in ({'r', 'g', 'b'}, {'r', 'g', 'b', 'a'}): + continue + if not all(type(v) in (int, float) for v in value.values()): + continue + key = tuple(sorted(value.items())) + matches.append((node, key, value)) + counts = Counter(key for _, key, _ in matches) + shared = {}; definitions = [] + for _, key, value in matches: + if counts[key] < 2 or key in shared: + continue + index = len(shared) + 1 + name = f'_color{index}' + while name in names: + index += 1; name = f'_color{index}' + names.add(name); shared[key] = name + definitions.append(f'{name} = {value!r}') + if not shared: + return code + # AST columns count UTF-8 bytes. Byte offsets preserve Korean/Greek text on + # a line containing a styled run and its color keyword. + data = code.encode('utf-8'); offsets = [0] + for line in data.splitlines(keepends=True): offsets.append(offsets[-1] + len(line)) + edits = [(offsets[n.lineno-1]+n.col_offset, + offsets[n.end_lineno-1]+n.end_col_offset, shared[key].encode()) + for n,key,_ in matches if key in shared] + for start,end,value in sorted(edits,reverse=True): data = data[:start] + value + data[end:] + return '\n'.join(definitions) + '\n\n' + data.decode('utf-8') + + +def compact_config(config, defaults): + """Drop only known default leaves; keep topology and every panel rectangle.""" + from .figure import _diff + from ._native import default_colorbar_json + patch = _diff(defaults, config) + patch['chart_area'] = copy.deepcopy(config['chart_area']) + if config.get('colorbar') is not None: + patch['colorbar'] = _diff(json.loads(default_colorbar_json()), config['colorbar']) + if config.get('subplots'): + patch['subplots'] = copy.deepcopy(config['subplots']) + for panel in patch['subplots']: + panel['config'] = compact_config(panel['config'], defaults) + if config.get('overlays'): + patch['overlays'] = copy.deepcopy(config['overlays']) + for overlay in patch['overlays']: + overlay['y_axis'] = _diff(defaults['left_y'], overlay['y_axis']) + if overlay.get('legend') is not None: + overlay['legend'] = _diff(defaults['legend'], overlay['legend']) + return patch + + +def expand_config(patch, defaults): + """Resolve compact settings against defaults, never the target's edited state.""" + from .figure import _merge + from ._native import default_colorbar_json + config = _merge(copy.deepcopy(defaults), patch) + if patch.get('colorbar') is not None: + config['colorbar'] = _merge(json.loads(default_colorbar_json()), patch['colorbar']) + for panel in config.get('subplots', []): + panel['config'] = expand_config(panel['config'], defaults) + for overlay in config.get('overlays', []): + overlay['y_axis'] = _merge(copy.deepcopy(defaults['left_y']), overlay['y_axis']) + if overlay.get('legend') is not None: + overlay['legend'] = _merge(copy.deepcopy(defaults['legend']), overlay['legend']) + return config + + +def settings_to_code(settings, defaults, dpi=120, variable="fig", *, compact=True, mode='api'): + if type(compact) is not bool: + raise ValueError('compact must be boolean') + if mode not in ('api','exact'): + raise ValueError('mode must be api or exact') + if mode=='api' and compact: + from ._api_codegen import settings_to_api_code + return settings_to_api_code(settings,defaults,dpi,variable) from .figure import _code_tree, _diff, _segments, _text from ._native import fitted_chart_title_margin, font_family_available if not isinstance(variable, str) or not variable.isidentifier() or keyword.iskeyword(variable): raise ValueError("variable must be a Python identifier") + if type(compact) is not bool: + raise ValueError('compact must be boolean') if isinstance(settings, str): settings = json.loads(settings) if isinstance(defaults, str): defaults = json.loads(defaults) + segments_function = "_text_segments" if variable == "text_segments" else "text_segments" + def formatted(value): + return pprint.pformat(_code_tree(value, segments_function) if compact else value, + width=96, sort_dicts=False) + def finish(code): + if compact: + code = _share_colors(code) + if f'{segments_function}(' in code: + alias = '' if segments_function == 'text_segments' else f' as {segments_function}' + code = f'from figgyplot import text_segments{alias}\n\n' + code + return code identity = settings.get('identity') def with_identity(code): if identity is not None: - code += f"{variable}.configure(identity={pprint.pformat(identity, width=96, sort_dicts=False)})\n" - return code + code += f"{variable}.configure(identity={formatted(identity)})\n" + return finish(code) config = settings['config'] advanced = config.get('subplots') or config.get('overlays') or config.get('polar') or config.get('annotations') for spec in settings['series']: @@ -30,9 +151,13 @@ def with_identity(code): style.get('scatter', {}).get(key) is not None for key in ('point_color_map', 'point_size_map')) advanced = advanced or style.get('line', {}).get('fill') is not None or style.get('line', {}).get('interpolation', 'Linear') != 'Linear' if advanced: - return (f"# Apply the complete document to an existing Figure with matching source columns.\n" + document = copy.deepcopy(settings) + if compact: + document['config'] = compact_config(config, defaults) + reset = ', reset=True' if compact else '' + return finish(f"# Apply the complete document to an existing Figure with matching source columns.\n" f"# Source arrays stay resident; panels, layers and references commit together.\n" - f"{variable}.apply_settings({pprint.pformat(settings, width=96, sort_dicts=False)})\n") + f"{variable}.apply_settings({formatted(document)}{reset})\n") for kind in ('categorical', 'boxplot', 'radar'): if settings.get(kind) is not None: chart = copy.deepcopy(settings[kind]) @@ -41,8 +166,12 @@ def with_identity(code): chart['expected_categories'] = [c['id'] for c in chart['categories']] chart['expected_series'] = [s['id'] for s in chart['series']] chart['series'] = {s['id']: {k: v for k, v in s.items() if k not in ('id', 'values')} for s in chart['series']} - args = pprint.pformat(chart, width=96, sort_dicts=False) - canvas = pprint.pformat({'chart_area': settings['config']['chart_area']}) + if compact: + from ._native import default_bounded_json + chart = _diff(json.loads(default_bounded_json(kind)), chart) + chart['reset'] = True + args = formatted(chart) + canvas = formatted({'chart_area': settings['config']['chart_area']}) return with_identity(f"# Apply to an existing {kind} chart with matching category/series IDs and DPI.\n" f"# Existing data values are preserved.\n" f"{variable}.configure(config={canvas})\n" @@ -54,13 +183,16 @@ def with_identity(code): slices = {i: {key: value for key, value in item.items() if key != "value"} for i, item in enumerate(chart.pop("slices"))} chart["slices"] = slices - args = pprint.pformat(chart, width=96, sort_dicts=False) - canvas = pprint.pformat({"chart_area": settings["config"]["chart_area"]}) + if compact: + from ._native import default_radial_json + chart = _diff(json.loads(default_radial_json()), chart) + chart['reset'] = True + args = formatted(chart) + canvas = formatted({"chart_area": settings["config"]["chart_area"]}) return with_identity(f"# Apply to an existing pie/donut with the same slice count and DPI.\n" f"# Existing category values are preserved.\n" f"{variable}.configure(config={canvas})\n" f"{variable}.configure_radial(expected_slices={len(slices)}, **{args})\n") - segments_function = "_text_segments" if variable == "text_segments" else "text_segments" cfg = settings["config"] modeled = copy.deepcopy(defaults) lines = [f"# Apply to the existing {variable} with the same plotted series.", @@ -69,7 +201,7 @@ def with_identity(code): # replay resets the rest of the document to defaults. if cfg.get("colorbar") is not None: modeled["colorbar"] = copy.deepcopy(cfg["colorbar"]) - reset = pprint.pformat({"colorbar": cfg["colorbar"]}, width=96, sort_dicts=False) + reset = formatted({"colorbar": cfg["colorbar"]}) lines.append(f"{variable}.configure(config={reset}, reset=True)") else: lines.append(f"{variable}.configure(reset=True)") @@ -177,8 +309,8 @@ def compact(value): def configure(options): lines.append(f"{variable}.configure(") for key, value in options.items(): - formatted = pprint.pformat(_code_tree(value, segments_function), width=96, sort_dicts=False) - lines.append(f" {key}=" + formatted.replace("\n", "\n ") + ",") + literal = formatted(value) + lines.append(f" {key}=" + literal.replace("\n", "\n ") + ",") lines.append(")") # Restore advanced series styling before rebuilding the legend so ordinary @@ -201,7 +333,4 @@ def configure(options): lines += ["", "# Preserve advanced layout."] configure({"config": residual}) code = "\n".join(lines) + "\n" - if f"{segments_function}(" in code: - alias = "" if segments_function == "text_segments" else f" as {segments_function}" - code = f"from figgyplot import text_segments{alias}\n\n" + code return with_identity(code) diff --git a/python/figgyplot/_color_scale.py b/python/figgyplot/_color_scale.py new file mode 100644 index 0000000..9aa51b3 --- /dev/null +++ b/python/figgyplot/_color_scale.py @@ -0,0 +1,68 @@ +"""Shared color-domain mutation for constructors, artists and scale handles.""" + +import json +import math + +import numpy as np + + +def colorbar(config, values, cmap, vmin, vmax): + finite = np.asarray(values)[np.isfinite(values)] + if not finite.size: + raise ValueError("mapped values need at least one finite value") + low = float(finite.min()) if vmin is None else float(vmin) + high = float(finite.max()) if vmax is None else float(vmax) + if low == high: + high = low + max(1.0, abs(low) * 0.01) + _set_colorbar(config, cmap, low, high) + + +_COLOR_UNSET = object() + + +def fit_colorbar_title(config): + """Fit an unpositioned label with renderer metrics; preserve manual offsets.""" + bar = config.get("colorbar") + if not bar or not bar["visible"]: + return + title = bar["axis"]["title_option"] + if not title["visible"] or title["offset_x"] or title["offset_y"]: + return + from ._native import fitted_colorbar_title_margin + + bar["axis"]["out_margin"] = fitted_colorbar_title_margin(json.dumps(config)) + + +def update_color_scale( + config, *, cmap=_COLOR_UNSET, vmin=None, vmax=None, reset_spacing=False +): + """Apply one complete scale change and fit its label once. + + The caller owns a detached SSOT snapshot. Constructors reset spacing for + their new data domain; edits retain the user's accepted spacing. + """ + from .figure import _colormap + from ._native import default_colorbar_json + + bar = config.get("colorbar") + if bar is None: + bar = json.loads(default_colorbar_json()) + axis = bar["axis"] + low = axis["min"] if vmin is None else float(vmin) + high = axis["max"] if vmax is None else float(vmax) + if not math.isfinite(low) or not math.isfinite(high) or low >= high: + raise ValueError("mapped value domain must be finite and increasing") + if cmap is not _COLOR_UNSET: + bar["colormap"] = _colormap(cmap) + axis.update(min=low, max=high) + if reset_spacing: + axis["major_spacing"] = (high - low) / 5 + config["colorbar"] = bar + # Palette changes do not change tick or title geometry. + if vmin is not None or vmax is not None: + fit_colorbar_title(config) + + +def _set_colorbar(config, cmap, low, high): + """Initialize a domain while retaining accepted colorbar appearance.""" + update_color_scale(config, cmap=cmap, vmin=low, vmax=high, reset_spacing=True) diff --git a/python/figgyplot/_colors.py b/python/figgyplot/_colors.py new file mode 100644 index 0000000..bbb3f79 --- /dev/null +++ b/python/figgyplot/_colors.py @@ -0,0 +1,155 @@ +"""Named colors from CSS Color Module Level 4 (standardized color values). + +https://www.w3.org/TR/css-color-4/#named-colors +""" + +CSS4_COLORS = { + "aliceblue": "#F0F8FF", + "antiquewhite": "#FAEBD7", + "aqua": "#00FFFF", + "aquamarine": "#7FFFD4", + "azure": "#F0FFFF", + "beige": "#F5F5DC", + "bisque": "#FFE4C4", + "black": "#000000", + "blanchedalmond": "#FFEBCD", + "blue": "#0000FF", + "blueviolet": "#8A2BE2", + "brown": "#A52A2A", + "burlywood": "#DEB887", + "cadetblue": "#5F9EA0", + "chartreuse": "#7FFF00", + "chocolate": "#D2691E", + "coral": "#FF7F50", + "cornflowerblue": "#6495ED", + "cornsilk": "#FFF8DC", + "crimson": "#DC143C", + "cyan": "#00FFFF", + "darkblue": "#00008B", + "darkcyan": "#008B8B", + "darkgoldenrod": "#B8860B", + "darkgray": "#A9A9A9", + "darkgreen": "#006400", + "darkgrey": "#A9A9A9", + "darkkhaki": "#BDB76B", + "darkmagenta": "#8B008B", + "darkolivegreen": "#556B2F", + "darkorange": "#FF8C00", + "darkorchid": "#9932CC", + "darkred": "#8B0000", + "darksalmon": "#E9967A", + "darkseagreen": "#8FBC8F", + "darkslateblue": "#483D8B", + "darkslategray": "#2F4F4F", + "darkslategrey": "#2F4F4F", + "darkturquoise": "#00CED1", + "darkviolet": "#9400D3", + "deeppink": "#FF1493", + "deepskyblue": "#00BFFF", + "dimgray": "#696969", + "dimgrey": "#696969", + "dodgerblue": "#1E90FF", + "firebrick": "#B22222", + "floralwhite": "#FFFAF0", + "forestgreen": "#228B22", + "fuchsia": "#FF00FF", + "gainsboro": "#DCDCDC", + "ghostwhite": "#F8F8FF", + "gold": "#FFD700", + "goldenrod": "#DAA520", + "gray": "#808080", + "green": "#008000", + "greenyellow": "#ADFF2F", + "grey": "#808080", + "honeydew": "#F0FFF0", + "hotpink": "#FF69B4", + "indianred": "#CD5C5C", + "indigo": "#4B0082", + "ivory": "#FFFFF0", + "khaki": "#F0E68C", + "lavender": "#E6E6FA", + "lavenderblush": "#FFF0F5", + "lawngreen": "#7CFC00", + "lemonchiffon": "#FFFACD", + "lightblue": "#ADD8E6", + "lightcoral": "#F08080", + "lightcyan": "#E0FFFF", + "lightgoldenrodyellow": "#FAFAD2", + "lightgray": "#D3D3D3", + "lightgreen": "#90EE90", + "lightgrey": "#D3D3D3", + "lightpink": "#FFB6C1", + "lightsalmon": "#FFA07A", + "lightseagreen": "#20B2AA", + "lightskyblue": "#87CEFA", + "lightslategray": "#778899", + "lightslategrey": "#778899", + "lightsteelblue": "#B0C4DE", + "lightyellow": "#FFFFE0", + "lime": "#00FF00", + "limegreen": "#32CD32", + "linen": "#FAF0E6", + "magenta": "#FF00FF", + "maroon": "#800000", + "mediumaquamarine": "#66CDAA", + "mediumblue": "#0000CD", + "mediumorchid": "#BA55D3", + "mediumpurple": "#9370DB", + "mediumseagreen": "#3CB371", + "mediumslateblue": "#7B68EE", + "mediumspringgreen": "#00FA9A", + "mediumturquoise": "#48D1CC", + "mediumvioletred": "#C71585", + "midnightblue": "#191970", + "mintcream": "#F5FFFA", + "mistyrose": "#FFE4E1", + "moccasin": "#FFE4B5", + "navajowhite": "#FFDEAD", + "navy": "#000080", + "oldlace": "#FDF5E6", + "olive": "#808000", + "olivedrab": "#6B8E23", + "orange": "#FFA500", + "orangered": "#FF4500", + "orchid": "#DA70D6", + "palegoldenrod": "#EEE8AA", + "palegreen": "#98FB98", + "paleturquoise": "#AFEEEE", + "palevioletred": "#DB7093", + "papayawhip": "#FFEFD5", + "peachpuff": "#FFDAB9", + "peru": "#CD853F", + "pink": "#FFC0CB", + "plum": "#DDA0DD", + "powderblue": "#B0E0E6", + "purple": "#800080", + "rebeccapurple": "#663399", + "red": "#FF0000", + "rosybrown": "#BC8F8F", + "royalblue": "#4169E1", + "saddlebrown": "#8B4513", + "salmon": "#FA8072", + "sandybrown": "#F4A460", + "seagreen": "#2E8B57", + "seashell": "#FFF5EE", + "sienna": "#A0522D", + "silver": "#C0C0C0", + "skyblue": "#87CEEB", + "slateblue": "#6A5ACD", + "slategray": "#708090", + "slategrey": "#708090", + "snow": "#FFFAFA", + "springgreen": "#00FF7F", + "steelblue": "#4682B4", + "tan": "#D2B48C", + "teal": "#008080", + "thistle": "#D8BFD8", + "tomato": "#FF6347", + "turquoise": "#40E0D0", + "violet": "#EE82EE", + "wheat": "#F5DEB3", + "white": "#FFFFFF", + "whitesmoke": "#F5F5F5", + "yellow": "#FFFF00", + "yellowgreen": "#9ACD32", +} diff --git a/python/figgyplot/_document.py b/python/figgyplot/_document.py index a9805c9..2349212 100644 --- a/python/figgyplot/_document.py +++ b/python/figgyplot/_document.py @@ -17,11 +17,7 @@ def initial_identity(figure_id=None): def panel_config(state, panel_id): - panels = state['identity']['panels'] - for index, panel in enumerate(panels): - if panel['id'] == panel_id: - return state['config'] if index == 0 else state['config']['subplots'][index-1]['config'] - raise ValueError(f'subplot {panel_id!r} no longer exists') + return _panel_at(state, panel_index(state, panel_id)) def panel_index(state, panel_id): @@ -31,13 +27,31 @@ def panel_index(state, panel_id): raise ValueError(f'subplot {panel_id!r} no longer exists') +def _panel_at(state, index): + return state['config'] if index == 0 else state['config']['subplots'][index-1]['config'] + + +def panel_configs(state): + """Build a short-lived ID lookup from this snapshot, never cached across edits.""" + return {panel['id']: _panel_at(state, index) + for index, panel in enumerate(state['identity']['panels'])} + + +def series_panel_id(state, series_id): + """Panel owning an existing series; overlays share that panel's color scale.""" + for index, panel in enumerate(state['config'].get('subplots', []), start=1): + if series_id in panel.get('series', []): + return state['identity']['panels'][index]['id'] + return state['identity']['panels'][0]['id'] + + def overlay_config(state, panel_id, overlay_id): index = panel_index(state, panel_id) try: overlay = state['identity']['panels'][index]['overlays'].index(overlay_id) except ValueError: raise ValueError(f'overlay {overlay_id!r} no longer exists') from None - return panel_config(state, panel_id)['overlays'][overlay] + return _panel_at(state, index)['overlays'][overlay] def target_layer(state, panel_id, overlay_id=None): @@ -120,27 +134,62 @@ def object_ids(state): def tick_owners(meta, owner, key): - owners={owner};dimension={'bottom_x':'x','left_y':'y'}.get(key) - if dimension is None:return owners - while True: - before=len(owners) - for group in meta['groups'].values(): - if group['axis']==dimension and owners.intersection(group['members']):owners.update(group['members']) - if len(owners)==before:return owners + """Connected group members in linear membership work, including cycles.""" + dimension = {'bottom_x': 'x', 'left_y': 'y'}.get(key) + if dimension is None: + return {owner} + membership = {} + groups = [] + for group in meta['groups'].values(): + if group['axis'] != dimension: + continue + index = len(groups) + members = group['members'] + groups.append(members) + for member in members: + membership.setdefault(member, []).append(index) + owners, pending, visited = {owner}, [owner], set() + while pending: + for index in membership.get(pending.pop(), ()): + if index in visited: + continue + visited.add(index) + for member in groups[index]: + if member not in owners: + owners.add(member) + pending.append(member) + return owners def set_tick_mode(state, panel_id, overlay_id, side, automatic, *, paired=True, spacing=None): - meta=state.get('identity') - if meta is None:return - owner=overlay_id if overlay_id and side in ('right_y','y_axis') else panel_id - key='y_axis' if owner==overlay_id else side - for member in tick_owners(meta,owner,key): - meta.setdefault('tick_modes',{}).setdefault(member,{})[key]='Auto' if automatic else 'Fixed' - if spacing is not None and key in ('bottom_x','left_y'):panel_config(state,member)[key]['major_spacing']=spacing - peer={'bottom_x':'top_x','top_x':'bottom_x','left_y':'right_y','right_y':'left_y'}.get(key) - cfg=panel_config(state,panel_id) - if paired and peer and all(cfg[key].get(k)==cfg[peer].get(k) for k in ('min','max','scale','inverted')): - for member in tick_owners(meta,owner,peer):meta['tick_modes'].setdefault(member,{})[peer]='Auto' if automatic else 'Fixed' + meta = state.get('identity') + if meta is None: + return + owner = overlay_id if overlay_id and side in ('right_y', 'y_axis') else panel_id + key = 'y_axis' if owner == overlay_id else side + + def assign(member, axis): + if automatic is not None: + meta.setdefault('tick_modes', {}).setdefault(member, {})[axis] = 'Auto' if automatic else 'Fixed' + else: + modes = meta.get('tick_modes', {}) + if member in modes: + modes[member].pop(axis, None) + if not modes[member]: + modes.pop(member) + if not modes: + meta.pop('tick_modes', None) + + configs = panel_configs(state) if spacing is not None and key in ('bottom_x', 'left_y') else {} + for member in tick_owners(meta, owner, key): + assign(member, key) + if configs: + configs[member][key]['major_spacing'] = spacing + peer = {'bottom_x': 'top_x', 'top_x': 'bottom_x', 'left_y': 'right_y', 'right_y': 'left_y'}.get(key) + cfg = panel_config(state, panel_id) + if paired and peer and all(cfg[key].get(k) == cfg[peer].get(k) for k in ('min', 'max', 'scale', 'inverted')): + for member in tick_owners(meta, owner, peer): + assign(member, peer) def manual_tick_patch(state, patch, panel_id=None, overlay_id=None, _claimed=None): @@ -168,11 +217,12 @@ def track_identities(before, after): if meta is None: return retired = set(meta['retired']) + active = object_ids(after) current = before.get('identity') if current and current['figure'] == meta['figure']: retired.update(current['retired']) - retired.update(object_ids(before)-object_ids(after)) - if retired & object_ids(after): + retired.update(object_ids(before)-active) + if retired & active: raise ValueError('retired object IDs cannot be reused; assign a new ID') meta['retired'] = sorted(retired) @@ -268,11 +318,13 @@ def positions(weights, size): if available <= 0: raise ValueError('grid gaps exceed the document size') edges = [0.] + total = sum(weights) for weight in weights: - edges.append(edges[-1]+available*weight/sum(weights)+gap) + edges.append(edges[-1]+available*weight/total+gap) return edges xs = positions(layout['column_weights'], width) ys = positions(layout['row_weights'], height) + configs = panel_configs(state) for slot, panel in layout['placement'].items(): r0, c0, r1, c1 = slots[slot] rect = dict(x=round(xs[c0]), y=round(ys[r0]), @@ -280,7 +332,7 @@ def positions(weights, size): height=round(ys[r1]-gap)-round(ys[r0])) if min(rect['width'], rect['height']) < 100: raise ValueError('grid panels must be at least 100 logical pixels per dimension') - panel_config(state, panel)['chart_area'] = rect + configs[panel]['chart_area'] = rect class Subplot: diff --git a/python/figgyplot/_features.py b/python/figgyplot/_features.py index 842f26d..7f52578 100644 --- a/python/figgyplot/_features.py +++ b/python/figgyplot/_features.py @@ -5,27 +5,16 @@ import uuid import numpy as np from . import _document +from . import _style_runtime -def colorbar(config, values, cmap, vmin, vmax): - from .figure import _colormap - from ._native import default_colorbar_json - finite = np.asarray(values)[np.isfinite(values)] - if not finite.size: - raise ValueError('mapped values need at least one finite value') - low = float(finite.min()) if vmin is None else float(vmin) - high = float(finite.max()) if vmax is None else float(vmax) - if low == high: - high = low+max(1., abs(low)*0.01) - if not math.isfinite(low) or not math.isfinite(high) or low >= high: - raise ValueError('mapped value domain must be finite and increasing') - bar = copy.deepcopy(config.get('colorbar') or json.loads(default_colorbar_json())) - bar['colormap'] = _colormap(cmap) - bar['axis'].update(min=low, max=high, major_spacing=(high-low)/5) - config['colorbar'] = bar - - -class Annotation: +from ._color_scale import colorbar + + +from ._annotation_api import AnnotationEditing + + +class Annotation(AnnotationEditing): def __init__(self, figure, id): self.figure, self.id = figure, id @@ -50,11 +39,15 @@ def set(self, *, visible=None, **geometry): return self def remove(self): - state = self.figure.get_settings() - owner, layer, item = self._find(state) - layer['items'].remove(item) - owner.get('decorations', {}).update(selected=None) - self.figure.apply_settings(state) + with self.figure.batch_update(): + state = self.figure.get_settings() + owner, layer, item = self._find(state) + layer['items'].remove(item) + arrow=getattr(self,'arrow_patch',None) + if arrow: + _,arrow_layer,arrow_item=arrow._find(state);arrow_layer['items'].remove(arrow_item) + owner.get('decorations', {}).update(selected=None) + self.figure.apply_settings(state) def select(self): from ._charts import family @@ -66,14 +59,62 @@ def select(self): self.figure.apply_settings(state) return self + def get_visible(self):return self._find(self.figure.get_settings())[2]['visible'] + def set_visible(self,value): + with self.figure.batch_update(): + self.set(visible=value) + arrow=getattr(self,'arrow_patch',None) + if arrow:arrow.set(visible=value) + def get_gid(self):return self.id + def get_text(self): + from .figure import _text + body=next(iter(self._find(self.figure.get_settings())[2]['kind'].values())) + rich=body.get('label',body.get('text')) + return _text(rich) if rich else '' + def _set_text_property(self,key,value): + state=self.figure.get_settings();body=next(iter(self._find(state)[2]['kind'].values())) + rich=body.get('label',body.get('text')) + if rich is None:raise AttributeError('this annotation has no text') + rich[key]=value;self.figure.apply_settings(state) + def set_text(self,value): + from .figure import _segments + self._set_text_property('segments',_segments(value, dpi=self.figure.dpi)) + def set_fontsize(self,value): + from ._validation import number as _number + self._set_text_property('font_size',_number(value,'fontsize',positive=True)*self.figure.dpi/72) + def get_fontsize(self): + return self._rich_text()['font_size']*72/self.figure.dpi + def set_fontfamily(self,value):self._set_text_property('font',self.figure._check_font(value)) + def set_color(self,value): + from .figure import _color + self._set_text_property('color',_color(value)) + def get_position(self): + body=next(iter(self._find(self.figure.get_settings())[2]['kind'].values())) + if 'anchor' not in body:raise AttributeError('this annotation has no text position') + kind,point=next(iter(body['anchor'].items())) + return point['x'],1-point['y'] if kind=='Ratio' and getattr(self,'_axes_fraction',False) else point['y'] + def set_position(self,xy): + from ._validation import number as _number + x,y=map(lambda v:_number(v,'position'),xy) + with self.figure.batch_update(): + state=self.figure.get_settings();body=next(iter(self._find(state)[2]['kind'].values())) + kind=next(iter(body['anchor'])) + anchor={kind:dict(x=x,y=1-y if kind=='Ratio' and getattr(self,'_axes_fraction',False) else y)} + body['anchor']=anchor + arrow=getattr(self,'arrow_patch',None) + if arrow:next(iter(arrow._find(state)[2]['kind'].values()))['from']=copy.deepcopy(anchor) + self.figure.apply_settings(state) + class FeatureMethods: + @_style_runtime.styled def step(self, x, y=None, *, where='pre', **kwargs): modes = dict(pre='StepPre', post='StepPost', mid='StepMid') if where not in modes: raise ValueError('where must be pre, post or mid') return self.plot(x, y, interpolation=modes[where], **kwargs) + @_style_runtime.styled def fill_between(self, x, y1, y2=0, *, color=None, alpha=0.3, label=None, id=None, interpolation='Linear'): """Fill to a baseline or another index-aligned line sharing the X column.""" @@ -98,6 +139,7 @@ def fill_between(self, x, y1, y2=0, *, color=None, alpha=0.3, label=None, id=Non # shared by reference, with no resampling or persistent CPU array copy. return self._add_fill_pair(x, lower, upper, line, label, sid) + @_style_runtime.styled def quiver(self, x, y, u, v, *, values=None, cmap='viridis', vmin=None, vmax=None, scale=1., units='pixels', pivot='tail', color='black', width=1., head_length=6., head_width=4., min_length=0., label=None, id=None): @@ -145,6 +187,7 @@ def matrix(name, values): return self._add(x, y, {'Vector': dict(components=components, style=style)}, label, sid, extra_columns=columns, config=cfg) + @_style_runtime.styled def radar(self, categories, values, *, labels=None, colors=None, **properties): from .figure import _color, _COLORS categories = list(categories) @@ -162,7 +205,8 @@ def radar(self, categories, values, *, labels=None, colors=None, **properties): for i, (row, label, color) in enumerate(zip(values, labels, colors))] return self._bounded('radar', cats, series, **properties) - def annotate(self, kind, *, id=None, **properties): + @_style_runtime.styled + def _add_annotation(self, kind, *, id=None, **properties): """Add a document annotation using upstream geometry and logical pixels. Ratio anchors use the parent plot rectangle. Subplot-local annotation @@ -188,32 +232,92 @@ def annotate(self, kind, *, id=None, **properties): if any(item['id'] == annotation_id for item in layer['items']): raise ValueError('duplicate annotation ID') layer['items'].append(dict(id=annotation_id, visible=True, kind={kind: properties})) + layer['items'][-1]=_style_runtime.annotation(self.figure,state,layer['items'][-1]) self.figure.apply_settings(state) return Annotation(self.figure, annotation_id) + @_style_runtime.styled + def annotate(self, text, xy=None, xytext=None, xycoords='data', textcoords=None, + arrowprops=None, *, id=None, **kwargs): + """Annotate a point with optional text and a straight arrow. + + Coordinates support data and axes fraction; offset points require a + renderer transform and are rejected. Curved arrows are unsupported. + """ + # The explicit renderer constructor remains available for old callers. + if xy is None and text in ('ReferenceLine','Span','Text','Arrow'): + return self._add_annotation(text,id=id,**kwargs) + if xy is None:raise TypeError('annotate() requires xy') + textcoords=xycoords if textcoords is None else textcoords + destination=self._annotation_anchor(xy,xycoords) + position=xy if xytext is None else xytext + origin=self._annotation_anchor(position,textcoords) + props=dict(arrowprops or {}) + allowed={'arrowstyle','color','linewidth','lw','linestyle','ls','head_length','head_width'} + if set(props)-allowed:raise NotImplementedError('unsupported arrow properties: '+', '.join(sorted(set(props)-allowed))) + if props.get('arrowstyle','->')!='->':raise NotImplementedError('renderer supports straight -> arrows only') + from .figure import _color,_LINES + with self.figure.batch_update(): + note=self.text(*position,text,transform=textcoords,id=id,**kwargs) + if arrowprops is not None: + unit=self.figure.dpi/72 + note.arrow_patch=self._add_annotation('Arrow',id=note.id+'/arrow',from_=origin,to=destination,text=None, + style=dict(line=dict(color=_color(props.get('color','black')), + width=float(props.get('linewidth',props.get('lw',1)))*unit, + style=_LINES.get(props.get('linestyle',props.get('ls','-')),props.get('linestyle','-'))), + head_length=float(props.get('head_length',8))*unit,head_width=float(props.get('head_width',6))*unit)) + return note + + def _annotation_anchor(self,xy,coords): + from ._matplotlib import Transform + from ._validation import number as _number + x,y=(_number(v,'annotation coordinate') for v in xy) + if isinstance(coords,Transform): + if coords.axes is not self:raise ValueError('transform belongs to another Axes') + coords=coords.kind + if coords=='data':return {'Data':dict(x=x,y=y)} + if coords=='axes fraction':return {'Ratio':dict(x=x,y=1-y)} + raise NotImplementedError('annotation coordinates support data and axes fraction') + + @_style_runtime.styled def axhline(self, y=0, *, color='black', linewidth=1., id=None): return self._reference_line('Y', y, color, linewidth, id) + @_style_runtime.styled def axvline(self, x=0, *, color='black', linewidth=1., id=None): return self._reference_line('X', x, color, linewidth, id) def _reference_line(self, axis, value, color, width, id): from .figure import _color - return self.annotate('ReferenceLine', id=id, axis=axis, value=float(value), label=None, + return self._add_annotation('ReferenceLine', id=id, axis=axis, value=float(value), label=None, style=dict(color=_color(color), width=float(width)*self.figure.dpi/72, style='Solid')) - def text(self, x, y, text, *, coords='data', fontsize=11, color='black', id=None): + @_style_runtime.styled + def text(self, x, y, text, *, transform=None, coords='data', fontsize=11, color='black', + fontfamily=None, fontweight='normal', fontstyle='normal', id=None): from .figure import _color, _segments if coords not in ('data', 'ratio'): raise ValueError('coords must be data or ratio') + from ._matplotlib import Transform + from ._validation import number as _number + if fontweight not in ('normal','bold',400,700) or fontstyle not in ('normal','italic'): + raise NotImplementedError('supported text styles are normal/bold and normal/italic') rich = copy.deepcopy(self.figure._default_config['legend']['content']) - rich.update(segments=_segments(str(text)), font_size=float(fontsize)*self.figure.dpi/72, color=_color(color)) - return self.annotate('Text', id=id, anchor={coords.title(): dict(x=float(x), y=float(y))}, - text=rich, box_style=None) + rich.update(segments=_segments(text, dpi=self.figure.dpi), font_size=_number(fontsize,'fontsize',positive=True)*self.figure.dpi/72, color=_color(color)) + if fontfamily is not None:rich['font']=self.figure._check_font(fontfamily) + for segment in rich['segments']: + if fontweight in ('bold',700):segment['bold']=True + if fontstyle=='italic':segment['italic']=True + anchor=self._annotation_anchor((x,y),transform or 'data') if transform is not None or coords=='data' else {'Ratio':dict(x=_number(x,'x'),y=_number(y,'y'))} + note=self._add_annotation('Text', id=id, anchor=anchor,text=rich, box_style=None) + note._axes_fraction=transform=='axes fraction' or isinstance(transform,Transform) and transform.kind=='axes fraction' + return note + @_style_runtime.styled def axhspan(self, ymin, ymax, *, color='gray', alpha=0.25, id=None): return self._span('Y', ymin, ymax, color, alpha, id) + @_style_runtime.styled def axvspan(self, xmin, xmax, *, color='gray', alpha=0.25, id=None): return self._span('X', xmin, xmax, color, alpha, id) @@ -223,15 +327,16 @@ def _span(self, axis, low, high, color, alpha, id): raise ValueError('alpha must be in 0..1') rgba = _color(color) rgba['a'] *= float(alpha) - return self.annotate('Span', id=id, axis=axis, from_=float(low), to=float(high), fill=rgba) + return self._add_annotation('Span', id=id, axis=axis, from_=float(low), to=float(high), fill=rgba) + @_style_runtime.styled def arrow(self, x, y, dx, dy, *, coords='data', color='black', linewidth=1., head_length=8., head_width=6., id=None): from .figure import _color if coords not in ('data', 'ratio'): raise ValueError('coords must be data or ratio') unit = self.figure.dpi/72 - return self.annotate('Arrow', id=id, from_={coords.title(): dict(x=float(x), y=float(y))}, + return self._add_annotation('Arrow', id=id, from_={coords.title(): dict(x=float(x), y=float(y))}, to={coords.title(): dict(x=float(x+dx), y=float(y+dy))}, text=None, style=dict(line=dict(color=_color(color), width=float(linewidth)*unit, style='Solid'), head_length=float(head_length)*unit, head_width=float(head_width)*unit)) diff --git a/python/figgyplot/_field_api.py b/python/figgyplot/_field_api.py new file mode 100644 index 0000000..6e2a477 --- /dev/null +++ b/python/figgyplot/_field_api.py @@ -0,0 +1,165 @@ +"""Scalar-field appearance and explicit contour-label placement.""" + +import copy +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ContourLabel: + """A label on a contour level, in data coordinates with a data tangent.""" + + level: int + x: float + y: float + tangent: tuple = (1, 0) + + +class ContourAppearance: + def __init__(self, artist): + self.artist = artist + self.figure = artist.figure + self._node(self.figure.get_settings()) + + def _node(self, state): + body = self.artist._body(state) + if "contour" not in body: + raise AttributeError("this artist has no contour lines") + return body["contour"] + + def _edit(self, operation): + state = self.figure.get_settings() + operation(self._node(state)) + self.figure.apply_settings(state) + return self + + def set_levels(self, levels): + from ._validation import number as _number + + values = [_number(v, "contour level") for v in levels] + return self._edit(lambda node: node.update(levels=values)) + + def get_levels(self): + return tuple(self._node(self.figure.get_settings())["levels"]) + + def set_style(self, *, color=None, linewidth=None, linestyle=None, colors=...): + from .figure import _color, _LINES + from ._validation import number as _number + + def edit(node): + if color is not None: + node["line"]["line_color"] = _color(color) + if linewidth is not None: + node["line"]["line_width"] = ( + _number(linewidth, "linewidth") * self.figure.dpi / 72 + ) + if linestyle is not None: + if linestyle not in _LINES: + raise ValueError("linestyle must be -, --, : or -.") + node["line"]["line_style"] = _LINES[linestyle] + if colors is not ...: + if colors is None: + node.pop("per_level_color", None) + else: + node["per_level_color"] = [_color(v) for v in colors] + + return self._edit(edit) + + def set_labels( + self, + visible=True, + *, + fontsize=None, + color=None, + formatter=None, + spacing=None, + positions=None, + background=..., + padding=None, + ): + """Style labels or place ContourLabel records; positions=[] restores auto placement. + + visible=None removes label configuration. Lengths use points. + """ + from .figure import _color + from ._validation import number as _number + from .ticker import SignificantFormatter + + def edit(node): + if visible is None: + node.pop("labels", None) + return + labels = node.setdefault( + "labels", + dict( + visible=True, + font_size=12.0, + color=_color("black"), + format="Decimal", + significant_digits=3, + spacing_px=100.0, + anchors=[], + bg_padding_px=0.0, + ), + ) + labels["visible"] = bool(visible) + for public, key in ( + (fontsize, "font_size"), + (spacing, "spacing_px"), + (padding, "bg_padding_px"), + ): + if public is not None: + labels[key] = _number(public, key) * self.figure.dpi / 72 + if color is not None: + labels["color"] = _color(color) + if formatter is not None: + if not isinstance(formatter, SignificantFormatter): + raise TypeError( + "formatter requires SignificantFormatter or TimestampFormatter" + ) + labels.update( + format=formatter._format(), significant_digits=formatter.precision + ) + if background is not ...: + if background is None: + labels.pop("bg_color", None) + else: + labels["bg_color"] = _color(background) + if positions is not None: + anchors = [] + for position in positions: + if not isinstance(position, ContourLabel): + raise TypeError("positions must contain ContourLabel values") + if ( + isinstance(position.level, bool) + or not isinstance(position.level, int) + or position.level < 0 + ): + raise ValueError("label level must be a nonnegative index") + tx, ty = position.tangent + anchors.append( + dict( + level_index=position.level, + x=_number(position.x, "x"), + y=_number(position.y, "y"), + tx=_number(tx, "tangent"), + ty=_number(ty, "tangent"), + ) + ) + labels["anchors"] = anchors + + return self._edit(edit) + + +def set_grid(artist, source): + """Bind an already registered matrix without uploading or transposing data.""" + from ._artist_appearance import GridReference + + if not isinstance(source, GridReference) or source.figure is not artist.figure: + raise ValueError("source must be a grid registered with this Figure") + state = artist.figure.get_settings() + body = artist._body(state) + if "matrix" not in body: + raise AttributeError("artist is not a scalar field") + body["matrix"] = copy.deepcopy(source._native()) + artist.figure.apply_settings(state) + return artist diff --git a/python/figgyplot/_markers.py b/python/figgyplot/_markers.py new file mode 100644 index 0000000..8d5090e --- /dev/null +++ b/python/figgyplot/_markers.py @@ -0,0 +1,25 @@ +"""Matplotlib marker symbols and full/none fill mapped to native shapes.""" + + +def decode_marker(shape): + from .figure import _MARKERS + + reverse = {value: key for key, value in _MARKERS.items()} + if shape in reverse: + return reverse[shape], None + if shape + "Filled" in reverse: + return reverse[shape + "Filled"], "none" + raise ValueError("unknown native marker shape") + + +def encode_marker(marker, fillstyle="full"): + from .figure import _MARKERS + + if fillstyle not in ("full", "none"): + raise ValueError("fillstyle must be full or none") + if marker not in _MARKERS: + raise ValueError("unknown marker symbol") + shape = _MARKERS[marker] + if fillstyle == "none": + return shape.removesuffix("Filled") + return {"Cross": "CrossFilled", "Plus": "PlusFilled"}.get(shape, shape) diff --git a/python/figgyplot/_matplotlib.py b/python/figgyplot/_matplotlib.py new file mode 100644 index 0000000..45183e9 --- /dev/null +++ b/python/figgyplot/_matplotlib.py @@ -0,0 +1,2160 @@ +"""Public plotting objects; renderer-specific storage stays behind these handles.""" + +import copy +import json +import math +from dataclasses import dataclass +from contextlib import contextmanager + +import numpy as np + +from ._validation import number as _number + + +@dataclass(frozen=True) +class Bbox: + x0: float + y0: float + width: float + height: float + + @property + def bounds(self): + return self.x0, self.y0, self.width, self.height + + @property + def x1(self): + return self.x0 + self.width + + @property + def y1(self): + return self.y0 + self.height + + def get_points(self): + return np.array([[self.x0, self.y0], [self.x1, self.y1]]) + + def frozen(self): + return self + + +@dataclass(frozen=True) +class Transform: + axes: object + kind: str + + +def canvas_size(state): + layout = state["identity"].get("layout") + if layout: + return tuple(layout["size"]) + areas = [state["config"]["chart_area"]] + [ + p["config"]["chart_area"] for p in state["config"].get("subplots", []) + ] + return max(p["x"] + p["width"] for p in areas), max( + p["y"] + p["height"] for p in areas + ) + + +def _locator_spacing(locator, node, axes): + """Interpret a locator once, independently of plot/colorbar ownership.""" + from .ticker import AutoLocator, LogLocator, MultipleLocator + + if isinstance(locator, AutoLocator): + from ._native import fitted_range_json + + # The renderer's range helper derives 1/2/5 or log-decade spacing. + # Use an isolated Config and copy only the spacing, so a locator never + # resets another boundary's range, formatter, visibility or tick intent. + config = copy.deepcopy(axes.axes.figure._default_config) + dimension = axes.dimension + key = "bottom_x" if dimension == "x" else "left_y" + config[key] = copy.deepcopy(node) + return json.loads( + fitted_range_json(json.dumps(config), dimension, node["min"], node["max"]) + )[key]["major_spacing"] + if isinstance(locator, LogLocator): + if node["scale"] != "Logarithmic": + raise ValueError("LogLocator requires a logarithmic axis") + return math.log10(locator.base) + if not hasattr(locator, "tick_values"): + raise TypeError("locator must provide tick_values(vmin,vmax)") + if node["scale"] != "Linear": + raise NotImplementedError("MultipleLocator requires a linear axis") + values = np.asarray(locator.tick_values(node["min"], node["max"]), dtype=float) + diffs = np.diff(values) + if ( + len(diffs) < 1 + or not np.isfinite(values).all() + or not np.allclose(diffs, diffs[0]) + or diffs[0] <= 0 + ): + raise NotImplementedError("the renderer supports regular major tick locators") + step = locator.base if isinstance(locator, MultipleLocator) else float(diffs[0]) + if not np.allclose(values / step, np.round(values / step), rtol=0, atol=1e-7): + raise NotImplementedError( + "offset or arbitrary-position major ticks need renderer support" + ) + return step + + +class Text: + """A stable chart/axis title, with sizes in points and ordinary text setters.""" + + def __init__(self, axes, role): + self.axes, self.role = axes, role + + def _node(self, state): + from ._charts import family + + kind = family(state) + if kind: + if self.role != "title": + raise NotImplementedError( + "independent bounded axis-label typography is not supported by the renderer" + ) + from ._chart_text import title_options + + return title_options(state[kind]) + if self.role == "ylabel" and self.axes.overlay_id: + return self.axes._overlay(state)["y_axis"]["title_option"] + cfg = self.axes._config(state) + sides = { + "xlabel": "bottom_x", + "ylabel": "left_y", + "toplabel": "top_x", + "rightlabel": "right_y", + } + return ( + cfg["chart_title"] + if self.role == "title" + else cfg[sides[self.role]]["title_option"] + ) + + def get_text(self): + from .figure import _text + from ._charts import family + + state = self.axes.figure.get_settings() + kind = family(state) + if kind and self.role != "title": + horizontal = state[kind].get("direction") == "Horizontal" + return state[kind][ + "value_title" + if (self.role == "xlabel") == horizontal + else "category_title" + ] + return _text(self._node(state)["text"]) + + def set_text(self, value): + self.set(text=value) + + def get_fontsize(self): + return ( + self._node(self.axes.figure.get_settings())["text"]["font_size"] + * 72 + / self.axes.figure.dpi + ) + + def get_color(self): + c = self._node(self.axes.figure.get_settings())["text"]["color"] + return tuple(c[k] for k in "rgba") + + def get_fontfamily(self): + return [self._node(self.axes.figure.get_settings())["text"]["font"]] + + def get_visible(self): + return self._node(self.axes.figure.get_settings())["visible"] + + def get_fontweight(self): + segments = self._node(self.axes.figure.get_settings())["text"]["segments"] + weights = {s["bold"] for s in segments} + if len(weights) > 1: + raise NotImplementedError("this rich text has mixed font weights") + return "bold" if weights == {True} else "normal" + + def get_fontstyle(self): + styles = { + s["italic"] + for s in self._node(self.axes.figure.get_settings())["text"]["segments"] + } + if len(styles) > 1: + raise NotImplementedError("this rich text has mixed font styles") + return "italic" if styles == {True} else "normal" + + def set(self, **properties): + from .figure import _color + + aliases = { + "size": "fontsize", + "family": "fontfamily", + "weight": "fontweight", + "style": "fontstyle", + } + properties = {aliases.get(k, k): v for k, v in properties.items()} + allowed = { + "fontsize", + "fontfamily", + "fontweight", + "fontstyle", + "color", + "visible", + "text", + "multialignment", + } + if set(properties) - allowed: + raise ValueError( + "unsupported Text properties: " + + ", ".join(sorted(set(properties) - allowed)) + ) + fig = self.axes.figure + state = fig.get_settings() + node = self._node(state) + rich = node["text"] + for key, value in properties.items(): + if key == "fontsize": + rich["font_size"] = ( + _number(value, "fontsize", positive=True) * fig.dpi / 72 + ) + elif key == "fontfamily": + font = value[0] if isinstance(value, (list, tuple)) else value + rich["font"] = fig._check_font(font) + elif key == "color": + rich["color"] = _color(value) + elif key == "visible": + node["visible"] = bool(value) + elif key == "multialignment": + if value not in ("left", "center", "right"): + raise ValueError("multialignment must be left, center or right") + rich["align"] = value.title() + elif key == "text": + from .figure import _segments + from ._charts import family + + if family(state): + from ._chart_text import set_title_text + + set_title_text( + state[family(state)], segments=_segments(value, dpi=fig.dpi) + ) + else: + rich["segments"] = _segments(value, dpi=fig.dpi) + elif key in ("fontweight", "fontstyle"): + if key == "fontweight": + if value not in ("normal", "bold", 400, 700): + raise NotImplementedError( + "renderer supports normal and bold font weights" + ) + flag = "bold" + enabled = value in ("bold", 700) + else: + if value not in ("normal", "italic"): + raise NotImplementedError( + "renderer supports normal and italic font styles" + ) + flag = "italic" + enabled = value != "normal" + for segment in rich["segments"]: + segment[flag] = enabled + if ( + self.role == "title" + and not state.get("categorical") + and not state.get("boxplot") + and not state.get("radar") + and not state.get("radial") + ): + if set(properties) & { + "fontsize", + "fontfamily", + "text", + "visible", + "fontweight", + "fontstyle", + }: + from ._native import fitted_chart_title_margin + + cfg = self.axes._config(state) + cfg["chart_title"]["top_margin"] = fitted_chart_title_margin( + json.dumps(cfg, allow_nan=False) + ) + self._finish_edit(state) + fig.apply_settings(state) + return self + + def _finish_edit(self, state): + """Specialized text layout, before the single document commit.""" + + def set_fontsize(self, value): + self.set(fontsize=value) + + def set_color(self, value): + self.set(color=value) + + def set_fontfamily(self, value): + self.set(fontfamily=value) + + def set_fontweight(self, value): + self.set(fontweight=value) + + def set_fontstyle(self, value): + self.set(fontstyle=value) + + def set_visible(self, value): + self.set(visible=value) + + def set_multialignment(self, value): + self.set(multialignment=value) + + def set_offset(self, offset): + """Figgy extension: title offset in points in the title's local frame.""" + x, y = offset + fig = self.axes.figure + state = fig.get_settings() + node = self._node(state) + if "offset_x" not in node: + raise NotImplementedError("bounded title offsets are automatic") + node["offset_x"] = _number(x, "x offset") * fig.dpi / 72 + node["offset_y"] = _number(y, "y offset") * fig.dpi / 72 + fig.apply_settings(state) + + def set_layout_height(self, height): + """Figgy extension: reserve a title band, in points, without moving data.""" + if self.role != "title": + raise NotImplementedError("only chart titles have a title band") + fig = self.axes.figure + state = fig.get_settings() + node = self._node(state) + if "top_margin" not in node: + raise NotImplementedError("bounded title layout is automatic") + value = _number(height, "title band height") * fig.dpi / 72 + if value < 0: + raise ValueError("title band height must be nonnegative") + node["top_margin"] = value + fig.apply_settings(state) + + +class ArtistLabel(Text): + """Typography of one legend entry, addressed by its artist's stable ID.""" + + def __init__(self, artist): + self.artist, self.axes, self.role = ( + artist, + artist.figure.axes[0], + "legend_label", + ) + + def _node(self, state): + label = self.artist._spec(state).get("label") + if label is None: + raise AttributeError("set the artist label before editing its typography") + return {"text": label, "visible": True} + + def set(self, **properties): + if "visible" in properties: + raise NotImplementedError( + "use artist.set_label(None) to remove a legend entry" + ) + return super().set(**properties) + + def set_offset(self, *args): + raise NotImplementedError("individual legend-entry offsets are not supported") + + def set_layout_height(self, *args): + raise NotImplementedError( + "individual legend-entry layout bands are not supported" + ) + + +class Spine: + def __init__(self, axes, side): + self.axes, self.side = axes, side + + def _node(self, state): + self.axes.figure._require_cartesian() + if self.side == "right" and self.axes.overlay_id: + return self.axes._overlay(state)["y_axis"] + return self.axes._config(state)[ + { + "bottom": "bottom_x", + "top": "top_x", + "left": "left_y", + "right": "right_y", + }[self.side] + ] + + def get_visible(self): + return self._node(self.axes.figure.get_settings())["line_visible"] + + def get_linewidth(self): + return ( + self._node(self.axes.figure.get_settings())["line_width"] + * 72 + / self.axes.figure.dpi + ) + + def get_edgecolor(self): + c = self._node(self.axes.figure.get_settings())["line_color"] + return tuple(c[k] for k in "rgba") + + def set(self, **properties): + from .figure import _color, _LINES + + fig = self.axes.figure + state = fig.get_settings() + node = self._node(state) + for key, value in properties.items(): + if key == "visible": + node["line_visible"] = bool(value) + elif key in ("color", "edgecolor"): + node["line_color"] = _color(value) + elif key in ("linewidth", "lw"): + node["line_width"] = _number(value, "linewidth") * fig.dpi / 72 + elif key in ("linestyle", "ls"): + node["line_style"] = _LINES.get(value, value) + else: + raise ValueError(f"unsupported spine property: {key}") + fig.apply_settings(state) + return self + + def set_visible(self, value): + self.set(visible=value) + + def set_color(self, value): + self.set(color=value) + + def set_edgecolor(self, value): + self.set(edgecolor=value) + + def set_linewidth(self, value): + self.set(linewidth=value) + + def set_linestyle(self, value): + self.set(linestyle=value) + + def set_position(self, position): + if ( + not isinstance(position, (tuple, list)) + or len(position) != 2 + or position[0] != "outward" + ): + raise NotImplementedError("spine position supports (outward, points) only") + fig = self.axes.figure + state = fig.get_settings() + node = self._node(state) + node["line_offset"] = _number(position[1], "spine offset") * fig.dpi / 72 + fig.apply_settings(state) + + def get_position(self): + return "outward", self._node(self.axes.figure.get_settings())[ + "line_offset" + ] * 72 / self.axes.figure.dpi + + +class Axis: + def __init__(self, axes, dimension, side=None): + self.axes, self.dimension, self.side = axes, dimension, side + + @property + def label(self): + return Text( + self.axes, + {"top": "toplabel", "right": "rightlabel"}.get( + self.side, "xlabel" if self.dimension == "x" else "ylabel" + ), + ) + + @property + def line(self): + return AxisLine(self) + + def set_label_text(self, label, **kwargs): + self.label.set(text=label, **kwargs) + return self.label + + def get_view_interval(self): + node = self._nodes(self.axes.figure.get_settings())[0] + values = [node["min"], node["max"]] + return np.array(values[::-1] if node["inverted"] else values) + + def set_view_interval(self, vmin, vmax, ignore=False): + low = _number(vmin, "minimum") + high = _number(vmax, "maximum") + if low == high: + raise ValueError("limits must be different") + fig = self.axes.figure + state = fig.get_settings() + node = self._nodes(state)[0] + node.update(min=min(low, high), max=max(low, high), inverted=low > high) + fig.apply_settings(state) + + def set_tick_params(self, which="major", reset=False, **kwargs): + return self.axes._tick_params( + axis=self.dimension, + which=which, + reset=reset, + sides=None if self.side is None else [self.side], + **kwargs, + ) + + def _nodes(self, state): + self.axes.figure._require_cartesian() + cfg = self.axes._config(state) + if self.side: + key = { + "bottom": "bottom_x", + "top": "top_x", + "left": "left_y", + "right": "right_y", + }[self.side] + return [ + self.axes._overlay(state)["y_axis"] + if self.axes.overlay_id and self.side == "right" + else cfg[key] + ] + if self.dimension == "y" and self.axes.overlay_id: + return [self.axes._overlay(state)["y_axis"]] + primary, peer = ( + (cfg["bottom_x"], cfg["top_x"]) + if self.dimension == "x" + else (cfg["left_y"], cfg["right_y"]) + ) + matching = all( + primary[k] == peer[k] for k in ("min", "max", "scale", "inverted") + ) + return [primary, peer] if matching else [primary] + + def set_major_locator(self, locator): + from .ticker import AutoLocator + from . import _document + + fig = self.axes.figure + state = fig.get_settings() + nodes = self._nodes(state) + step = _locator_spacing(locator, nodes[0], self) + for node in nodes: + node["major_spacing"] = step + key = ( + { + "bottom": "bottom_x", + "top": "top_x", + "left": "left_y", + "right": "right_y", + }[self.side] + if self.side + else "bottom_x" + if self.dimension == "x" + else "left_y" + ) + if self.axes.overlay_id and self.dimension == "y": + key = "y_axis" + _document.set_tick_mode( + state, + self.axes.subplot_id, + self.axes.overlay_id, + key, + isinstance(locator, AutoLocator), + paired=self.side is None, + spacing=step, + ) + fig.apply_settings(state) + + def set_layout_margin(self, value): + """Figgy extension: outer axis margin in points.""" + fig = self.axes.figure + state = fig.get_settings() + for node in self._nodes(state): + node["out_margin"] = _number(value, "axis margin") * fig.dpi / 72 + fig.apply_settings(state) + + def set_ticklabel_offset(self, offset): + """Figgy extension: numeric-label translation in points.""" + fig = self.axes.figure + state = fig.get_settings() + x, y = offset + for node in self._nodes(state): + node["label_style"]["label_offset_x"] = ( + _number(x, "x offset") * fig.dpi / 72 + ) + node["label_style"]["label_offset_y"] = ( + _number(y, "y offset") * fig.dpi / 72 + ) + fig.apply_settings(state) + + def get_major_locator(self): + from .ticker import MultipleLocator, LogLocator + + node = self._nodes(self.axes.figure.get_settings())[0] + return ( + LogLocator(10 ** node["major_spacing"]) + if node["scale"] == "Logarithmic" + else MultipleLocator(node["major_spacing"]) + ) + + def set_tick_auto(self, automatic=True): + """Choose future auto/fixed ticks without replanning the current spacing. + + ``None`` removes an explicit policy and restores the implicit automatic + default. It also removes the override from the document snapshot. + """ + from . import _document + + fig = self.axes.figure + state = fig.get_settings() + sides = ( + [self.side] + if self.side + else ["bottom", "top"] + if self.dimension == "x" + else ["left", "right"] + ) + for side in sides: + key = { + "bottom": "bottom_x", + "top": "top_x", + "left": "left_y", + "right": "right_y", + }[side] + if self.axes.overlay_id and self.dimension == "y": + key = "y_axis" + _document.set_tick_mode( + state, + self.axes.subplot_id, + self.axes.overlay_id, + key, + None if automatic is None else bool(automatic), + paired=False, + ) + fig.apply_settings(state) + + def set_minor_locator(self, locator): + from .ticker import AutoMinorLocator + + if not isinstance(locator, AutoMinorLocator): + # Matplotlib's public object has no subdivision getter; accepting a + # guessed private attribute would conceal a different contract. + raise NotImplementedError( + "use figgyplot.ticker.AutoMinorLocator for renderer subdivisions" + ) + fig = self.axes.figure + state = fig.get_settings() + for node in self._nodes(state): + node["minor_count"] = locator.n - 1 + fig.apply_settings(state) + + def set_inverted(self, inverted): + state = self.axes.figure.get_settings() + for node in self._nodes(state): + node["inverted"] = bool(inverted) + self.axes.figure.apply_settings(state) + + def get_inverted(self): + return self._nodes(self.axes.figure.get_settings())[0]["inverted"] + + def set_ticklabels_enabled(self, enabled): + """Enable the renderer's numeric-label style independently of side visibility.""" + state = self.axes.figure.get_settings() + for node in self._nodes(state): + node["label_style"]["visible"] = bool(enabled) + self.axes.figure.apply_settings(state) + + def set_scale(self, value): + """Set this axis scale, preserving independent boundaries and formatting.""" + if value not in ("linear", "log"): + raise ValueError("scale must be linear or log") + fig = self.axes.figure + state = fig.get_settings() + nodes = self._nodes(state) + if value == "log" and any(node["min"] <= 0 for node in nodes): + raise ValueError( + "set positive view limits before choosing a logarithmic boundary" + ) + for node in nodes: + node["scale"] = "Logarithmic" if value == "log" else "Linear" + fig.apply_settings(state) + + def get_scale(self): + return ( + "log" + if self._nodes(self.axes.figure.get_settings())[0]["scale"] == "Logarithmic" + else "linear" + ) + + def set_major_formatter(self, formatter): + from .ticker import SignificantFormatter, TimestampFormatter + + if not isinstance(formatter, SignificantFormatter): + raise NotImplementedError( + "arbitrary tick labels are not supported; use figgyplot.ticker.SignificantFormatter" + ) + fig = self.axes.figure + state = fig.get_settings() + for node in self._nodes(state): + node["label_style"].update( + format=formatter._format() + if isinstance(formatter, TimestampFormatter) + else { + "plain": "Decimal", + "scientific": "Scientific", + "power": "Power", + "degrees": "Degrees", + "radians": "Radians", + }[formatter.style], + significant_digits=formatter.precision, + ) + fig.apply_settings(state) + + +class AxisLine(Spine): + """The selected boundary's line and tick stroke.""" + + def __init__(self, axis): + self.axis, self.axes = axis, axis.axes + + def _node(self, state): + return self.axis._nodes(state)[0] + + +class LegendFrame: + def __init__(self, legend): + self.legend = legend + + def set(self, **kwargs): + from .figure import _color + + fig = self.legend.axes.figure + state = fig.get_settings() + node = self.legend._node(state) + for key, value in kwargs.items(): + if key == "facecolor": + node["bg_color"] = _color(value) + elif key == "edgecolor": + node["border_color"] = _color(value) + elif key == "alpha": + alpha = _number(value, "alpha") + if not 0 <= alpha <= 1: + raise ValueError("alpha must be in 0..1") + node["bg_color"]["a"] = node["border_color"]["a"] = alpha + else: + raise NotImplementedError( + f"legend frame property {key!r} is not supported" + ) + fig.apply_settings(state) + + def set_facecolor(self, value): + self.set(facecolor=value) + + def set_edgecolor(self, value): + self.set(edgecolor=value) + + def set_alpha(self, value): + self.set(alpha=value) + + def get_facecolor(self): + color = self.legend._node(self.legend.axes.figure.get_settings())["bg_color"] + return tuple(color[k] for k in "rgba") + + def get_edgecolor(self): + color = self.legend._node(self.legend.axes.figure.get_settings())[ + "border_color" + ] + return tuple(color[k] for k in "rgba") + + +class Legend: + def __init__(self, axes): + self.axes = axes + + def _node(self, state): + from ._charts import family + + if family(state): + raise NotImplementedError( + "bounded legend typography is part of chart label styling" + ) + if self.axes.overlay_id: + owner = self.axes._overlay(state) + if owner.get("legend") is None: + raise ValueError("create the secondary legend with legend() first") + return owner["legend"] + return self.axes._config(state)["legend"] + + def get_texts(self): + state = self.axes.figure.get_settings() + return [ + ArtistLabel(self.axes.figure.artist(spec["series_id"])) + for spec in self.axes._owned_series(state) + if spec.get("label") is not None + ] + + @property + def content(self): + """The complete rich legend text, including deliberate custom keys.""" + return LegendText(self) + + def get_frame(self): + return LegendFrame(self) + + def get_visible(self): + from ._charts import family + + state = self.axes.figure.get_settings() + kind = family(state) + return state[kind]["legend"] if kind else self._node(state)["visible"] + + def set_visible(self, value): + from ._charts import family + + fig = self.axes.figure + state = fig.get_settings() + kind = family(state) + if kind: + state[kind]["legend"] = bool(value) + else: + self._node(state)["visible"] = bool(value) + fig.apply_settings(state) + + def set_loc(self, value): + from .figure import _legend_corner + + fig = self.axes.figure + state = fig.get_settings() + self._node(state)["corner"] = _legend_corner(value) + fig.apply_settings(state) + + def set_fontsize(self, value): + fig = self.axes.figure + state = fig.get_settings() + self._node(state)["content"]["font_size"] = ( + _number(value, "fontsize", positive=True) * fig.dpi / 72 + ) + fig.apply_settings(state) + + def set_offset(self, offset): + """Figgy extension: offset the legend by points in screen directions.""" + fig = self.axes.figure + state = fig.get_settings() + node = self._node(state) + node["offset_x"], node["offset_y"] = ( + _number(v, "legend offset") * fig.dpi / 72 for v in offset + ) + fig.apply_settings(state) + + +class LegendText(Text): + def __init__(self, legend): + self.legend = legend + super().__init__(legend.axes, "legend") + + def _node(self, state): + node = self.legend._node(state) + return dict(text=node["content"], visible=node["visible"]) + + def set(self, **properties): + visible = properties.pop("visible", None) + with self.axes.figure.batch_update(): + if visible is not None: + self.legend.set_visible(visible) + if properties: + super().set(**properties) + return self + + +class ArtistMethods: + def set_fillstyle(self, value): + """Set marker fill to full/none without changing the marker's symbol.""" + from ._markers import decode_marker, encode_marker + + state = self.figure.get_settings() + node = self._body(state).get("scatter") + if node is None: + raise AttributeError("this artist has no markers") + marker, _ = decode_marker(node["point_shape"]) + node["point_shape"] = encode_marker(marker, value) + self.figure.apply_settings(state) + return self + + @property + def contours(self): + from ._field_api import ContourAppearance + + return ContourAppearance(self) + + def set_grid(self, source): + from ._field_api import set_grid + + return set_grid(self, source) + + def _spec(self, state): + try: + return next(s for s in state["series"] if s["series_id"] == self.id) + except StopIteration: + raise ValueError(f"artist {self.id!r} no longer exists") from None + + def _body(self, state): + return next(iter(self._spec(state)["render_type"].values())) + + def _panel_axes(self, state): + from ._document import series_panel_id + + return self.figure._axes_for(series_panel_id(state, self.id)) + + @property + def data(self): + from ._artist_appearance import ArtistData + + return ArtistData(self) + + @property + def vectors(self): + from ._artist_appearance import VectorAppearance + + return VectorAppearance(self) + + @property + def markers(self): + from ._artist_appearance import ElementAppearance + + return ElementAppearance(self, "markers") + + @property + def errors(self): + from ._artist_appearance import ElementAppearance + + return ElementAppearance(self, "errors") + + @property + def bins(self): + from ._artist_appearance import ElementAppearance + + return ElementAppearance(self, "bins") + + @property + def legend_label(self): + return ArtistLabel(self) + + def set_fill(self, target=None, *, color=...): + """Fill to a data baseline or another resident line's stable ID.""" + from .figure import _color + + state = self.figure.get_settings() + line = self._body(state).get("line") + if line is None: + raise AttributeError("this artist has no line fill") + if target is None: + line.pop("fill", None) + elif isinstance(target, str): + line["fill"] = {"ToSeries": target} + elif hasattr(target, "id"): + if target.figure is not self.figure: + raise ValueError("fill target belongs to another Figure") + line["fill"] = {"ToSeries": target.id} + else: + line["fill"] = {"ToBaseline": _number(target, "fill baseline")} + if color is not ...: + if color is None: + line.pop("fill_color", None) + else: + line["fill_color"] = _color(color) + self.figure.apply_settings(state) + return self + + def set_field_fill(self, *, mode="continuous", shading="flat", alpha=1.0): + """Set heatmap/filled-contour painting without changing matrix bindings.""" + if mode not in ("continuous", "bands") or shading not in ( + "flat", + "interpolated", + ): + raise ValueError( + "field fill requires continuous/bands and flat/interpolated" + ) + alpha = _number(alpha, "alpha") + if not 0 <= alpha <= 1: + raise ValueError("alpha must be in [0,1]") + state = self.figure.get_settings() + body = self._body(state) + if "matrix" not in body: + raise AttributeError("this artist is not a scalar field") + if "fill" not in body: + raise AttributeError("this field has no filled surface") + body["fill"] = dict(mode=mode.title(), shading=shading.title(), opacity=alpha) + self.figure.apply_settings(state) + return self + + def get_gid(self): + return self.id + + def get_label(self): + from .figure import _text + + label = self._spec(self.figure.get_settings()).get("label") + return "" if label is None else _text(label) + + def get_color(self): + body = self._body(self.figure.get_settings()) + for part, key in [ + ("line", "line_color"), + ("scatter", "point_color"), + ("bar", "fill_color"), + ("style", "color"), + ]: + if key in body.get(part, {}): + return tuple(body[part][key][k] for k in "rgba") + raise NotImplementedError( + "this artist uses a color scale rather than one color" + ) + + def get_linewidth(self): + body = self._body(self.figure.get_settings()) + for part, key in [ + ("line", "line_width"), + ("bar", "border_width"), + ("style", "width_px"), + ]: + if key in body.get(part, {}): + return body[part][key] * 72 / self.figure.dpi + raise AttributeError("artist has no line") + + def get_markersize(self): + scatter = self._body(self.figure.get_settings()).get("scatter") + if scatter is None: + raise AttributeError("artist has no stored marker size; add a marker first") + return scatter["point_size"] * 144 / self.figure.dpi + + def set_markersize(self, value): + return self.figure.update(self.id, markersize=value) + + def get_linestyle(self): + from .figure import _LINES + + value = self._body(self.figure.get_settings())["line"]["line_style"] + return {v: k for k, v in _LINES.items()}.get(value, value) + + def set_linestyle(self, value): + return self.figure.update(self.id, linestyle=value) + + def get_marker(self): + from .figure import _MARKERS + + scatter = self._body(self.figure.get_settings()).get("scatter") + if scatter is None: + return "None" + value = scatter["point_shape"] + return {v: k for k, v in _MARKERS.items()}.get(value, value) + + def set_marker(self, value): + from .figure import _MARKERS + + fig = self.figure + state = fig.get_settings() + body = self._body(state) + if value in (None, "", "None", "none", " "): + if set(self._spec(state)["render_type"]) == {"Line"}: + return + raise NotImplementedError( + "removing markers while retaining their hidden style is not supported" + ) + if "scatter" not in body: + spec = self._spec(state) + if set(spec["render_type"]) != {"Line"}: + raise NotImplementedError("this artist has no editable point markers") + # A renderer variant is a storage detail, not a reason to make users + # recreate or upload a plotted line merely to add markers. + body["scatter"] = dict( + point_shape="CircleFilled", + point_color=copy.deepcopy(body["line"]["line_color"]), + point_size=6 * fig.dpi / 144, + ) + spec["render_type"] = {"ScatterLine": body} + body["scatter"]["point_shape"] = _MARKERS.get(value, value) + fig.apply_settings(state) + + def set_facecolor(self, value): + from .figure import _color + + fig = self.figure + state = fig.get_settings() + body = self._body(state) + if "fill_color" in body.get("line", {}): + body["line"]["fill_color"] = _color(value) + elif "fill_color" in body.get("bar", {}): + body["bar"]["fill_color"] = _color(value) + elif "scatter" in body: + body["scatter"]["point_color"] = _color(value) + else: + raise NotImplementedError("this artist has no independent face color") + fig.apply_settings(state) + + def set_edgecolor(self, value): + from .figure import _color + + fig = self.figure + state = fig.get_settings() + body = self._body(state) + if "bar" not in body: + raise NotImplementedError( + "this renderer has no independent marker edge color" + ) + body["bar"]["border_color"] = _color(value) + fig.apply_settings(state) + + def set_baseline(self, value): + """Figgy extension: baseline of an existing histogram.""" + fig = self.figure + state = fig.get_settings() + self._body(state)["bar"]["baseline"] = _number(value, "baseline") + fig.apply_settings(state) + + def set_bar_width(self, ratio=1.0, *, gap=0.0): + """Figgy extension: fraction of each bin and gap in points.""" + ratio = _number(ratio, "bar width") + gap = _number(gap, "bar gap") + if not 0 < ratio <= 1 or gap < 0: + raise ValueError("bar width must be in (0,1] and gap nonnegative") + fig = self.figure + state = fig.get_settings() + self._body(state)["bar"].update(width_ratio=ratio, gap_px=gap * fig.dpi / 72) + fig.apply_settings(state) + + def set_orientation(self, value): + """Figgy extension: use an existing histogram's horizontal/vertical mapping.""" + if value not in ("horizontal", "vertical"): + raise ValueError("orientation must be horizontal or vertical") + fig = self.figure + state = fig.get_settings() + self._body(state)["bar"]["orientation"] = value.title() + fig.apply_settings(state) + + def set_errorbar_style(self, *, color, width, capsize, capthick): + """Figgy extension: restyle existing errors with errorbar argument units.""" + from .figure import _color + + fig = self.figure + state = fig.get_settings() + body = self._body(state) + if "err_style" not in body: + raise AttributeError("artist has no error bars") + values = [_number(v, "errorbar size") for v in [width, capsize, capthick]] + if min(values) < 0: + raise ValueError("errorbar sizes must be nonnegative") + node = body["err_style"] + node["error_bar_color"] = _color(color) + for key, value in zip( + ("error_bar_width", "error_bar_cap_size", "cap_width"), values + ): + node[key] = value * fig.dpi / 72 + fig.apply_settings(state) + + def set_drawstyle(self, value): + modes = { + "default": "Linear", + "steps-pre": "StepPre", + "steps-post": "StepPost", + "steps-mid": "StepMid", + } + if value not in modes: + raise ValueError("unsupported drawstyle") + fig = self.figure + state = fig.get_settings() + self._body(state)["line"]["interpolation"] = modes[value] + fig.apply_settings(state) + + def set_cmap(self, value): + self.figure.update(self.id, cmap=value) + + def set_clim(self, vmin=None, vmax=None): + if vmax is None and vmin is not None and not np.isscalar(vmin): + vmin, vmax = vmin + props = {k: v for k, v in [("vmin", vmin), ("vmax", vmax)] if v is not None} + self.figure.update(self.id, **props) + + def get_clim(self): + axis = self._colorbar()["axis"] + return axis["min"], axis["max"] + + def get_cmap(self): + return self._colorbar()["colormap"] + + def _colorbar(self): + state = self.figure.get_settings() + return self._colorbar_owner(state)._config(state)["colorbar"] + + def _colorbar_owner(self, state): + body = self._body(state) + if not ( + "matrix" in body + or body.get("scatter", {}).get("point_color_map") + or body.get("style", {}).get("color_map") + ): + raise AttributeError("artist is not scalar-mapped") + return self._panel_axes(state) + + def set_alpha(self, value): + alpha = _number(value, "alpha") + if not 0 <= alpha <= 1: + raise ValueError("alpha must be in 0..1") + fig = self.figure + state = fig.get_settings() + body = self._body(state) + if body.get("scatter", {}).get("point_color_map") or body.get("style", {}).get( + "color_map" + ): + raise NotImplementedError( + "independent scalar-mapped opacity requires renderer support" + ) + + def opacity(node): + if isinstance(node, dict) and set(node) == set("rgba"): + node["a"] = alpha + return True + values = ( + node.values() + if isinstance(node, dict) + else node + if isinstance(node, list) + else () + ) + changed = False + for value in values: + # Visit every per-point/bin override, not just the base color. + changed = opacity(value) or changed + return changed + + found = False + for part in ("line", "scatter", "bar", "style", "err_style"): + found = opacity(body.get(part, {})) or found + if not found: + raise NotImplementedError("scalar-mapped opacity requires renderer support") + fig.apply_settings(state) + + +class AxesMethods: + def activate_annotations(self): + """Route pointer editing to the document's annotation layer.""" + from ._charts import family + + state = self.figure.get_settings() + owner = state[family(state)] if family(state) else state["config"] + owner["active_layer"] = "Annotations" + self.figure.apply_settings(state) + return self + + @property + def legend_handle(self): + """Edit the legend even while hidden (an absent overlay legend still raises).""" + return Legend(self) + + def get_overlay(self, id): + """Look up an existing secondary Y layer by its stable ID.""" + from ._document import overlay_config + + overlay_config(self.figure.get_settings(), self.subplot_id, id) + return self.figure._axes_for(self.subplot_id, id) + + def clear_legend(self): + """Remove an optional secondary legend; hide a primary legend.""" + if not self.overlay_id: + return self.legend_handle.set_visible(False) + state = self.figure.get_settings() + self._overlay(state).pop("legend", None) + self.figure.apply_settings(state) + + def set_polar_options( + self, + *, + unit="degrees", + zero=0, + clockwise=False, + sector=None, + inner_radius=0, + interpolation="chord", + pieces=8, + label_angle=90, + ): + """Figgy polar projection; angles zero/label_angle use degrees. + + Sector endpoints use unit. Arc interpolation uses 1..64 chords per + sample segment. Original numerical coordinates are retained. + """ + if unit not in ("degrees", "radians") or interpolation not in ("chord", "arc"): + raise ValueError("unit is degrees/radians; interpolation is chord/arc") + if not isinstance(clockwise, bool): + raise TypeError("clockwise must be bool") + if ( + isinstance(pieces, bool) + or not isinstance(pieces, int) + or not 1 <= pieces <= 64 + ): + raise ValueError("arc pieces must be an integer in 1..64") + bounds = None if sector is None else [_number(v, "sector") for v in sector] + if bounds is not None and len(bounds) != 2: + raise ValueError("sector requires two endpoints") + return self.set_polar( + angle_unit=unit.title(), + zero_angle_degrees=_number(zero, "zero"), + clockwise=clockwise, + sector=bounds, + inner_radius_fraction=_number(inner_radius, "inner radius"), + interpolation="Chord" + if interpolation == "chord" + else {"Arc": {"pieces": pieces}}, + radial_label_angle_degrees=_number(label_angle, "label angle"), + ) + + def clear_polar(self): + state = self.figure.get_settings() + self._config(state)["polar"] = None + self.figure.apply_settings(state) + return self + + def get_colorbar(self): + if not self._config(self.figure.get_settings()).get("colorbar"): + return None + return Colorbar(None, self) + + def add_colorbar(self, *, cmap="viridis", vmin=0, vmax=1, label=None): + """Add an explicit standalone scale; existing data colors are retained.""" + from ._color_scale import _set_colorbar + + with self.figure.batch_update(): + state = self.figure.get_settings() + _set_colorbar(self._config(state), cmap, vmin, vmax) + self.figure.apply_settings(state) + bar = Colorbar(None, self) + if label is not None: + bar.set_label(label) + return bar + + def clear_colorbar(self): + state = self.figure.get_settings() + self._config(state).pop("colorbar", None) + self.figure.apply_settings(state) + + @property + def chart(self): + """Figgy's named appearance controls for bounded chart families.""" + from ._chart_artist import ChartAppearance + + return ChartAppearance(self) + + def get_panel_position(self): + """Figgy extension: outer panel bounds, in Figure fractions.""" + state = self.figure.get_settings() + w, h = canvas_size(state) + area = self._config(state)["chart_area"] + return Bbox( + area["x"] / w, + 1 - (area["y"] + area["height"]) / h, + area["width"] / w, + area["height"] / h, + ) + + def set_panel_position(self, bounds): + """Figgy extension: position the panel including its labels and title.""" + left, bottom, width, height = map( + float, bounds.bounds if hasattr(bounds, "bounds") else bounds + ) + if ( + not all(math.isfinite(v) for v in [left, bottom, width, height]) + or width <= 0 + or height <= 0 + ): + raise ValueError("invalid panel position") + state = self.figure.get_settings() + w, h = canvas_size(state) + self._config(state)["chart_area"] = dict( + x=round(left * w), + y=round((1 - bottom - height) * h), + width=round(width * w), + height=round(height * h), + ) + self.figure.apply_settings(state) + + @property + def transAxes(self): + return Transform(self, "axes fraction") + + @property + def transData(self): + return Transform(self, "data") + + def secondary_xaxis(self, location, functions=None, **kwargs): + if location not in ("top", "bottom") or functions is not None or kwargs: + raise NotImplementedError( + "only an existing top/bottom axis with the same transform is supported" + ) + return SecondaryAxes(self, "x", location) + + def secondary_yaxis(self, location, functions=None, **kwargs): + if location not in ("left", "right") or functions is not None or kwargs: + raise NotImplementedError( + "only an existing left/right axis with the same transform is supported" + ) + return SecondaryAxes(self, "y", location) + + def set_xticks(self, ticks, labels=None, *, minor=False, **kwargs): + return self._set_ticks("x", ticks, labels, minor, kwargs) + + def set_yticks(self, ticks, labels=None, *, minor=False, **kwargs): + return self._set_ticks("y", ticks, labels, minor, kwargs) + + def _set_ticks(self, dim, ticks, labels, minor, kwargs): + from .ticker import MultipleLocator + + if labels is not None or minor or kwargs: + raise NotImplementedError( + "explicit labels/minor ticks need renderer support; use tick_params for styling" + ) + values = np.asarray(ticks, dtype=float) + steps = np.diff(values) + if ( + len(steps) < 1 + or not np.isfinite(values).all() + or steps[0] <= 0 + or not np.allclose(steps, steps[0]) + or not np.allclose(values / steps[0], np.round(values / steps[0])) + ): + raise NotImplementedError( + "the renderer supports regular zero-aligned ticks only" + ) + # Explicit positions must cover the complete view; otherwise a spacing + # would introduce ticks the caller did not request. + low, high = sorted(getattr(self, "get_" + dim + "lim")()) + expected = ( + np.arange(math.ceil(low / steps[0]), math.floor(high / steps[0]) + 1) + * steps[0] + ) + if values.shape != expected.shape or not np.allclose(values, expected): + raise NotImplementedError( + "tick positions must cover the view; arbitrary tick clipping needs renderer support" + ) + getattr(self, dim + "axis").set_major_locator(MultipleLocator(steps[0])) + + def _plot_rectangle(self, state=None): + state = self.figure.get_settings() if state is None else state + from ._native import editor_catalog_json + + catalog = json.loads( + editor_catalog_json(json.dumps(state, allow_nan=False), None) + ) + owner = next( + item for item in catalog if item["id"] == "panel/" + self.subplot_id + ) + bounds = next( + field["value"] + for field in owner["fields"] + if field["id"] == "Plot area/bounds" + ) + if len(bounds) != 4: + raise NotImplementedError( + "automatic bounded plot geometry is not exposed by the renderer" + ) + area = self._config(state)["chart_area"] + return [bounds[0] + area["x"], bounds[1] + area["y"], bounds[2], bounds[3]] + + def get_position(self, original=False): + state = self.figure.get_settings() + width, height = canvas_size(state) + x, y, w, h = self._plot_rectangle(state) + return Bbox(x / width, 1 - (y + h) / height, w / width, h / height) + + def set_position(self, pos, which="both"): + if which != "both": + raise NotImplementedError( + "the renderer has one active/original plot rectangle" + ) + values = pos.bounds if hasattr(pos, "bounds") else pos + left, bottom, width, height = map(float, values) + if ( + not all(math.isfinite(v) for v in [left, bottom, width, height]) + or width <= 0 + or height <= 0 + ): + raise ValueError("invalid axes position") + state = self.figure.get_settings() + w, h = canvas_size(state) + area = self._config(state)["chart_area"] + bounds = [ + left * w - area["x"], + (1 - bottom - height) * h - area["y"], + width * w, + height * h, + ] + self.figure.edit_property( + "panel/" + self.subplot_id, "Plot area/bounds", bounds + ) + + @property + def xaxis(self): + return Axis(self, "x") + + @property + def yaxis(self): + return Axis(self, "y") + + @property + def title(self): + return Text(self, "title") + + @property + def spines(self): + return {side: Spine(self, side) for side in ["left", "right", "bottom", "top"]} + + @property + def lines(self): + return [ + self.figure._artist_for(s["series_id"]) + for s in self._owned_series(self.figure.get_settings()) + if next(iter(s["render_type"])) in ("Line", "ScatterLine") + ] + + @property + def collections(self): + return [ + self.figure._artist_for(s["series_id"]) + for s in self._owned_series(self.figure.get_settings()) + if next(iter(s["render_type"])) in ("Scatter", "Vector") + ] + + def get_lines(self): + return self.lines + + def get_xaxis(self): + return self.xaxis + + def get_yaxis(self): + return self.yaxis + + def get_legend(self): + from ._charts import family + + state = self.figure.get_settings() + kind = family(state) + if kind: + return Legend(self) if state[kind].get("legend") else None + node = ( + self._overlay(state).get("legend") + if self.overlay_id + else self._config(state)["legend"] + ) + return ( + Legend(self) + if node and (node["visible"] or node["content"]["segments"]) + else None + ) + + def get_xlabel(self): + return self.xaxis.label.get_text() + + def get_ylabel(self): + return self.yaxis.label.get_text() + + def get_title(self): + return self.title.get_text() + + def get_xlim(self): + from ._charts import family + + state = self.figure.get_settings() + kind = family(state) + if kind: + if ( + kind == "boxplot" + and state[kind]["direction"] == "Horizontal" + and state[kind]["value_range"] is not None + ): + return tuple(state[kind]["value_range"]) + raise NotImplementedError( + "the renderer does not expose automatic bounded-axis limits" + ) + cfg = self._config(self.figure.get_settings())["bottom_x"] + values = (cfg["min"], cfg["max"]) + return values[::-1] if cfg["inverted"] else values + + def get_ylim(self): + from ._charts import family + + state = self.figure.get_settings() + kind = family(state) + if kind: + if ( + kind in ("boxplot", "radar") + and state[kind].get("direction", "Vertical") == "Vertical" + and state[kind]["value_range"] is not None + ): + return tuple(state[kind]["value_range"]) + raise NotImplementedError( + "the renderer does not expose automatic bounded-axis limits" + ) + cfg = ( + self._overlay(state)["y_axis"] + if self.overlay_id + else self._config(state)["left_y"] + ) + values = (cfg["min"], cfg["max"]) + return values[::-1] if cfg["inverted"] else values + + def xaxis_inverted(self): + return self.get_xlim()[0] > self.get_xlim()[1] + + def yaxis_inverted(self): + return self.get_ylim()[0] > self.get_ylim()[1] + + def set(self, **properties): + supported = {"title", "xlabel", "ylabel", "xlim", "ylim", "xscale", "yscale"} + if set(properties) - supported: + raise AttributeError( + "unsupported Axes properties: " + + ", ".join(sorted(set(properties) - supported)) + ) + result = [] + with self.figure.batch_update(): + for key, value in properties.items(): + method = getattr(self, "set_" + key) + result.append( + method(*value) if key in ("xlim", "ylim") else method(value) + ) + return result + + def _limits(self, dimension, first, second, minimum, maximum): + from ._charts import family + + if minimum is not None: + if first is not None: + raise TypeError("both lower limit and its alias were supplied") + first = minimum + if maximum is not None: + if second is not None: + raise TypeError("both upper limit and its alias were supplied") + second = maximum + if second is None and first is not None and not np.isscalar(first): + first, second = first + if first is None or second is None: + old = getattr(self, "get_" + dimension + "lim")() + first = old[0] if first is None else first + second = old[1] if second is None else second + first = _number(first, "limit") + second = _number(second, "limit") + if first == second: + raise ValueError("limits must be different") + kind = family(self.figure.get_settings()) + if kind and first > second: + raise NotImplementedError( + "this bounded renderer does not support reversed value axes" + ) + with self.figure.batch_update(): + self._set_bounds(dimension, min(first, second), max(first, second)) + if not kind: + state = self.figure.get_settings() + cfg = self._config(state) + keys = ( + ("bottom_x", "top_x") if dimension == "x" else ("left_y", "right_y") + ) + for key in keys: + node = ( + self._overlay(state)["y_axis"] + if self.overlay_id and dimension == "y" + else cfg[key] + ) + node["inverted"] = first > second + self.figure.apply_settings(state) + return first, second + + def tick_params(self, axis="both", which="major", reset=False, **kwargs): + return self._tick_params(axis=axis, which=which, reset=reset, **kwargs) + + def _tick_params( + self, axis="both", which="major", reset=False, sides=None, **kwargs + ): + """Style ticks in points using the shared axis option translator.""" + from ._charts import family + from ._ticks import apply_tick_params + + fig = self.figure + state = fig.get_settings() + if family(state) is not None: + raise ValueError("bounded charts have no Cartesian axes; use their chart properties or data selection") + cfg = self._config(state) + sides = sides or [ + side for side in ("bottom", "top", "left", "right") + if axis == "both" or (axis == "x") == (side in ("bottom", "top")) + ] + targets = [] + for side in sides: + if self.overlay_id and axis == "y" and side == "left": + continue + key = {"bottom": "bottom_x", "top": "top_x", "left": "left_y", "right": "right_y"}[side] + node = self._overlay(state)["y_axis"] if self.overlay_id and side == "right" else cfg[key] + targets.append((side, node, fig._default_config[key])) + apply_tick_params(fig, targets, grid=cfg["grid"], axis=axis, which=which, reset=reset, **kwargs) + fig.apply_settings(state) + + +class SecondaryAxes: + """Handle to an existing boundary; transformed secondary scales are guarded.""" + + def __init__(self, axes, dimension, side): + self.axes, self.dimension, self.side = axes, dimension, side + + @property + def xaxis(self): + if self.dimension != "x": + raise AttributeError("this secondary axis is vertical") + return Axis(self.axes, "x", self.side) + + @property + def yaxis(self): + if self.dimension != "y": + raise AttributeError("this secondary axis is horizontal") + return Axis(self.axes, "y", self.side) + + def tick_params(self, **kwargs): + return self.axes._tick_params(axis=self.dimension, sides=[self.side], **kwargs) + + def set_xlabel(self, text, **kwargs): + return self.xaxis.set_label_text(text, **kwargs) + + def set_ylabel(self, text, **kwargs): + return self.yaxis.set_label_text(text, **kwargs) + + +class ColorbarText(Text): + def _node(self, state): + return self.axes._config(state)["colorbar"]["axis"]["title_option"] + + def _finish_edit(self, state): + from ._color_scale import fit_colorbar_title + + fit_colorbar_title(self.axes._config(state)) + + +class Colorbar: + """The shared color scale of scalar-mapped artists on one panel.""" + + def __init__(self, mappable, axes): + self.mappable, self.axes = mappable, axes + + @property + def ax(self): + return ColorbarAxes(self.axes) + + def set_label(self, label, **kwargs): + return self.ax.yaxis.set_label_text(label, **kwargs) + + def _edit(self, operation): + from ._color_scale import fit_colorbar_title + + fig = self.axes.figure + state = fig.get_settings() + config = self.axes._config(state) + operation(config["colorbar"]) + fit_colorbar_title(config) + fig.apply_settings(state) + return self + + def set_clim(self, vmin=None, vmax=None): + if vmax is None and vmin is not None and not np.isscalar(vmin): + vmin, vmax = vmin + from ._color_scale import update_color_scale + + fig = self.axes.figure + state = fig.get_settings() + update_color_scale(self.axes._config(state), vmin=vmin, vmax=vmax) + fig.apply_settings(state) + + def set_cmap(self, cmap): + from ._color_scale import update_color_scale + + fig = self.axes.figure + state = fig.get_settings() + update_color_scale(self.axes._config(state), cmap=cmap) + fig.apply_settings(state) + + def get_layout(self): + bar = self.axes._config(self.axes.figure.get_settings())["colorbar"] + unit = 72 / self.axes.figure.dpi + return dict( + side=bar["side"].lower(), + align=bar["align"].lower(), + fraction=bar["length_frac"], + thickness=bar["thickness_px"] * unit, + gap=bar["gap_px"] * unit, + offset=(bar["offset_x"] * unit, bar["offset_y"] * unit), + ) + + def set_layout( + self, + *, + side=None, + align=None, + fraction=None, + thickness=None, + gap=None, + offset=None, + ): + def edit(bar): + if side is not None: + if side not in ("left", "right", "top", "bottom"): + raise ValueError("colorbar side must be left, right, top or bottom") + bar["side"] = side.title() + if align is not None: + if align not in ("start", "center", "end"): + raise ValueError("colorbar align must be start, center or end") + bar["align"] = align.title() + if fraction is not None: + bar["length_frac"] = _number(fraction, "colorbar fraction") + unit = self.axes.figure.dpi / 72 + if thickness is not None: + if thickness < 0: + raise ValueError("colorbar thickness must be nonnegative") + bar["thickness_px"] = _number(thickness, "thickness") * unit + if gap is not None: + bar["gap_px"] = _number(gap, "gap") * unit + if offset is not None: + x, y = offset + bar["offset_x"] = _number(x, "offset x") * unit + bar["offset_y"] = _number(y, "offset y") * unit + + return self._edit(edit) + + def set_nan_color(self, color): + from .figure import _color + + return self._edit(lambda bar: bar.update(nan_color=_color(color))) + + def set_outline(self, *, color=None, linewidth=None): + from .figure import _color + + def edit(bar): + if color is not None: + bar["border_color"] = _color(color) + if linewidth is not None: + width = _number(linewidth, "outline width") + if width < 0: + raise ValueError("outline width must be nonnegative") + bar["border_width"] = width * self.axes.figure.dpi / 72 + + return self._edit(edit) + + def set_visible(self, visible): + return self._edit(lambda bar: bar.update(visible=bool(visible))) + + def get_visible(self): + return self.axes._config(self.axes.figure.get_settings())["colorbar"]["visible"] + + def remove(self): + return self.set_visible(False) + + +class ColorbarAxis(Axis): + def set_label_text(self, label, **kwargs): + kwargs.setdefault('visible', True) + return super().set_label_text(label, **kwargs) + + def _nodes(self, state): + return [self.axes._config(state)["colorbar"]["axis"]] + + def set_major_locator(self, locator): + from ._color_scale import fit_colorbar_title + + fig = self.axes.figure + state = fig.get_settings() + node = self._nodes(state)[0] + node["major_spacing"] = _locator_spacing(locator, node, self) + fit_colorbar_title(self.axes._config(state)) + fig.apply_settings(state) + + def set_view_interval(self, *args, **kwargs): + raise NotImplementedError( + "independent colorbar viewport is unavailable; use mappable.set_clim to change the data norm" + ) + + def set_tick_auto(self, *args, **kwargs): + raise NotImplementedError( + "independent colorbar tick intent is unavailable; choose a locator explicitly" + ) + + @property + def label(self): + return ColorbarText(self.axes, "colorbar") + + def set_tick_params(self, which="major", reset=False, **kwargs): + from ._ticks import apply_tick_params + from ._color_scale import fit_colorbar_title + + fig = self.axes.figure + state = fig.get_settings() + cfg = self.axes._config(state) + vertical = cfg["colorbar"]["side"] in ("Left", "Right") + key, side = ("right_y", "right") if vertical else ("bottom_x", "bottom") + apply_tick_params( + fig, [(side, cfg["colorbar"]["axis"], fig._default_config[key])], + axis="y" if vertical else "x", which=which, reset=reset, **kwargs, + ) + fit_colorbar_title(cfg) + fig.apply_settings(state) + + +class ColorbarAxes: + def __init__(self, axes): + self.axes = axes + + @property + def yaxis(self): + return ColorbarAxis(self.axes, "y") + + @property + def xaxis(self): + return ColorbarAxis(self.axes, "x") + + def tick_params(self, axis="both", **kwargs): + return self.yaxis.set_tick_params(**kwargs) + + +class FigureMethods: + @property + def data(self): + from ._artist_appearance import DataRegistry + + return DataRegistry(self) + + def annotation(self, id): + """Look up an existing annotation by its stable ID.""" + from ._features import Annotation + + handle = Annotation(self, id) + handle._find(self.get_settings()) + return handle + + def set_annotations_visible(self, visible): + from ._charts import family + + state = self.get_settings() + owner = state[family(state)] if family(state) else state["config"] + owner.setdefault("annotations", dict(visible=True, items=[]))["visible"] = bool( + visible + ) + self.apply_settings(state) + + def clear_annotations(self): + """Remove the document annotation layer; resident chart data is untouched.""" + from ._charts import family + + state = self.get_settings() + owner = state[family(state)] if family(state) else state["config"] + owner.pop("annotations", None) + if "decorations" in owner: + owner["decorations"]["selected"] = None + self.apply_settings(state) + + def reset_style(self): + """Reset Cartesian decorations without discarding data or panel ownership. + + This Figgy extension is intended for replaying an exported style recipe. + Data mappings, ranges, layout rectangles and projection remain resident. + """ + self._require_cartesian() + state = self.get_settings() + state.pop("background", None) + for spec in state["series"]: + body = next(iter(spec["render_type"].values())) + for part in ("scatter", "err_style", "bar"): + if part not in body: + continue + # Remove optional per-element appearance, retaining column + # bindings and numerical data-to-color/size mappings. + for key in list(body[part]): + if key.endswith(("style_overrides", "style_table")) or key.endswith( + "style_index_column" + ): + body[part].pop(key) + if "line" in body: + for key in ("fill", "fill_color", "interpolation"): + body["line"].pop(key, None) + for cfg in [state["config"]] + [ + p["config"] for p in state["config"].get("subplots", []) + ]: + cfg.pop("draw_style", None) + cfg.pop("annotations", None) + for key in ( + "chart_title", + "grid", + "legend", + "bottom_x", + "top_x", + "left_y", + "right_y", + ): + old = cfg[key] + cfg[key] = copy.deepcopy(self._default_config[key]) + if key.endswith(("_x", "_y")): + for field in ("min", "max", "scale", "inverted", "major_spacing"): + cfg[key][field] = old[field] + for overlay in cfg.get("overlays", []): + old = overlay["y_axis"] + overlay["y_axis"] = copy.deepcopy(self._default_config["right_y"]) + for field in ("min", "max", "scale", "inverted", "major_spacing"): + overlay["y_axis"][field] = old[field] + if overlay.get("legend") is not None: + overlay["legend"] = copy.deepcopy(self._default_config["legend"]) + if cfg.get("colorbar") is not None: + from ._native import default_colorbar_json + + old = cfg["colorbar"] + cfg["colorbar"] = json.loads(default_colorbar_json()) + cfg["colorbar"]["colormap"] = old["colormap"] + for key in ("min", "max", "major_spacing", "scale"): + cfg["colorbar"]["axis"][key] = old["axis"][key] + self.apply_settings(state) + + def artist(self, gid): + """Look up an existing artist by its stable public ID.""" + if not any(s["series_id"] == gid for s in self.get_settings()["series"]): + raise ValueError(f"artist {gid!r} no longer exists") + return self._artist_for(gid) + + def colorbar(self, mappable, ax=None, *, label=None, **kwargs): + if kwargs: + raise NotImplementedError( + "colorbar layout options are not yet part of the Matplotlib subset" + ) + if mappable.figure is not self: + raise ValueError("mappable belongs to another Figure") + with self.batch_update(): + state = self.get_settings() + owner = mappable._colorbar_owner(state) + if ax is not None and ( + ax.figure is not self or ax.subplot_id != owner.subplot_id + ): + raise ValueError("colorbar must belong to the mappable panel") + owner._config(state)["colorbar"]["visible"] = True + self.apply_settings(state) + bar = Colorbar(mappable, owner) + if label is not None: + bar.set_label(label) + return bar + + @contextmanager + def batch_update(self): + """Commit existing-artist style/layout edits once, or roll them all back. + + Create plot data outside this block. Nested blocks are supported; failed + inner blocks restore their entry state. Resident columns are untouched. + """ + with self._lock: + previous = self._style_batch + revision = self.revision + self._style_batch = ( + self.get_settings() if previous is None else copy.deepcopy(previous) + ) + try: + yield self + except BaseException: + self._style_batch = previous + raise + else: + candidate = self._style_batch + self._style_batch = previous + if previous is None: + self.apply_settings(candidate, expected_revision=revision) + else: + self._style_batch = candidate + + def get_size_inches(self): + return np.array(canvas_size(self.get_settings())) / self.dpi + + def get_figwidth(self): + return self.get_size_inches()[0] + + def get_figheight(self): + return self.get_size_inches()[1] + + def get_dpi(self): + return self.dpi + + def set_size_inches(self, w, h=None, forward=True): + if h is None: + w, h = w + w = _number(w, "width", positive=True) + h = _number(h, "height", positive=True) + from ._document import place_grid + + state = self.get_settings() + old_w, old_h = canvas_size(state) + new_w, new_h = round(w * self.dpi), round(h * self.dpi) + if new_w < 1 or new_h < 1: + raise ValueError("figure size must be at least one logical pixel") + layout = state["identity"].get("layout") + if layout: + layout["size"] = [new_w, new_h] + place_grid(state) + else: + for cfg in [state["config"]] + [ + p["config"] for p in state["config"].get("subplots", []) + ]: + area = cfg["chart_area"] + for key, scale in [ + ("x", new_w / old_w), + ("width", new_w / old_w), + ("y", new_h / old_h), + ("height", new_h / old_h), + ]: + area[key] = round(area[key] * scale) + self.apply_settings(state) + + +def _StateFigure(state, defaults, dpi): + """CPU-only view used by translators, never a second plotting backend. + + Calls the actual public setters against copied state. It cannot read/upload + columns, draw, or acquire the native Figure lock (Copy Python holds it). + """ + from .figure import Figure + import threading + + class StateFigure(Figure): + def __init__(self): + self._state = copy.deepcopy(state) + self._default_config = copy.deepcopy(defaults) + self.dpi = dpi + self.backend = "webgpu" + self._style_batch = None + self._lock = threading.RLock() + self._style_defaults = None + self._axes_handles = {} + self._artist_handles = {} + self._native = object() + self.axes = [ + self._axes_for(p["id"], overlay) + for p in state["identity"]["panels"] + for overlay in [None, *p["overlays"]] + ] + from ._document import Subplots + + self.subplots = Subplots(self) + + @property + def revision(self): + return 0 + + def get_settings(self): + return json.loads( + json.dumps( + self._style_batch if self._style_batch is not None else self._state, + allow_nan=False, + ) + ) + + def apply_settings(self, settings, **kwargs): + resolved = json.loads(json.dumps(settings, allow_nan=False)) + if self._style_batch is not None: + self._style_batch = resolved + else: + self._state = resolved + return self + + def _changed(self): + pass + + def close(self): + self._native = None + + return StateFigure() diff --git a/python/figgyplot/_review_diff.py b/python/figgyplot/_review_diff.py index 653f6c8..8ae568a 100644 --- a/python/figgyplot/_review_diff.py +++ b/python/figgyplot/_review_diff.py @@ -29,7 +29,8 @@ def _selector(path, item, index, document): return None -def resolve(document, path, selectors): +def locate(document, path, selectors): + """Resolve a snapshot path by stable selectors and retain its actual address.""" parts = path.split('/')[1:] value = document actual = [] @@ -49,7 +50,11 @@ def resolve(document, path, selectors): key = int(key) value = value[key] actual.append(escape(key)) - return value + return '/'+'/'.join(actual) if actual else '', value + + +def resolve(document, path, selectors): + return locate(document, path, selectors)[1] def changes(before, after): diff --git a/python/figgyplot/_review_font.py b/python/figgyplot/_review_font.py new file mode 100644 index 0000000..2f2e2ec --- /dev/null +++ b/python/figgyplot/_review_font.py @@ -0,0 +1,30 @@ +"""Resolve the actual installed UI face and carry it to notebook browsers.""" +from functools import lru_cache +import struct + + +@lru_cache(maxsize=8) +def font_data(family): + from ._native import review_font_data + data,index=review_font_data(family) + data=bytes(data) + if data[:4]!=b'ttcf':return data + # A browser FontFace consumes a standalone SFNT. Preserve the selected TTC + # face, including CJK regional glyph forms, rather than silently using face 0. + offset=struct.unpack_from('>I',data,12+4*index)[0] + header=data[offset:offset+12] + count=struct.unpack_from('>H',header,4)[0] + directory=bytearray();payload=bytearray();cursor=12+16*count;head=None + for i in range(count): + tag,checksum,start,size=struct.unpack_from('>4sIII',data,offset+12+16*i) + table=bytearray(data[start:start+size]) + if tag==b'head':table[8:12]=b'\0'*4;head=cursor+8 + padded=table+b'\0'*((-len(table))%4) + checksum=sum(struct.unpack('>'+str(len(padded)//4)+'I',padded))&0xffffffff + directory+=struct.pack('>4sIII',tag,checksum,cursor,size) + payload+=padded;cursor+=len(padded) + result=bytearray(header)+directory+payload + if head is not None: + checksum=sum(struct.unpack('>'+str(len(result)//4)+'I',result))&0xffffffff + struct.pack_into('>I',result,head,(0xB1B0AFBA-checksum)&0xffffffff) + return bytes(result) diff --git a/python/figgyplot/_review_store.py b/python/figgyplot/_review_store.py index 5f15421..8ed1dcd 100644 --- a/python/figgyplot/_review_store.py +++ b/python/figgyplot/_review_store.py @@ -1,6 +1,7 @@ """Crash-safe review manifests and immutable numeric payloads.""" from contextlib import contextmanager import hashlib +import errno import io import json import os @@ -10,9 +11,32 @@ import tempfile import threading import time +import base64 +import struct import numpy as np +_WINDOWS = os.name == 'nt' + + +def _windows_retry(operation, *, timeout=30): + """Retry sharing/byte-lock contention, retaining the original error on timeout. + + Permission failures on Unix and unrelated Windows I/O errors are not retried. + A sharing denial can be reported as EACCES without winerror by the CRT. + """ + deadline = time.monotonic()+timeout + while True: + try: + return operation() + except OSError as exc: + transient = _WINDOWS and (getattr(exc,'winerror',None) in (5,32,33) + or exc.errno in (errno.EACCES,errno.EAGAIN,errno.EDEADLK)) + remaining = deadline-time.monotonic() + if not transient or remaining<=0: + raise + time.sleep(min(.02,remaining)) + def encoded(value): return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False).encode('utf-8') @@ -38,7 +62,7 @@ def atomic_write(path, data): stream.write(data) stream.flush() os.fsync(stream.fileno()) - os.replace(temporary, path) + _windows_retry(lambda: os.replace(temporary, path)) _sync_directory(path.parent) finally: if os.path.exists(temporary): @@ -46,6 +70,12 @@ def atomic_write(path, data): class Store: + @staticmethod + def image_references(doc): + yield from doc.get('image_assets', []) + for comment in doc.get('comments', []): + yield from comment.get('attachments', []) + @staticmethod def references(doc): for key in ('columns','baseline_columns','bounded_data','baseline_bounded_data'): @@ -55,30 +85,38 @@ def __init__(self, path): self.path = Path(path).resolve() self.path.mkdir(parents=True, exist_ok=True) (self.path/'data').mkdir(exist_ok=True) + (self.path/'attachments').mkdir(exist_ok=True) self.mutex = threading.RLock() + self._local = threading.local() self._seen_generation = None + self._verified_images = {} @contextmanager def locked(self): - with self.mutex, (self.path/'.lock').open('a+b') as stream: - if os.name == 'nt': - import msvcrt - stream.seek(0) - if not stream.read(1): - stream.write(b'0'); stream.flush() - stream.seek(0) - msvcrt.locking(stream.fileno(), msvcrt.LK_LOCK, 1) - else: - import fcntl - fcntl.flock(stream, fcntl.LOCK_EX) - try: + with self.mutex: + if getattr(self._local,'held',False): yield - finally: - if os.name == 'nt': + return + # Windows permits locking beyond EOF. Do not read or initialize + # byte zero before locking: a peer may already own that byte. + with (self.path/'.lock').open('a+b') as stream: + if _WINDOWS: + import msvcrt stream.seek(0) - msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + _windows_retry(lambda: msvcrt.locking(stream.fileno(),msvcrt.LK_NBLCK,1)) else: - fcntl.flock(stream, fcntl.LOCK_UN) + import fcntl + fcntl.flock(stream, fcntl.LOCK_EX) + self._local.held=True + try: + yield + finally: + self._local.held=False + if _WINDOWS: + stream.seek(0) + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(stream, fcntl.LOCK_UN) def read(self): state = json.loads((self.path/'manifest.json').read_text(encoding='utf-8')) @@ -95,6 +133,7 @@ def read(self): def write(self, state): state['generation'] += 1 _sync_directory(self.path/'data') + _sync_directory(self.path/'attachments') atomic_write(self.path/'manifest.json', encoded(state)) self._seen_generation=state['generation'] @@ -139,6 +178,10 @@ def collect(self, state): for path in (self.path/'data').iterdir(): if path.name.startswith('.writing-') or ('data/'+path.name not in live and re.fullmatch('[0-9a-f]{64}\\.npy', path.name)): path.unlink() + images = {ref['file'] for r in retained for doc in r['documents'].values() for ref in self.image_references(doc)} + for path in (self.path/'attachments').iterdir(): + if ('attachments/'+path.name not in images and re.fullmatch('[0-9a-f]{64}\\.png',path.name)) or path.name.startswith('.writing-'): + _windows_retry(path.unlink) for path in self.path.glob('.writing-*'): path.unlink() active={r.get('claim',{}).get('token') for r in retained @@ -148,3 +191,38 @@ def collect(self, state): if match and match[1] not in active and path.is_dir(): try:shutil.rmtree(path) except OSError:pass # Retry on access if an expired writer still has files open. + + def put_image(self, data, name='Review image.png'): + from ._native import review_image_json + if not isinstance(data, bytes):data=bytes(data) + image=json.loads(review_image_json(data,str(name))) + payload=base64.b64decode(image.pop('data'),validate=True) + sha=hashlib.sha256(payload).hexdigest() + image.update(id=sha,sha256=sha,file='attachments/'+sha+'.png') + path=self.path/image['file'] + if not path.exists():atomic_write(path,payload) + self.get_image(image) + return image + + def get_image(self, reference): + sha=reference.get('sha256') + if (not isinstance(sha,str) or not re.fullmatch('[0-9a-f]{64}',sha) + or reference.get('file')!='attachments/'+sha+'.png' or reference.get('id')!=sha + or reference.get('mime_type')!='image/png' or not isinstance(reference.get('name'),str)): + raise ValueError('Invalid review image reference') + data=(self.path/reference['file']).read_bytes() + if (len(data)!=reference.get('bytes') or hashlib.sha256(data).hexdigest()!=sha + or data[:8]!=b'\x89PNG\r\n\x1a\n' or len(data)<24 + or tuple(struct.unpack('>II',data[16:24]))!=(reference.get('width'),reference.get('height'))): + raise ValueError('Review image integrity check failed: '+sha) + return data + + def verify_image(self, reference): + sha=reference.get('sha256') + if not isinstance(sha,str) or not re.fullmatch('[0-9a-f]{64}',sha) or reference.get('file')!='attachments/'+sha+'.png': + raise ValueError('Invalid review image reference') + stat=(self.path/reference['file']).stat() + stamp=(stat.st_size,stat.st_mtime_ns,stat.st_ctime_ns,reference.get('id'),reference.get('name'),reference.get('mime_type'),reference.get('bytes'),reference.get('width'),reference.get('height')) + if self._verified_images.get(sha)!=stamp: + self.get_image(reference) + self._verified_images[sha]=stamp diff --git a/python/figgyplot/_review_summary.py b/python/figgyplot/_review_summary.py new file mode 100644 index 0000000..6284b17 --- /dev/null +++ b/python/figgyplot/_review_summary.py @@ -0,0 +1,152 @@ +"""Derived review views; exact snapshot diffs remain the validation authority.""" +import copy +import json + +from ._review_diff import locate + + +def _bindings(state): + from ._native import review_object_bindings_json + return json.loads(review_object_bindings_json(json.dumps(state, allow_nan=False))) + + +def _lookup(state, path, selectors): + try: + return True, locate(state, path, selectors)[1] + except (KeyError, IndexError, StopIteration, ValueError, TypeError): + return False, None + + +def _preview(value, *, text=False): + if text and isinstance(value, list): + value = ''.join(v.get('text', '') for v in value) + if value is None or isinstance(value, (bool, int, float)): + return dict(value=value, truncated=False) + encoded = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, separators=(',', ':')) + return dict(value=encoded[:160], truncated=len(encoded)>160) + + +def _rich_signature(segments): + text, styles = '', [] + for segment in segments if isinstance(segments,list) else []: + value = segment.get('text','') + flags = tuple(sorted((k,v) for k,v in segment.items() if k!='text' and v)) + if flags: + styles.extend((index, flags) for index in range(len(text),len(text)+len(value))) + text += value + return text, styles + + +def _document(doc): + before, after = doc['baseline'], doc['approval']['settings'] + states = dict(before=before, after=after) + bindings = {side:_bindings(state) for side,state in states.items()} + objects = {v['id']:v for side in ('before','after') for v in bindings[side]} + groups = {} + + def group(target, label=None): + if target not in groups: + binding = objects.get(target, {}) + label_preview = _preview(label or binding.get('label', target)) + groups[target] = dict(target=target, label=label_preview['value'], label_truncated=label_preview['truncated'], + scope=binding.get('kind', 'unresolved'), change_count=0, + properties=[], comments=[], _changes=[], _properties={}) + return groups[target] + + for change in doc['approval']['changes']: + side = 'before' if change['op']=='remove' else 'after' + try: + actual, _ = locate(states[side], change['path'], change['selectors']) + except (KeyError, IndexError, StopIteration, ValueError, TypeError): + side = 'after' if side=='before' else 'before' + actual, _ = locate(states[side], change['path'], change['selectors']) + matches = [(len(path), binding, path) for binding in bindings[side] for path in binding['paths'] + if actual==path or actual.startswith(path+'/')] + _, binding, root = max(matches, key=lambda item:item[0]) + item = group(binding['id']) + item['_changes'].append(change) + item['change_count'] += 1 + # A rich text document is one readable property even when its exact + # diff contains separate character/style operations. IDs are retained. + parts = change['path'].split('/') + text = 'segments' in parts + raw_property = '/'.join(parts[:parts.index('segments')+1]) if text else change['path'] + actual_parts = actual.split('/') + property_path = '/'.join(actual_parts[:actual_parts.index('segments')+1]) if text else actual + relative = property_path[len(root):] or '/' + key = (relative, change['op']) if change['op']=='move' else (relative, '') + if key not in item['_properties']: + old_exists, old = _lookup(before, raw_property, change['selectors']) + new_exists, new = _lookup(after, raw_property, change['selectors']) + field = dict(path=relative, kind='rich_text' if text else change['op'], + before_exists=old_exists, after_exists=new_exists, + before=_preview(old, text=text), after=_preview(new, text=text), + operation_ids=[]) + if text: + old_text, old_styles = _rich_signature(old) + new_text, new_styles = _rich_signature(new) + field['text_changed'] = old_text != new_text + field['formatting_changed'] = old_styles != new_styles + item['_properties'][key] = field + item['properties'].append(field) + field = item['_properties'][key] + field['operation_ids'].append(change['id']) + figure_comments = [] + for comment in doc['comments']: + if comment.get('target'): + group(comment['target'], comment.get('label'))['comments'].append(comment) + else: + figure_comments.append(comment) + return states, bindings, groups, figure_comments + + +def _public(item, figure_id): + return dict({k:v for k,v in item.items() if not k.startswith('_')}, + details=dict(figure_id=figure_id, target=item['target'])) + + +def summary(feedback): + round_ = feedback['round'] + figures = [] + for fid,doc in round_['documents'].items(): + _, _, groups, comments = _document(doc) + approval = doc['approval'] + figures.append(dict(id=fid, disposition=approval['disposition'], intent=approval.get('intent','revise'), + change_count=len(approval['changes']), comment_count=len(doc['comments']), + data_changed=doc.get('baseline_columns',{})!=doc.get('columns',{}) or doc.get('baseline_bounded_data',{})!=doc.get('bounded_data',{}), + comments=comments, objects=[_public(v,fid) for v in groups.values()], + details=dict(figure_id=fid))) + return dict(protocol=1, view='summary', session_id=feedback['session_id'], + round_id=round_['id'], work_id=round_['work_id'], intent=round_.get('intent','revise'), + source=round_['source'], figures=figures, + counts=dict(figures=len(figures), objects=sum(len(f['objects']) for f in figures), + changes=sum(f['change_count'] for f in figures), comments=sum(f['comment_count'] for f in figures))) + + +def details(feedback, figure_id, *, target=None, change_id=None): + round_ = feedback['round'] + if figure_id not in round_['documents']: + raise ValueError('Unknown Figure ID: '+figure_id) + states, bindings, groups, comments = _document(round_['documents'][figure_id]) + selected = list(groups.values()) + if target is not None: + if target not in groups: + raise ValueError('No feedback for this object: '+target) + selected = [groups[target]] + comments = [] + if change_id is not None: + selected = [g for g in selected if any(c['id']==change_id for c in g['_changes'])] + if not selected: + raise ValueError('Unknown change ID for this Figure/target: '+change_id) + objects = [] + for item in selected: + snapshot = {} + for side,state in states.items(): + paths = next((b['paths'] for b in bindings[side] if b['id']==item['target']), []) + snapshot[side] = {p:_lookup(state,p,{})[1] for p in paths if _lookup(state,p,{})[0]} + objects.append(dict(_public(item,figure_id), snapshots=snapshot)) + selected_ids = {c['id'] for item in selected for c in item['_changes'] if change_id is None or c['id']==change_id} + changes = [c for c in round_['documents'][figure_id]['approval']['changes'] if c['id'] in selected_ids] + return dict(protocol=1, view='details', session_id=feedback['session_id'], round_id=round_['id'], + work_id=round_['work_id'], figure_id=figure_id, comments=comments, objects=objects, + selection=dict(target=target,change_id=change_id), changes=copy.deepcopy(changes)) diff --git a/python/figgyplot/_richtext.py b/python/figgyplot/_richtext.py new file mode 100644 index 0000000..bd22140 --- /dev/null +++ b/python/figgyplot/_richtext.py @@ -0,0 +1,100 @@ +"""Public styled text runs shared by titles, labels, legends and annotations.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TextRun: + """One styled span. Font size is in points; width/dashes use em units. + + script is normal, super or sub. None color/fontsize inherits the parent. + rule draws a line in place of glyphs; fill shades below a legend rule. + """ + + text: str + bold: bool = False + italic: bool = False + underline: bool = False + script: str = "normal" + greek: bool = False + color: object = None + fontsize: object = None + width: object = None + rule: bool = False + dashes: object = None + fill: object = None + + def _segments(self, dpi): + from .figure import _color + from ._validation import number as _number + + if not isinstance(self.text, str) or self.script not in ( + "normal", + "super", + "sub", + ): + raise ValueError("TextRun requires text and script normal/super/sub") + for flag in (self.bold, self.italic, self.underline, self.greek, self.rule): + if not isinstance(flag, bool): + raise TypeError("text style switches must be bool") + style = dict( + bold=self.bold, + italic=self.italic, + underline=self.underline, + superscript=self.script == "super", + subscript=self.script == "sub", + greek=self.greek, + ) + for key, value in (("color", self.color), ("rule_band", self.fill)): + if value is not None: + style[key] = _color(value) + if self.fontsize is not None: + style["font_size"] = ( + _number(self.fontsize, "fontsize", positive=True) * dpi / 72 + ) + if self.width is not None: + style["field_em"] = _number(self.width, "width", positive=True) + if self.rule: + style["rule"] = True + if self.dashes is not None: + style["rule_dash"] = [ + _number(v, "dash length", positive=True) for v in self.dashes + ] + return [dict(text=char, **style) for char in self.text] + + +def run_expression(segments, dpi, name="TextRun"): + """Render compact public spans without exposing per-glyph schema records.""" + from ._api_codegen import _color_literal, _points + + groups = [] + for segment in segments: + style = {k: v for k, v in segment.items() if k != "text"} + if groups and groups[-1][1] == style: + groups[-1][0] += segment["text"] + else: + groups.append([segment["text"], style]) + expressions = [] + for text, style in groups: + args = [repr(text)] + for key in ("bold", "italic", "underline", "greek", "rule"): + if style.get(key): + args.append(key + "=True") + if style.get("superscript") and style.get("subscript"): + raise ValueError( + "simultaneous subscript and superscript require exact recovery" + ) + if style.get("superscript") or style.get("subscript"): + args.append( + "script=" + repr("super" if style.get("superscript") else "sub") + ) + for public, native in (("color", "color"), ("fill", "rule_band")): + if style.get(native) is not None: + args.append(public + "=" + repr(_color_literal(style[native]))) + if style.get("font_size") is not None: + args.append("fontsize=" + repr(_points(style["font_size"], dpi))) + for public, native in (("width", "field_em"), ("dashes", "rule_dash")): + if style.get(native) is not None: + args.append(public + "=" + repr(style[native])) + expressions.append(name + "(" + ", ".join(args) + ")") + return "[" + ", ".join(expressions) + "]" diff --git a/python/figgyplot/_style_presets.py b/python/figgyplot/_style_presets.py new file mode 100644 index 0000000..99a5cc1 --- /dev/null +++ b/python/figgyplot/_style_presets.py @@ -0,0 +1,125 @@ +"""Task profiles: documented source guidance and explicit product choices.""" +import copy + +SOURCES={ + 'nature':['https://www.nature.com/nature/for-authors/final-submission', + 'https://research-figure-guide.nature.com/figures/preparing-figures-our-specifications/'], + 'ieee':['https://proceedingsoftheieee.ieee.org/resources/guidelines-for-figures-and-tables/'], + 'kfas':['https://www.e-kfas.org/intro/instructions.asp'], + 'screen':['https://learn.microsoft.com/en-us/power-bi/create-reports/service-dashboards-design-tips', + 'https://www.carbondesignsystem.com/building-blocks/data-visualization/color-palettes'], + 'operations':['https://grafana.com/docs/grafana/latest/visualizations/dashboards/build-dashboards/best-practices/'], + 'contrast':['https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html', + 'https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html'], +} +# Font sizes are points. Profile widths are recommendations, never resize edits. +PROFILES={ + 'publication.nature':dict(purpose='Small final-size scientific figures',font=6.5,title=7,print=True,tick='Inside',widths_mm=[89,183],source='nature'), + 'publication.ieee-proceedings':dict(purpose='Engineering figures for Proceedings of the IEEE',font=9,title=10,print=True,tick='Inside',widths_mm=[88.9,182],source='ieee',mono=True), + 'publication.kfas':dict(purpose='Four-sided scientific frame with inward ticks',font=9,title=10,print=True,tick='Inside',source='kfas'), + 'scientific.precision':dict(purpose='Dense quantitative signals with a full measurement frame',font=10,title=12,tick='Inside',grid='both',source='screen'), + 'print.monochrome':dict(purpose='Grayscale printing and photocopying',font=9,title=11,print=True,tick='Inside',mono=True,source='contrast'), + 'presentation.stage':dict(purpose='Projected charts with few large marks',font=17,title=22,dark=True,thick=2.6,source='screen'), + 'dashboard.executive':dict(purpose='Sparse performance overview with comparison context',font=12,title=16,thick=2,source='screen'), + 'dashboard.operations':dict(purpose='Operational trends with caller-supplied alert roles',font=11,title=14,dark=True,source='operations'), + 'dashboard.analysis':dict(purpose='Comparisons, uncertainty and focus versus context',font=10,title=13,grid='both',source='screen'), + 'dashboard.instrument':dict(purpose='Dense measurement traces, units and references',font=10,title=12,dark=True,tick='Inside',grid='both',source='screen'), + 'dashboard.wallboard':dict(purpose='Distant viewing with large labels and strokes',font=17,title=24,dark=True,thick=2.8,source='screen'), + 'dashboard.public':dict(purpose='Broad-audience charts with clear redundant encodings',font=13,title=17,thick=2,source='contrast'), + 'dashboard.mobile':dict(purpose='Small-screen single-chart reading',font=11,title=14,thick=1.8,source='screen'), + 'education.explainer':dict(purpose='Teaching with a clear frame, labels and emphasis',font=12,title=16,tick='Outside',grid='both',thick=1.8,source='screen'), +} +LIGHT=['#0067a5','#a74432','#6841b1','#007f65','#946200','#5d6470'] +DARK=['#64c6ef','#ffbf69','#baa5ff','#65d6b4','#ff7c85','#c0cad7'] +MARKERS=['CircleFilled','SquareFilled','TriangleFilled','DiamondFilled','Plus','Cross'] +DASHES=['Solid','Dash','Dot','DashDot'] + + +def describe(name): + if name not in PROFILES:raise ValueError('unknown task profile: '+name) + p=copy.deepcopy(PROFILES[name]);source=p.pop('source') + guidance={ + 'nature':['89/183 mm typical final widths; ordinary lettering 5–7 pt; editable vector/font handling required.'], + 'ieee':['Proceedings of the IEEE recommends approximately 9–10 pt lettering.'], + 'kfas':['Four-sided frames and inward ticks are specified; this profile\'s 9 pt size is a product choice.'], + }.get(source,['Task taxonomy, typography, spacing and default colors are product choices informed by the cited guidance.']) + p.update(name=name,checked='2026-10-26',sources=SOURCES[source]+([] if source=='contrast' else SOURCES['contrast']), + verified_guidance=guidance, + default_theme='dark' if p.get('dark') else 'light', + tick_provenance='journal requirement' if name=='publication.kfas' else 'product design choice; not a journal requirement', + category_capacity=12 if p.get('mono') else 24, + limits=['Appearance only; no thresholds, units, scale domains or text inferred.', + 'PNG export does not meet editable vector/font submission requirements.', + 'Default host font must be checked or explicitly replaced; no bundled journal font guarantee.', + 'Color contrast audit is not whole-chart accessibility or CVD certification.', + 'Mobile navigation, reflow and wallboard viewing distance belong to the host.']) + return p + + +def build(name,theme=None,density='comfortable'): + from .styles import StyleToken + p=PROFILES[name] + if density not in ('compact','comfortable'):raise ValueError('density must be compact or comfortable') + if theme not in (None,'light','dark'):raise ValueError('theme must be light or dark') + printed=p.get('print',False) + if printed and theme=='dark':raise ValueError('print/publication profiles require a light surface') + dark=p.get('dark',False) if theme is None else theme=='dark' + if printed and density!='comfortable':raise ValueError('print typography is fixed; customize an extracted token explicitly') + size=p['font']*(.9 if density=='compact' else 1) + title=p['title']*(.9 if density=='compact' else 1) + bg='#101924' if dark else '#ffffff';fg='#e6edf5' if dark else '#202733' + grid='#344355' if dark else '#dbe2e9' + muted='#aab8ca' if dark else '#5d6470' + palette=(['#e6edf5','#b5c4d4','#889eb5'] if dark else ['#171717','#505050','#767676']) if p.get('mono') else (DARK if dark else LIGHT) + if name in ('dashboard.operations','dashboard.instrument','dashboard.wallboard'): + # Warm alert colors are reserved for caller-declared statuses here. + palette=['#64c6ef','#baa5ff','#65d6b4','#c0cad7','#a4c4ff','#cfb8dd'] if dark else ['#0067a5','#6841b1','#007f65','#5d6470','#1b5c89','#6d456c'] + frame=printed or name in ('scientific.precision','dashboard.instrument','education.explainer') + tick=p.get('tick','None');stroke=p.get('thick',1.4) + if printed:stroke=.65 + axis=dict(tick=tick,line_visible=frame,line_color=fg,line_width=.5 if printed else .65, + major_tick_length=2.5 if tick!='None' else 0,minor_tick_length=1.25 if tick!='None' else 0, + minor_count=1 if tick!='None' else 0,label_style=dict(color=fg), + title_option=dict(text=dict(color=fg))) + panel=dict(chart_title=dict(visible=not printed,top_margin=0 if printed else title+9), + legend=dict(bg_color=bg,border_color=bg,padding=3 if printed else 5), + grid=dict(show_major_x=not printed and p.get('grid')=='both',show_major_y=not printed, + show_minor_x=False,show_minor_y=False,major_x_color=grid,major_y_color=grid, + major_x_width=.45,major_y_width=.45), + **{key:copy.deepcopy(axis) for key in ('bottom_x','top_x','left_y','right_y')}) + for key in ('top_x','right_y'): + panel[key]['label_style']['label_visible']=False + panel[key]['out_margin']=4 if printed else max(10,size*1.5) + panel['left_y']['out_margin']=size*4.3 if not printed else size*4.8 + panel['bottom_x']['out_margin']=size*3.4 if not printed else size*3.6 + panel['left_y']['title_option'].update(offset_x=0,offset_y=-size*.85) + panel['bottom_x']['title_option']['offset_y']=size*.35 + if name=='dashboard.mobile': + panel['legend']['padding']=3 + panel['top_x']['out_margin']=4;panel['right_y']['out_margin']=4 + def role(color,width=stroke,dash='Solid',shape='CircleFilled'): + return dict(line=dict(line_color=color,line_width=width,line_style=dash), + scatter=dict(point_color=color,point_shape=shape), + err_style=dict(error_bar_color=color),bar=dict(fill_color=color), + style=dict(color=color),contour=dict(line=dict(line_color=color,line_width=width))) + roles=dict(focus=role(palette[0],stroke*1.4),context=role(muted,stroke*.75), + reference=role(muted,stroke,'Dash'), + warning=role('#ffbf69' if dark else '#946200',stroke,'Dash','TriangleFilled'), + critical=role('#ff7c85' if dark else '#b42332',stroke*1.2,'Dot','DiamondFilled'), + positive=role('#65d6b4' if dark else '#007f65')) + bounded={k:dict(text_color=fg,background=bg,grid=not printed) for k in ('categorical','boxplot','radar')} + bounded['radial']=dict(label_color=fg,background=bg) + bounded['categorical']['style']=dict(outline_color=fg,outline_width=.5) + bounded['boxplot']['style']=dict(outline_color=fg,median_color=bg,mean_color=bg,whisker_color=fg,outlier_color=fg, + median_width=.8,whisker_width=.6) + bounded['radar']['style']=dict(spoke_color=grid,ring_color=grid,line_width_px=stroke,fill_opacity=.12) + return StyleToken(dict(background=bg,typography=dict(font='',font_size=size,color=fg, + title_scale=title/size,label_scale=1,legend_scale=.95),panel=panel,palette=palette, + line_cycle=DASHES,marker_cycle=MARKERS,roles=roles,bounded=bounded, + series_defaults=dict(line=dict(line_width=stroke),scatter=dict(point_size=2.5 if printed else 4), + err_style=dict(error_bar_width=.6 if printed else 1,error_bar_cap_size=2,cap_width=.6 if printed else 1), + bar=dict(border_width=.5,border_color=fg),contour=dict(line=dict(line_width=stroke)), + style=dict(width_px=stroke)), + annotations=dict(ReferenceLine=dict(style=dict(color=muted,width=stroke,style='Dash')), + Arrow=dict(style=dict(line=dict(color=fg,width=stroke,style='Solid'),head_length=7,head_width=5)), + Span=dict(fill=dict(r=0.3,g=0.5,b=0.7,a=.15))))) diff --git a/python/figgyplot/_style_runtime.py b/python/figgyplot/_style_runtime.py new file mode 100644 index 0000000..36db147 --- /dev/null +++ b/python/figgyplot/_style_runtime.py @@ -0,0 +1,301 @@ +"""Execution-local style defaults and construction-time inheritance. + +Only newly constructed objects are styled. Existing accepted edits never need +replaying, and resolved defaults enter the original constructor transaction. +""" +import copy +from contextlib import contextmanager +from contextvars import ContextVar +import functools +import inspect +import threading +import warnings + +from . import styles + +INHERIT=object() +_current=ContextVar('figgyplot_style_defaults',default=None) +_call=ContextVar('figgyplot_style_construction',default=None) + + +class _Session: + def __init__(self): + self.indices={};self.lock=threading.RLock() + + def index(self,key): + with self.lock: + if key not in self.indices:self.indices[key]=len(self.indices) + return self.indices[key] + + +def _token(value): + if isinstance(value,str):value=styles.preset(value) + if not isinstance(value,styles.StyleToken):raise TypeError('style must be a preset name or StyleToken') + value.validate() + # Default declarations never replay wording or coordinate domains. + return styles.StyleToken(appearance=value.appearance) + + +def capture(value=INHERIT): + current=_current.get() + if value is None:return None,None + base=copy.deepcopy(current[0]) if current else None + session=current[1] if current else _Session() + if value is INHERIT:return base,session if base is not None else None + local=_token(value) + if base is None:return local,session + styles._merge(base.appearance,local.appearance);base.validate() + return base,session + + +def use_style(value): + """Set defaults for subsequent Figures in this execution context only.""" + if value is None:_current.set(None) + else:_current.set((_token(value),_Session())) + + +@contextmanager +def style_context(value): + """Temporarily replace execution defaults; nesting and exceptions restore them.""" + token=None if value is None else _token(value) + handle=_current.set(None if token is None else (token,_Session())) + try:yield copy.deepcopy(token) + finally:_current.reset(handle) + + +def styled(method): + """Record actually supplied arguments, including positional/default-equal values.""" + signature=inspect.signature(method) + accepts_key=method.__name__ not in ('annotate','text','arrow','axhline','axvline','axhspan','axvspan') + @functools.wraps(method) + def wrapped(self,*args,**kwargs): + if not accepts_key and 'style_key' in kwargs:raise TypeError('style_key applies to data series, not annotations') + key=kwargs.pop('style_key',None) + fig=self.figure + if key is not None and (not isinstance(key,str) or not key):raise ValueError('style_key must be a nonempty string') + active=_call.get() + if active is not None and active['figure'] is fig: + return method(self,*args,**kwargs) + with fig._lock: + if fig._style_defaults is None: + if key is not None:raise ValueError('style_key requires Figure or execution style defaults') + return method(self,*args,**kwargs) + bound=signature.bind(self,*args,**kwargs) + explicit=dict(bound.arguments);explicit.pop('self',None) + for parameter in signature.parameters.values(): + if parameter.kind is inspect.Parameter.VAR_KEYWORD: + explicit.update(explicit.pop(parameter.name,{})) + call=dict(figure=fig,name=method.__name__,explicit=explicit,key=key,index=None,used=False) + handle=_call.set(call) + try: + result=method(self,*args,**kwargs) + if call['used'] and call['key'] not in fig._style_defaults.appearance.get('roles',{}): + fig._style_next_index+=1 + return result + finally:_call.reset(handle) + # Expose the additional style correspondence keyword to help()/inspect(). + params=list(signature.parameters.values()) + extra=inspect.Parameter('style_key',kind=inspect.Parameter.KEYWORD_ONLY,default=None) + where=next((i for i,p in enumerate(params) if p.kind is inspect.Parameter.VAR_KEYWORD),len(params)) + if accepts_key:params.insert(where,extra) + wrapped.__signature__=signature.replace(parameters=params) + return wrapped + + +def _construction(fig): + call=_call.get() + if call is None or call['figure'] is not fig: + return dict(name='',explicit={},key=None,index=fig._style_next_index) + if call['index'] is None: + a=fig._style_defaults.appearance;palette=a.get('palette',[]) + call['index']=(0 if call['key'] in a.get('roles',{}) else fig._style_session.index(call['key'])) if call['key'] is not None else fig._style_next_index + capacity=len(palette)*max(1,len(a.get('line_cycle',[]))) + if capacity and call['index']>=capacity and call['key'] not in a.get('roles',{}): + warnings.warn('style category encodings exhausted; supply explicit styles or split/group categories',styles.StyleWarning,stacklevel=4) + return call + + +def _mark_used(fig): + call=_call.get() + if call is not None and call['figure'] is fig:call['used']=True + + +def containers(fig,state,panel_ids=(),overlays=(),initial=False): + if fig._style_defaults is None:return state + for pid,oid in overlays: + from ._document import overlay_config + patch=styles._units(fig._style_defaults.appearance.get('panel',{}).get('right_y',{}),fig.dpi/72) + # Top/right labels suppressed in a base frame must become readable on + # an explicitly requested secondary measurement axis. + patch.get('label_style',{}).pop('label_visible',None) + styles._merge(overlay_config(state,pid,oid)['y_axis'],patch) + overlay_config(state,pid,oid)['y_axis']['label_style']['label_visible']=True + # Grid geometry is resolved once from explicit/accepted/inherited options + # in set_layout(). Styling new panels must not place only part of the grid + # using a second set of layout defaults. + token=fig._style_defaults + if 'layout' in token.appearance: + appearance=copy.deepcopy(token.appearance);appearance.pop('layout') + token=styles.StyleToken(appearance) + candidate=styles._resolve(fig,state,token) + if initial: + state['config']=candidate['config'] + if 'background' in candidate:state['background']=candidate['background'] + return state + for pid in panel_ids: + from ._document import panel_config + original=panel_config(state,pid);original.clear();original.update(copy.deepcopy(panel_config(candidate,pid))) + for pid,oid in overlays: + from ._document import overlay_config + original=overlay_config(state,pid,oid);original.clear();original.update(copy.deepcopy(overlay_config(candidate,pid,oid))) + return state + + +def layout_options(fig,state,grid,row_weights,column_weights,gap): + defaults=fig._style_defaults.appearance.get('layout',{}) if fig._style_defaults is not None else {} + previous=state['identity'].get('layout') or {} + result={} + for key,value,count in [('row_weights',row_weights,len(grid)), + ('column_weights',column_weights,len(grid[0]))]: + if value is None: + value=next((v for v in (previous.get(key),defaults.get(key)) if v is not None and len(v)==count),[1]*count) + result[key]=list(value) + if gap is None: + if 'gap' in previous:gap=previous['gap'] + elif 'gap' in defaults:gap=defaults['gap']*fig.dpi/72 + else:gap=12 + result['gap']=float(gap) + return result + + +def _restore_explicit(target,original,supplied): + """Protect supplied leaves without restoring unrelated constructor defaults.""" + for key,value in supplied.items(): + if key not in original:continue + if isinstance(value,dict) and isinstance(original[key],dict) and isinstance(target.get(key),dict): + _restore_explicit(target[key],original[key],value) + else:target[key]=copy.deepcopy(original[key]) + + +def _copy_path(target,original,path): + a,b=target,original + for key in path[:-1]: + if not isinstance(a,dict) or not isinstance(b,dict) or key not in a or key not in b:return + a,b=a[key],b[key] + if isinstance(a,dict) and isinstance(b,dict) and path[-1] in b:a[path[-1]]=copy.deepcopy(b[path[-1]]) + + +def _restore_series(body,original,call): + e=call['explicit'];name=call['name'] + fields={ + 'color':[('line','line_color'),('scatter','point_color'),('bar','fill_color'),('style','color'),('err_style','error_bar_color'),('line','fill_color'),('contour','line','line_color')], + 'linewidth':[('line','line_width'),('bar','border_width'),('style','width_px'),('contour','line','line_width')], + 'linestyle':[('line','line_style')], 'marker':[('scatter','point_shape')], + 'markersize':[('scatter','point_size')], 's':[('scatter','point_size')], + 'ecolor':[('err_style','error_bar_color')], + 'elinewidth':[('err_style','error_bar_width'),('err_style','cap_width')], + 'capsize':[('err_style','error_bar_cap_size')], + 'edgecolor':[('bar','border_color')], 'fill_color':[('line','fill_color')], + 'gap':[('bar','gap_px')], 'width':[('bar','width_ratio'),('style','width_px')], + 'pivot':[('style','pivot')], 'head_length':[('style','head_length_px')], + 'head_width':[('style','head_width_px')], + } + fmt=e.get('fmt',e.get('y') if isinstance(e.get('y'),str) else '') or '' + protected={k for k,v in e.items() if v is not None} + if any(c in fmt for c in 'bgrkw'):protected.add('color') + if any(c in fmt for c in 'os^vD+x*'):protected.add('marker') + if any(c in fmt for c in '-:'):protected.add('linestyle') + for key in protected: + for path in fields.get(key,[]):_copy_path(body,original,path) + if name=='fill_between': + # Both lines are fill geometry, not requested visible strokes. + _copy_path(body,original,('line','line_width')) + if 'alpha' in protected and 'fill_color' in body.get('line',{}): + body['line']['fill_color']['a']=original['line']['fill_color']['a'] + if name=='contourf' and 'linewidth' not in e: + _copy_path(body,original,('contour','line','line_width')) + + +def series(fig,document,spec,panel_id): + if fig._style_defaults is None:return spec,document + from ._document import panel_config + call=_construction(fig);original=copy.deepcopy(spec) + source=copy.deepcopy(document);source['series'].append(copy.deepcopy(spec)) + sid=spec['series_id'];a=styles._units(fig._style_defaults.appearance,fig.dpi/72) + def finalize(candidate): + styled_spec=next(s for s in candidate['series'] if s['series_id']==sid) + body=next(iter(styled_spec['render_type'].values()));before=next(iter(original['render_type'].values())) + styles._encode_series(body,a,call['index'],call['key']) + if a.get('palette') and 'fill_color' in body.get('line',{}): + color=copy.deepcopy(body['line']['line_color']) + color['a']*=before['line']['fill_color']['a'];body['line']['fill_color']=color + _restore_series(body,before,call) + candidate=styles._resolve(fig,source,fig._style_defaults,finalize) + result=next(s for s in candidate['series'] if s['series_id']==sid) + cfg=panel_config(document,panel_id);old=panel_config(fig.get_settings(),panel_id) + if cfg.get('colorbar'): + if not old.get('colorbar'): + bar=panel_config(candidate,panel_id)['colorbar'] + if call['explicit'].get('cmap') is not None:bar['colormap']=copy.deepcopy(cfg['colorbar']['colormap']) + cfg['colorbar']=copy.deepcopy(bar) + elif call['explicit'].get('cmap') is None: + cfg['colorbar']['colormap']=copy.deepcopy(old['colorbar']['colormap']) + return copy.deepcopy(result),document + + +def bounded(fig,state,kind): + if fig._style_defaults is None:return state + call=_construction(fig);original=copy.deepcopy(state[kind]);e=call['explicit'] + def finalize(candidate): + chart=candidate[kind] + if call['key'] is not None: + items=chart.get('series',chart.get('slices',[])) + if len(items)!=1:raise ValueError('style_key identifies one series; multi-series bounded charts require explicit colors') + a=fig._style_defaults.appearance + if a.get('palette'):items[0]['color']=copy.deepcopy(a['palette'][call['index']%len(a['palette'])]) + role=a.get('roles',{}).get(call['key'],{}) + for part,key in [('line','line_color'),('scatter','point_color'),('bar','fill_color')]: + if key in role.get(part,{}):items[0]['color']=copy.deepcopy(role[part][key]);break + # Public advanced chart properties and per-family style dictionaries + # are explicit overrides, including values equal to old defaults. + _restore_explicit(chart,original,{k:v for k,v in e.items() if k not in ('series','slices','categories')}) + if e.get('color') is not None or e.get('colors') is not None: + for item,before in zip(chart.get('series',chart.get('slices',[])),original.get('series',original.get('slices',[]))):item['color']=copy.deepcopy(before['color']) + if call['name'] in ('pie','donut'): + for key in ('material','tilt','depth'): + field={'material':'material','tilt':'tilt_degrees','depth':'depth'}[key] + if key in e:chart['style'][field]=copy.deepcopy(original['style'][field]) + _restore_explicit(chart['style'],original['style'],e) + candidate=styles._resolve(fig,state,fig._style_defaults,finalize) + state[kind]=candidate[kind] + palette=fig._style_defaults.appearance.get('palette',[]) + items=state[kind].get('series',state[kind].get('slices',[])) + if palette and len(items)>len(palette) and e.get('colors') is None and e.get('color') is None: + warnings.warn('bounded palette exhausted; supply explicit colors or group series/slices',styles.StyleWarning,stacklevel=4) + _mark_used(fig) + return state + + +def annotation(fig,state,item): + if fig._style_defaults is None:return item + from ._charts import family + kind=family(state);call=_construction(fig);original=copy.deepcopy(item) + def finalize(candidate): + owner=candidate[kind] if kind else candidate['config'] + body=next(v for v in owner['annotations']['items'] if v['id']==item['id'])['kind'] + tag=next(iter(body));body=body[tag];before=original['kind'][tag];e=call['explicit'] + if call['name']=='annotate': + for key in e: + if key in before:body[key]=copy.deepcopy(before[key]) + else: + if 'fontsize' in e and isinstance(body.get('text'),dict):body['text']['font_size']=before['text']['font_size'] + if e.get('color') is not None: + for path in [('text','color'),('style','color'),('style','line','color'),('fill',)]:_copy_path(body,before,path) + if 'linewidth' in e: + for path in [('style','width'),('style','line','width')]:_copy_path(body,before,path) + for key in ('head_length','head_width'): + if key in e:_copy_path(body,before,('style',key)) + if 'alpha' in e:_copy_path(body,before,('fill','a')) + candidate=styles._resolve(fig,state,fig._style_defaults,finalize) + owner=candidate[kind] if kind else candidate['config'] + return copy.deepcopy(next(v for v in owner['annotations']['items'] if v['id']==item['id'])) diff --git a/python/figgyplot/_ticks.py b/python/figgyplot/_ticks.py new file mode 100644 index 0000000..9d7624f --- /dev/null +++ b/python/figgyplot/_ticks.py @@ -0,0 +1,153 @@ +"""One tick-option translator for plot axes, overlays and colorbars. + +Callers supply detached SSOT nodes and commit them once. This module neither +constructs Figures nor clones documents; grid-less axes reject grid options. +""" + +import copy +import math + +from ._validation import number as _number + +_ALLOWED = { + "direction", + "length", + "width", + "color", + "pad", + "labelsize", + "labelcolor", + "colors", + "labelfontfamily", + "bottom", + "top", + "left", + "right", + "labelbottom", + "labeltop", + "labelleft", + "labelright", + "grid_color", + "grid_alpha", + "grid_linewidth", + "grid_linestyle", +} + + +def _prepare_options(fig, options): + """Validate/convert once per call, regardless of the number of axes.""" + from .figure import _color, _LINES + + result = dict(options) + unit = fig.dpi / 72 + for name, value in options.items(): + if name in ("color", "colors", "labelcolor", "grid_color"): + result[name] = _color(value) + elif name in ("length", "width", "grid_linewidth", "labelsize"): + number = _number(value, name, positive=name == "labelsize") + if number < 0: + raise ValueError(name + " must be nonnegative") + result[name] = number * unit + elif name == "grid_alpha": + result[name] = _number(value, name) + elif name == "grid_linestyle": + result[name] = _LINES.get(value, value) + elif name == "labelfontfamily": + result[name] = fig._check_font(value) + elif name == "direction": + if value not in ("in", "out", "inout"): + raise ValueError("direction must be in, out or inout") + result[name] = {"in": "Inside", "out": "Outside", "inout": "Both"}[value] + elif name == "pad": + raise NotImplementedError( + "independent tick padding is not supported by this renderer" + ) + return result + + +def apply_tick_params( + fig, targets, *, grid=None, axis="both", which="major", reset=False, **kwargs +): + if axis not in ("x", "y", "both") or which not in ("major", "minor", "both"): + raise ValueError("invalid axis or which") + if set(kwargs) - _ALLOWED: + raise ValueError( + "unsupported tick parameters: " + ", ".join(sorted(set(kwargs) - _ALLOWED)) + ) + if grid is None and any(name.startswith("grid_") for name in kwargs): + raise NotImplementedError("colorbar axes have no grid to style") + kwargs = _prepare_options(fig, kwargs) + for side, node, defaults in targets: + label = node["label_style"] + if reset: + base = defaults + for field in ("tick", "major_tick_length", "minor_tick_length"): + node[field] = copy.deepcopy(base[field]) + node["label_style"] = label = copy.deepcopy(base["label_style"]) + for name, value in kwargs.items(): + if name == "direction": + node["tick"] = value + elif name == "length": + if which in ("major", "both"): + node["major_tick_length"] = value + if which in ("minor", "both"): + node["minor_tick_length"] = value + elif name in ("color", "colors"): + color = value + if node["line_visible"] and color != node["line_color"]: + raise NotImplementedError( + "renderer shares tick and spine colors; set spine color explicitly first" + ) + node["line_color"] = dict(color) + elif name == "labelcolor": + label["color"] = dict(value) + elif name == "width": + width = value + if node["line_visible"] and not math.isclose( + width, node["line_width"], rel_tol=1e-6 + ): + raise NotImplementedError( + "renderer shares tick and spine widths; set spine linewidth explicitly first" + ) + node["line_width"] = width + elif name == "labelsize": + label["font_size"] = value + elif name == "labelfontfamily": + label["label_font"] = value + elif name == "label" + side: + label["label_visible"] = bool(value) + if value: + label["visible"] = True + if side in kwargs: + if not kwargs[side]: + node["tick"] = "None" + elif node["tick"] == "None": + node["tick"] = defaults["tick"] + if "colors" in kwargs: + label["color"] = dict(kwargs["colors"]) + if grid is None: + return + for dimension in ("x", "y"): + if axis not in (dimension, "both"): + continue + for kind in ("major", "minor"): + if which not in (kind, "both"): + continue + prefix = kind + "_" + dimension + for name, field in [ + ("grid_color", "color"), + ("grid_alpha", "alpha"), + ("grid_linewidth", "width"), + ("grid_linestyle", "style"), + ]: + if name not in kwargs: + continue + value = kwargs[name] + if field == "color": + grid[prefix + "_color"] = dict(value) + elif field == "alpha": + grid[prefix + "_color"]["a"] = value + elif field == "width": + grid[prefix + "_width"] = value + else: + grid[prefix + "_style"] = value diff --git a/python/figgyplot/_validation.py b/python/figgyplot/_validation.py new file mode 100644 index 0000000..16e05cc --- /dev/null +++ b/python/figgyplot/_validation.py @@ -0,0 +1,13 @@ +"""Numeric public-argument validation, independent of plotting objects.""" +import math + + +def number(value, name, *, positive=False): + value = float(value) + if not math.isfinite(value) or (positive and value <= 0): + raise ValueError( + f"{name} must be finite" + (" and positive" if positive else "") + ) + return value + + diff --git a/python/figgyplot/editor.css b/python/figgyplot/editor.css index 065096b..843a884 100644 --- a/python/figgyplot/editor.css +++ b/python/figgyplot/editor.css @@ -99,3 +99,21 @@ .figgy-review textarea {display:block;box-sizing:border-box;width:100%;margin:6px 0;resize:vertical;font:inherit;} .figgy-review button {margin-right:8px;} .figgy-review [data-review-status] {font-size:13px;} + +.figgy-webgpu .figgy-properties { resize: horizontal; overflow: auto; min-width: 280px; max-width: 640px; } +.figgy-webgpu .figgy-properties input { box-sizing: border-box; } + +.figgy-review textarea,.figgy-review-dialog {font-family:var(--figgy-review-font,system-ui,sans-serif);} +.figgy-review-dialog {max-width:min(440px,90vw);border:1px solid #bbcadd;border-radius:8px;padding:18px;color:#172b44;background:white;} +.figgy-review-dialog textarea {box-sizing:border-box;display:block;width:100%;margin:12px 0;font:inherit;} +.figgy-review-dialog button {margin-right:8px;} +.figgy-review-menu {position:fixed;inset:auto;margin:0;z-index:20;max-width:320px;padding:6px;border:1px solid #bbcadd;border-radius:6px;background:white;box-shadow:0 4px 18px #172b4422;} +.figgy-review-menu button {display:block;max-width:100%;text-align:left;} +.figgy-review-images {margin:8px 0;font-size:13px;} +.figgy-review-image-list {display:flex;gap:8px;overflow:auto;max-height:100px;margin:6px 0;} +.figgy-review-image-list>span {display:flex;align-items:flex-start;border:1px solid #bbcadd;border-radius:5px;padding:3px;} +.figgy-review-image-list img {width:80px;height:60px;object-fit:contain;} +.figgy-review-image-list button {padding:3px;margin:0;} +.figgy-review-image-preview {max-width:90vw;border:1px solid #bbcadd;border-radius:8px;} +.figgy-review-image-preview img {display:block;max-width:80vw;max-height:75vh;object-fit:contain;margin-bottom:10px;} +.figgy-review-images [role=alert] {display:block;color:#aa2d2d;} diff --git a/python/figgyplot/editor.js b/python/figgyplot/editor.js index 4fb77bc..c417760 100644 --- a/python/figgyplot/editor.js +++ b/python/figgyplot/editor.js @@ -20,6 +20,8 @@ export function render({ model, el }) { let request = 0, pending = null, queue = [], decode = 0, frame = null; let pointer = null, dragTimer = null, dragDelta = [0, 0], handle = null; let localTarget = null; + let notice = null; + const idleWaiters = []; const finite = value => Number.isFinite(Number(value)); // Kernel-provided text for a rich node (keeps "10⁻³" as typed); the joined // characters only when the kernel sent none for it. @@ -28,8 +30,17 @@ export function render({ model, el }) { const sideKey = {bottom: "bottom_x", top: "top_x", left: "left_y", right: "right_y"}; function dispatch(message) { + notice = null; queue.push(message); pump(); } + const commandsIdle = () => !pending && !queue.length && !dragTimer; + function idle() { + if (commandsIdle()) return Promise.resolve(); + return new Promise((resolve, reject) => idleWaiters.push({resolve, reject})); + } + function settleIdle() { + if (commandsIdle()) for (const waiter of idleWaiters.splice(0)) waiter.resolve(); + } function pump() { if (disposed || pending || !current || !queue.length) return; const message = queue.shift(); @@ -85,15 +96,17 @@ export function render({ model, el }) { } function refresh() { if (disposed) return; + const previousRevision = current?.revision; current = model.get("state"); if (!current?.settings) return; + if (previousRevision !== undefined && previousRevision !== current.revision) notice = null; for(const action of ['undo','redo'])root.querySelector(`[data-${action}]`).disabled=!!pending||!current.edit_history?.[`can_${action}`]||current.edit_history.gesture_active; if (pending === current.last_request) { pending = null; handle = current.handle; if (pointer) pointer.target = current.selection; pump(); } - status.textContent = pending ? "Rendering…" : `Revision ${current.revision}`; + status.textContent = pending ? "Rendering…" : notice ?? `Revision ${current.revision}`; error.textContent = current.error || ""; const choices = ["chart_title", "axis_title_bottom", "axis_title_left", "axis_bottom", "axis_left", "tick_labels_bottom", "tick_labels_left", "legend", "data_area", ...current.settings.series.map(s => s.series_id)]; localTarget = current.selection || localTarget || choices[0]; @@ -113,6 +126,7 @@ export function render({ model, el }) { bitmap?.close(); bitmap = next; draw(); }).catch(e => { error.textContent = e.message; }); } else draw(); + settleIdle(); } function schedule() { if (frame === null) frame = requestAnimationFrame(() => { frame = null; refresh(); }); } function xy(event) { @@ -126,6 +140,7 @@ export function render({ model, el }) { dispatch({action: "drag", target: current.selection, dx: dragDelta[0], dy: dragDelta[1], handle}); } dragDelta = [0, 0]; + settleIdle(); } canvas.addEventListener("pointerdown", event => { if (!current || pending || current.image_revision !== current.revision) return; @@ -151,12 +166,21 @@ export function render({ model, el }) { root.querySelector("[data-copy]").addEventListener("click", async () => { const code = root.querySelector("[data-code]"); try { + notice = null; + await idle(); + if (disposed) return; + const copiedRevision = current.revision; if (globalThis.figgyDesktopCopy) globalThis.figgyDesktopCopy(code.value); else await navigator.clipboard.writeText(code.value); - status.textContent = "Python code copied"; + if (commandsIdle() && current.revision === copiedRevision) { + notice = "Python code copied"; + status.textContent = notice; + } } catch { + if (disposed) return; root.querySelector("[data-code-panel]").open = true; code.focus(); code.select(); - status.textContent = "Press Ctrl/Cmd+C to copy Python code"; + notice = "Press Ctrl/Cmd+C to copy Python code"; + status.textContent = notice; } }); root.querySelector("[data-save]").addEventListener("click", () => { @@ -166,6 +190,7 @@ export function render({ model, el }) { model.on("change:state", schedule); model.on("change:image", schedule); refresh(); return () => { disposed = true; ++decode; if (frame !== null) cancelAnimationFrame(frame); + for (const waiter of idleWaiters.splice(0)) waiter.reject(new Error("Editor closed")); if (dragTimer) clearTimeout(dragTimer); bitmap?.close(); if (imageURL) URL.revokeObjectURL(imageURL); model.off("change:state", schedule); model.off("change:image", schedule); root.remove(); }; diff --git a/python/figgyplot/editor.py b/python/figgyplot/editor.py index 943e221..58b454c 100644 --- a/python/figgyplot/editor.py +++ b/python/figgyplot/editor.py @@ -19,6 +19,7 @@ def snapshot(self): fig = self.figure with fig._lock: settings = fig.get_settings() + from ._codegen import editor_code_payload revision = fig.revision if revision != self._image_revision: try: @@ -31,7 +32,8 @@ def snapshot(self): "bounds": fig.selection_bounds(), "handle": self.handle, "width": rect["width"], "height": rect["height"], "dpi": fig.dpi, - "code": fig.to_code(), "error": self.error, "last_request": self.last_request, + **editor_code_payload(settings,fig._default_config,fig.dpi), + "error": self.error, "last_request": self.last_request, "edit_history": fig.edit_history, "texts": _display_texts(settings)} diff --git a/python/figgyplot/figure.py b/python/figgyplot/figure.py index 22ac633..16f4cf9 100644 --- a/python/figgyplot/figure.py +++ b/python/figgyplot/figure.py @@ -14,17 +14,44 @@ import numpy as np from . import _document +from . import _style_runtime +from . import _chart_text +from ._colors import CSS4_COLORS +from ._matplotlib import ArtistMethods, AxesMethods, FigureMethods, Legend, Text from ._native import NativeFigure, __version__, fitted_chart_title_margin, font_family_available, segments_plain_text, sync_linked_axis_edit_json, text_segments_json _COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b"] -_NAMED = {"blue": "#0000ff", "red": "#ff0000", "green": "#008000", "black": "#000000", "white": "#ffffff", "orange": "#ffa500", "purple": "#800080", "gray": "#808080", "grey": "#808080", "b": "#0000ff", "r": "#ff0000", "g": "#008000", "k": "#000000", "w": "#ffffff"} -_MARKERS = {"o": "CircleFilled", "s": "SquareFilled", "^": "TriangleFilled", "v": "TriangleDownFilled", "D": "DiamondFilled", "+": "Plus", "x": "Cross", "*": "StarFilled"} +_NAMED = dict(CSS4_COLORS, b="#0000ff", r="#ff0000", g="#008000", k="#000000", w="#ffffff") +_MARKERS = {"o": "CircleFilled", "s": "SquareFilled", "^": "TriangleFilled", "v": "TriangleDownFilled", "D": "DiamondFilled", "+": "Plus", "x": "Cross", "*": "StarFilled", "<": "TriangleLeftFilled", ">": "TriangleRightFilled", "p": "PentagonFilled", "h": "HexagonFilled", "8": "OctagonFilled", "P": "PlusFilled", "X": "CrossFilled"} _LINES = {"-": "Solid", "--": "Dash", ":": "Dot", "-.": "DashDot"} +_LEGEND_LOCATIONS = {'upper left': 'TopLeft', 'upper right': 'TopRight', + 'lower left': 'BottomLeft', 'lower right': 'BottomRight'} + + +def _legend_corner(value): + if not isinstance(value, str): + raise ValueError('legend location must be upper/lower left/right or a renderer corner name') + corner = _LEGEND_LOCATIONS.get(value.lower(), value) + if corner not in _LEGEND_LOCATIONS.values(): + raise ValueError('legend location must be upper/lower left/right or TopLeft/TopRight/BottomLeft/BottomRight') + return corner def _color(value): if isinstance(value, str): + short={'g':(0.,.5,0.),'c':(0.,.75,.75),'m':(.75,0.,.75),'y':(.75,.75,0.)} + if value in short:return dict(zip('rgba',(*short[value],1.))) + tab10=['#1f77b4','#ff7f0e','#2ca02c','#d62728','#9467bd','#8c564b','#e377c2','#7f7f7f','#bcbd22','#17becf'] + if value.lower() in ('none','transparent'): + return dict(r=0.,g=0.,b=0.,a=0.) + if re.fullmatch(r'C\d+',value):value=tab10[int(value[1:])%len(tab10)] + tabs=dict(zip(['blue','orange','green','red','purple','brown','pink','gray','olive','cyan'],tab10)) + if value.startswith('tab:') and value[4:] in tabs:value=tabs[value[4:]] value = _NAMED.get(value.lower(), value) + if not value.startswith('#'): + try:shade=float(value) + except ValueError:shade=float('nan') + if math.isfinite(shade) and 0<=shade<=1:return dict(r=shade,g=shade,b=shade,a=1.) if re.fullmatch(r"#[0-9a-fA-F]{3}", value): value = "#" + "".join(c * 2 for c in value[1:]) if not re.fullmatch(r"#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?", value): @@ -68,9 +95,18 @@ def _warn_external(message, category): _ALIGN = {"left": "Left", "center": "Center", "right": "Right"} -def _segments(text): +def _segments(text, *, dpi=120): # The renderer's own plain-text segments, including its mapping of # Unicode superscript/subscript characters (e.g. "10⁻³") to styled ones. + from ._richtext import TextRun + if isinstance(text, TextRun): + return text._segments(dpi) + if isinstance(text, (list, tuple)) and any(isinstance(s, TextRun) for s in text): + return [part for run in text for part in _segments(run, dpi=dpi)] + if isinstance(text,list): + if not all(isinstance(s,dict) and isinstance(s.get('text'),str) for s in text): + raise ValueError('rich text must contain text segment mappings') + return copy.deepcopy(text) return json.loads(text_segments_json(str(text))) @@ -110,7 +146,7 @@ def _text(value): return "".join(s["text"] for s in value["segments"]) if text is None else text -class Artist: +class Artist(ArtistMethods): """A stable series handle. Data lives in the Rust renderer's column pool.""" def __init__(self, fig, series_id): @@ -124,10 +160,19 @@ def set_linewidth(self, value): return self.figure.update(self.id, linewidth=value) def set_label(self, value): + if value is None: + state=self.figure.get_settings();self._spec(state)['label']=None + return self.figure.apply_settings(state) return self.figure.update(self.id, label=value) def set(self, **kwargs): - return self.figure.update(self.id, **kwargs) + aliases={'c':'color','lw':'linewidth','ls':'linestyle','ms':'markersize'} + kwargs={aliases.get(k,k):v for k,v in kwargs.items()} + with self.figure.batch_update(): + for key in ('marker','alpha','drawstyle','facecolor','edgecolor'): + if key in kwargs:getattr(self,'set_'+key)(kwargs.pop(key)) + self.figure.update(self.id, **kwargs) + return self.figure class RadialArtist: @@ -137,9 +182,12 @@ def __init__(self, figure): self.figure = figure def set_style(self, **properties): - self.figure.set_radial_style(**properties) + self.figure.axes[0].chart.set_style(**properties) return self + def get_style(self): + return self.figure.axes[0].chart.get_style() + def set_slice(self, index, **properties): if "color" in properties: properties["color"] = _color(properties["color"]) @@ -168,7 +216,7 @@ def set_values(self, values): return self -class Figure: +class Figure(FigureMethods): """One editable chart document, with a persistent native renderer. ``figsize`` is inches, ``dpi`` defines the logical canvas size. Arrays are @@ -177,7 +225,7 @@ class Figure: Rendering, initialization, GPU fitting and picking release the GIL. """ - def __init__(self, figsize=(8, 5), dpi=120, *, pool_bytes=8 * 1024**2, backend="auto", retain_notebook_data=False, grid=None, id=None): + def __init__(self, figsize=(8, 5), dpi=120, *, pool_bytes=8 * 1024**2, backend="auto", retain_notebook_data=False, grid=None, id=None, style=_style_runtime.INHERIT): if not math.isfinite(float(dpi)) or dpi <= 0: raise ValueError("dpi must be positive and finite") figsize = tuple(map(float, figsize)) @@ -186,6 +234,7 @@ def __init__(self, figsize=(8, 5), dpi=120, *, pool_bytes=8 * 1024**2, backend=" width, height = (round(v * float(dpi)) for v in figsize) self.dpi = float(dpi) self._lock = threading.RLock() + self._style_batch = None if backend == "auto": try: from IPython import get_ipython @@ -196,6 +245,8 @@ def __init__(self, figsize=(8, 5), dpi=120, *, pool_bytes=8 * 1024**2, backend=" if backend not in ("native", "webgpu"): raise ValueError("backend must be auto/native/webgpu") self.backend = backend + self._style_defaults,self._style_session = _style_runtime.capture(style) + self._style_next_index = 0 if backend == "webgpu": from .notebook import NotebookData self._native = NotebookData(width, height, pool_bytes, retain_notebook_data) @@ -208,10 +259,12 @@ def __init__(self, figsize=(8, 5), dpi=120, *, pool_bytes=8 * 1024**2, backend=" self._selection = None self._next_series = 0 self._axes_handles = {} + self._artist_handles = {} self.axes = [Axes(self, 'main')] self.subplots = _document.Subplots(self) state = self.get_settings() state.update(schema_version=2, identity=_document.initial_identity(id)) + _style_runtime.containers(self,state,initial=True) self.apply_settings(state) if grid is not None: self.set_layout(grid) @@ -226,6 +279,11 @@ def _axes_for(self, panel_id, overlay_id=None): self._axes_handles[key] = Axes(self, panel_id, overlay_id) return self._axes_handles[key] + def _artist_for(self, series_id): + if series_id not in self._artist_handles: + self._artist_handles[series_id] = Artist(self,series_id) + return self._artist_handles[series_id] + @property def id(self): return self.get_settings()['identity']['figure'] @@ -234,8 +292,12 @@ def id(self): def shared_axes(self): return copy.deepcopy(self.get_settings()['identity']['groups']) - def set_layout(self, grid, *, placement=None, row_weights=None, column_weights=None, gap=12): - """Place stable subplot IDs into named grid slots (repeated IDs span cells).""" + def set_layout(self, grid, *, placement=None, row_weights=None, column_weights=None, gap=None): + """Place stable subplot IDs into named grid slots (repeated IDs span cells). + + Omitted gap/weights retain compatible accepted values, then inherit + captured style defaults. A new unstyled grid defaults to a 12 px gap. + """ slots = _document.grid_slots(grid) placement = dict(placement) if placement is not None else {slot: slot for slot in slots} if set(placement) != set(slots): @@ -251,6 +313,7 @@ def set_layout(self, grid, *, placement=None, row_weights=None, column_weights=N if len(ids) != len(placement): raise ValueError('a subplot cannot occupy more than one slot') old_ids = [p['id'] for p in old] + created=[] if not state['series'] and len(old) == 1 and not state['config'].get('overlays'): # The empty initial panel may take the first explicit identity. old[0]['id'] = ids[0] @@ -265,15 +328,15 @@ def set_layout(self, grid, *, placement=None, row_weights=None, column_weights=N cfg.pop(key, None) state['config'].setdefault('subplots', []).append(dict(config=cfg, series=[])) old.append(dict(id=panel_id, overlays=[])) + created.append(panel_id) previous = state['identity'].get('layout') area = self._default_config['chart_area'] size = previous['size'] if previous else [area['width'], area['height']] state['identity']['layout'] = dict(grid=[list(row) for row in grid], placement=placement, - row_weights=list(row_weights) if row_weights is not None else [1]*len(grid), - column_weights=list(column_weights) if column_weights is not None else [1]*len(grid[0]), - gap=float(gap), size=size) + size=size,**_style_runtime.layout_options(self,state,grid,row_weights,column_weights,gap)) _document.validate(state) _document.place_grid(state) + _style_runtime.containers(self,state,panel_ids=created) self.apply_settings(state) return self.subplots @@ -337,8 +400,73 @@ def info(self): def get_settings(self): with self._lock: self._require_open() + if self._style_batch is not None: + return copy.deepcopy(self._style_batch) return json.loads(self._native.settings_json()) + def _require_data_edit(self): + if self._style_batch is not None: + raise RuntimeError('create or replace plot data outside batch_update; this block edits existing artists') + + def extract_style(self, *, scales=False, text=False): + """Extract a portable StyleToken; domains and wording are opt-in. + + After browser edits use ``await extract_style_async()`` to include all + pending changes. Lengths in the token use points, independent of DPI. + """ + from .styles import extract + return extract(self, scales=scales, text=text) + + async def extract_style_async(self, *, scales=False, text=False, timeout=30): + """Synchronize browser edits before extracting the accepted style.""" + await self.sync(timeout=timeout) + return self.extract_style(scales=scales, text=text) + + def apply_style(self, token, **options): + """Apply a StyleToken/preset atomically and return a StyleReport. + + Appearance is applied by default. ``scales=True`` and ``text=True`` + opt into captured payloads; text can instead be a set containing + titles, axis_titles and/or legend_labels. ``size_scale`` changes + visual lengths only. See docs/STYLE_TOKENS.md. + """ + from .styles import apply + return apply(self, token, **options) + + @property + def style_defaults(self): + """A copy of this Figure's captured appearance defaults, or None.""" + return copy.deepcopy(self._style_defaults) + + def set_style(self, token): + """Restyle existing objects and retain appearance defaults for new ones. + + Unlike one-shot apply_style(), subsequent constructors inherit this + token. Passing None disables future inheritance without undoing edits. + """ + with self._lock: + if token is None: + self._style_defaults=self._style_session=None + return self + value=_style_runtime._token(token) + self.apply_style(value) + self._style_defaults=value + self._style_session=_style_runtime._Session() + self._style_next_index=len(self.get_settings()['series']) + return self + + def apply_scales(self, scales, *, limits=True, **options): + """Apply captured scales only; limits=False keeps bounds and ticks. + + Invalid logarithmic domains are rejected without silently fitting data. + """ + from .styles import StyleToken, apply + if scales is None: + raise ValueError('no captured scales') + panels=[dict(config={}) for _ in scales.get('panels', [])] + return apply(self, StyleToken(dict(panels=panels), scales=scales), + scales=True, limits=limits, **options) + @property def edit_history(self): """Undo/redo availability for persistent edits; no source arrays are copied.""" @@ -418,6 +546,11 @@ def apply_settings(self, settings, *, expected_revision=None, reset=False): Read the current SSOT with ``get_settings``, edit only the desired fields, then submit it here for precise control. This does not choose tick spacing, fit data ranges or enlarge the title band. + + ``reset=True`` expands omitted Config fields from this Figure's renderer + defaults, including subplot/overlay axes and colorbars. The resolved + complete document is validated and committed once. It never merges + omitted fields from the target's current edits. """ with self._lock: self._require_open() @@ -425,22 +558,27 @@ def apply_settings(self, settings, *, expected_revision=None, reset=False): raise ValueError("stale edit: the Figure changed; refresh and retry") if not isinstance(settings, dict): raise ValueError('settings must be a document mapping') - if reset: - settings = copy.deepcopy(settings) - settings["config"] = _merge(copy.deepcopy(self._default_config), settings["config"]) + # Detach exactly once; validation and batch staging own this copy. settings = copy.deepcopy(settings) + if reset: + from ._codegen import expand_config + settings['config'] = expand_config(settings['config'], self._default_config) previous = self.get_settings() if settings.get('schema_version') == 1 and 'identity' not in settings: - current = self.get_settings().get('identity') + current = previous.get('identity') if current is not None: settings['identity'] = copy.deepcopy(current) settings['schema_version'] = 2 try: _document.prune_tick_modes(previous,settings) _document.validate(settings) - _document.track_identities(previous, settings) + if self._style_batch is None: + _document.track_identities(previous, settings) except (AttributeError, KeyError, IndexError, StopIteration) as exc: raise ValueError('invalid settings document structure') from exc + if self._style_batch is not None: + self._style_batch = settings + return self self._native.apply_settings(json.dumps(settings, allow_nan=False)) accepted = json.loads(self._native.settings_json()) if 'identity' in accepted: @@ -450,6 +588,8 @@ def apply_settings(self, settings, *, expected_revision=None, reset=False): return self def _changed(self): + if self._style_batch is not None: + return for widget in list(self._widgets): widget.refresh() for window in list(self._windows): @@ -483,52 +623,43 @@ def update(self, target, *, _axes=None, **properties): """ if not isinstance(target, str) or not target: raise ValueError("editable target must be a non-empty string") - kind = _family(self.get_settings()) - if kind is not None: - if target == 'chart_title': - state = self.get_settings() - chart = state[kind] - title = chart.get('chart_title') or dict(visible=True, text=dict( - segments=_segments(chart['title']), font_size=chart['font_size']*1.5, - font=chart['font_family'], color=copy.deepcopy(chart.get('text_color', chart.get('label_color'))), align='Left')) - title = copy.deepcopy(title) - for key, value in properties.items(): - if key == 'text': - title['text']['segments'] = _segments(str(value)) - chart['title'] = str(value) - elif key == 'fontsize': title['text']['font_size'] = float(value)*self.dpi/72 - elif key == 'font': title['text']['font'] = self._check_font(value) - elif key == 'color': title['text']['color'] = _color(value) - elif key == 'align': title['text']['align'] = str(value).title() - elif key == 'visible': title['visible'] = bool(value) - else: raise ValueError(f'unsupported bounded title property: {key}') - chart['chart_title'] = title - return self.apply_settings(state) - patch = {} - titles = {'chart_title': 'title', 'axis_title_bottom': 'category_title', 'axis_title_left': 'value_title'} - if kind not in ('radial', 'radar') and self.get_settings()[kind]['direction'] == 'Horizontal': - titles.update(axis_title_bottom='value_title', axis_title_left='category_title') - if target not in titles or (kind in ('radial', 'radar') and target != 'chart_title'): - raise ValueError('use the chart artist or configure_bounded/configure_radial for data properties') - for key, value in properties.items(): - if key == 'text': patch[titles[target]] = str(value) - elif key == 'fontsize': patch['font_size'] = float(value)*self.dpi/72 - elif key == 'font': patch['font_family'] = self._check_font(value) - else: raise ValueError(f'unsupported bounded title property: {key}') - return self.configure_radial(**patch) if kind == 'radial' else self.configure_bounded(kind, **patch) with self._lock: - self._require_open() state = self.get_settings() + kind = _family(state) + if kind is not None: + if target == 'chart_title': + chart = state[kind] + title = _chart_text.title_options(chart) + for key, value in properties.items(): + if key == 'text': + _chart_text.set_title_text(chart, segments=_segments(value, dpi=self.dpi)) + elif key == 'fontsize': title['text']['font_size'] = float(value)*self.dpi/72 + elif key == 'font': title['text']['font'] = self._check_font(value) + elif key == 'color': title['text']['color'] = _color(value) + elif key == 'align': title['text']['align'] = str(value).title() + elif key == 'visible': title['visible'] = bool(value) + else: raise ValueError(f'unsupported bounded title property: {key}') + chart['chart_title'] = title + return self.apply_settings(state) + patch = {} + titles = {'chart_title': 'title', 'axis_title_bottom': 'category_title', 'axis_title_left': 'value_title'} + if kind not in ('radial', 'radar') and state[kind]['direction'] == 'Horizontal': + titles.update(axis_title_bottom='value_title', axis_title_left='category_title') + if target not in titles or (kind in ('radial', 'radar') and target != 'chart_title'): + raise ValueError('use the chart artist or configure_bounded/configure_radial for data properties') + for key, value in properties.items(): + if key == 'text': patch[titles[target]] = str(value) + elif key == 'fontsize': patch['font_size'] = float(value)*self.dpi/72 + elif key == 'font': patch['font_family'] = self._check_font(value) + else: raise ValueError(f'unsupported bounded title property: {key}') + return self.configure_radial(**patch) if kind == 'radial' else self.configure_bounded(kind, **patch) if _axes is None: - for index, panel in enumerate(state['config'].get('subplots', [])): - if target in panel.get('series', []): - _axes = self._axes_for(state['identity']['panels'][index+1]['id']) - break + _axes = self._axes_for(_document.series_panel_id(state, target)) cfg = state["config"] if _axes is None else _axes._config(state) - before_config = copy.deepcopy(cfg) series = next((s for s in state["series"] if s["series_id"] == target), None) if series is not None: style = next(iter(series["render_type"].values())) + scale_changes = {} for key, value in properties.items(): if key == "color": matched = False @@ -545,23 +676,25 @@ def update(self, target, *, _axes=None, **properties): style["line"]["line_width"] = float(value) * self.dpi / 72 elif key == 'linewidth' and 'Vector' in series['render_type']: style['style']['width_px'] = float(value)*self.dpi/72 + elif key == 'linewidth' and 'bar' in style: + style['bar']['border_width'] = float(value)*self.dpi/72 elif key == "markersize" and "scatter" in style: style["scatter"]["point_size"] = float(value) * self.dpi / 144 elif key == "linestyle" and "line" in style: style["line"]["line_style"] = _LINES.get(value, value) elif key == "label": label = copy.deepcopy(cfg["legend"]["content"]) - label["segments"] = _segments(value) + label["segments"] = _segments(value, dpi=self.dpi) series["label"] = label elif key in ("cmap", "vmin", "vmax") and ('matrix' in style or style.get('scatter', {}).get('point_color_map') or style.get('style', {}).get('color_map')): - bar = cfg["colorbar"] - if key == "cmap": - bar["colormap"] = _colormap(value) - else: - bar["axis"]["min" if key == "vmin" else "max"] = float(value) + scale_changes[key] = value else: raise ValueError(f"unsupported series property: {key}") + if scale_changes: + from ._color_scale import update_color_scale + update_color_scale(cfg, **scale_changes) else: + before_config = copy.deepcopy(cfg) if target == "chart_title": node, rich = cfg["chart_title"], cfg["chart_title"]["text"] elif target == "legend": @@ -581,7 +714,7 @@ def update(self, target, *, _axes=None, **properties): _document.set_tick_mode(state,_axes.subplot_id if _axes else state['identity']['panels'][0]['id'],_axes.overlay_id if _axes else None,_axis_key(target[len('axis_'):]),False,spacing=properties['major_spacing']) for key, value in properties.items(): if key == "text" and rich is not None: - rich["segments"] = _segments(value) + rich["segments"] = _segments(value, dpi=self.dpi) elif key == "align" and rich is not None: if not isinstance(value, str) or value.lower() not in _ALIGN: raise ValueError("align must be 'left', 'center' or 'right'") @@ -606,6 +739,8 @@ def update(self, target, *, _axes=None, **properties): raise ValueError(f"unsupported property for {target}: {key}") elif key == "offset" and "offset_x" in node: node["offset_x"], node["offset_y"] = map(float, value) + elif target == 'legend' and key in ('corner', 'loc'): + node['corner'] = _legend_corner(value) elif key in ("format", "significant_digits") and "label_style" in node: node["label_style"][key] = copy.deepcopy(value) elif key == "label_style" and "label_style" in node: @@ -640,14 +775,12 @@ def update(self, target, *, _axes=None, **properties): cfg.clear() cfg.update(reconciled) # Reject bad sizes before they reach GPU geometry creation. - for spec in state["series"]: + for spec in ([series] if series is not None else []): for style in spec["render_type"].values(): for kind, field in [("line", "line_width"), ("scatter", "point_size")]: if kind in style and (not math.isfinite(style[kind][field]) or style[kind][field] < 0): raise ValueError(f"{field} must be nonnegative and finite") - self._native.apply_settings(json.dumps(state, allow_nan=False)) - self._changed() - return self + return self.apply_settings(state) def render_rgba(self, *, scale=1.0): """Synchronously render and read back a (height, width, 4) uint8 array.""" @@ -724,11 +857,11 @@ def set_facecolor(self, color): if _family(state) is not None: state[_family(state)]["background"] = _color(color) else: - rgba = _color(color) - if rgba == _color("white"): - state.pop("background", None) - else: - state["background"] = rgba + # An explicit white background is still accepted document state. + # Dropping it here makes exact code replay silently normalize a + # style token's snapshot. Resetting to defaults is reset_style's + # job, independently of which color a caller explicitly sets. + state["background"] = _color(color) return self.apply_settings(state) def set_draw_style(self, mode="precise", **options): @@ -754,13 +887,18 @@ def set_draw_style(self, mode="precise", **options): self.apply_settings(state) return self - def configure_radial(self, *, style=None, slices=None, expected_slices=None, **properties): + def configure_radial(self, *, style=None, slices=None, expected_slices=None, reset=False, **properties): """Patch an existing pie/donut, preserving its values unless explicitly set. ``slices`` maps existing zero-based indices to appearance/label patches. Use the radial artist's ``set_values`` to replace category values. Advanced fields follow the upstream serialized radial model. + ``reset=True`` restores chart-level renderer defaults before patching; + existing slice properties/values remain unless explicitly patched. + The default keeps accepted edits. """ + if type(reset) is not bool: + raise ValueError('reset must be boolean') with self._lock: state = self.get_settings() if state.get("radial") is None: @@ -770,6 +908,11 @@ def configure_radial(self, *, style=None, slices=None, expected_slices=None, **p raise ValueError("radial style replay requires the same slice count") if "slices" in properties or "value" in properties: raise ValueError("use set_values to change radial data") + if reset: + from ._native import default_radial_json + baseline = json.loads(default_radial_json()) + baseline['slices'] = chart['slices'] + chart = state['radial'] = baseline _merge(chart, properties) if style is not None: _merge(chart["style"], style) @@ -784,12 +927,17 @@ def configure_radial(self, *, style=None, slices=None, expected_slices=None, **p _merge(chart["slices"][index], patch) return self.apply_settings(state) - def configure_bounded(self, kind=None, *, style=None, series=None, expected_categories=None, expected_series=None, **properties): + def configure_bounded(self, kind=None, *, style=None, series=None, expected_categories=None, expected_series=None, reset=False, **properties): """Patch a categorical/box chart SSOT without changing unspecified fields. ``series`` maps stable series IDs to patches. Values are only replaced when explicitly supplied; Copy Python preserves the destination data. + ``reset=True`` restores renderer defaults before applying this patch, + retaining existing categories/series and their values. Otherwise only + supplied fields change. """ + if type(reset) is not bool: + raise ValueError('reset must be boolean') with self._lock: state = self.get_settings() kind = kind or _family(state) @@ -800,6 +948,11 @@ def configure_bounded(self, kind=None, *, style=None, series=None, expected_cate raise ValueError('style replay requires matching category IDs') if expected_series is not None and [s['id'] for s in chart['series']] != expected_series: raise ValueError('style replay requires matching series IDs') + if reset: + from ._native import default_bounded_json + baseline = json.loads(default_bounded_json(kind)) + baseline.update(categories=chart['categories'], series=chart['series']) + chart = state[kind] = baseline _merge(chart, properties) if style is not None: _merge(chart['style'], style) @@ -817,14 +970,19 @@ def set_radial_style(self, *, material=None, **properties): properties["material"] = _material(material) return self.configure_radial(style=properties) - def to_code(self, variable="fig"): + def to_code(self, variable="fig", *, mode='api', compact=True): """Readable Python setters for an existing Figure with the same series. Font sizes are exported in points, using this Figure's DPI. Recreate the original plot at the same DPI before replaying its settings. + The default emits public Figure/Axes/Axis/Artist setters. Figgy-only + state without a corresponding object setter remains an explicit advanced + block; nothing is silently omitted. ``mode='exact'`` restores the complete + document, including identities. ``compact=False`` expands that diagnostic + form. Replay requires the same panels, source columns, and DPI. """ from ._codegen import settings_to_code - return settings_to_code(self.get_settings(), self._default_config, self.dpi, variable) + return settings_to_code(self.get_settings(), self._default_config, self.dpi, variable, compact=compact,mode=mode) def select(self, x, y, *, edit_area=False): with self._lock: @@ -880,6 +1038,7 @@ def fit(self, padding=None): to zero padding. Other Cartesian figures use 5%. Pass an explicit padding fraction to override either default. """ + self._require_data_edit() with self._lock: self._require_open() kind = _family(self.get_settings()) @@ -985,10 +1144,12 @@ def _repr_mimebundle_(self, **kwargs): return self.widget()._repr_mimebundle_(**kwargs) def show(self, *, block=True): + self._require_data_edit() from .desktop import show return show(self, block=block) def close(self): + self._require_data_edit() with self._lock: for widget in list(self._widgets): widget.close() @@ -1053,6 +1214,29 @@ def _code_tree(value, segments_function="text_segments"): if text is not None: result[key] = _CodeExpression(f"{segments_function}({text!r})") continue + # Consecutive equal styles are readable runs, not a dictionary + # for every glyph. Fall back to raw values unless reconstruction + # is exact, including renderer-specific legend rule fields. + runs = [] + for segment in current: + style = {k: v for k, v in segment.items() if k != 'text'} + if runs and runs[-1][0] == style: + runs[-1][1].append(segment) + else: + runs.append((style, [segment])) + expressions = [] + for style, segments in runs: + plain = ''.join(s['text'] for s in segments) + base = _segments(plain) + overrides = {k: v for k, v in style.items() if any(s.get(k) != v for s in base)} + rebuilt = [dict(s, **overrides) for s in base] + if rebuilt != segments: + break + args = ', '.join(f'{k}={v!r}' for k, v in overrides.items()) + expressions.append(f"{segments_function}({plain!r}" + (f', {args}' if args else '') + ')') + else: + result[key] = _CodeExpression(' + '.join(expressions)) + continue result[key] = _code_tree(current, segments_function) return result if isinstance(value, list): @@ -1060,18 +1244,24 @@ def _code_tree(value, segments_function="text_segments"): return value -def text_segments(text): +def text_segments(text, **style): """Build renderer RichSegment values for ``text``. Unicode superscript/subscript characters such as ``"10⁻³"`` become superscript/subscript segments, exactly as the renderer maps them. + Optional renderer segment fields (bold, italic, color, font_size, etc.) apply + to the whole run. Text remains ordinary Python, including Unicode/newlines. """ - return _segments(text) + allowed = {'bold', 'italic', 'underline', 'superscript', 'subscript', 'greek', + 'color', 'font_size', 'field_em', 'rule', 'rule_dash', 'rule_band'} + if set(style) - allowed: + raise ValueError('unknown rich-text segment style') + return [dict(segment, **copy.deepcopy(style)) for segment in _segments(text)] def _colormap(value): try: - return {"viridis": "Viridis", "magma": "Magma", "turbo": "Turbo", "gray": "GrayScale", "grey": "GrayScale", "rdbu": "RdBu"}[value.lower()] + return {"viridis": "Viridis", "magma": "Magma", "turbo": "Turbo", "gray": "GrayScale", "grey": "GrayScale", "grayscale": "GrayScale", "rdbu": "RdBu"}[value.lower()] except (KeyError, AttributeError): raise ValueError("cmap must be viridis, magma, turbo, gray or RdBu") from None @@ -1092,7 +1282,7 @@ def _material(value): from ._features import FeatureMethods -class Axes(FeatureMethods, ChartMethods): +class Axes(AxesMethods, FeatureMethods, ChartMethods): def __init__(self, figure, panel_id='main', overlay_id=None): self.figure = figure self.subplot_id, self.overlay_id = panel_id, overlay_id @@ -1146,6 +1336,7 @@ def twinx(self, *, id=None): y_axis['label_style']['visible'] = True cfg.setdefault('overlays', []).append(dict(y_axis=y_axis, series=[])) ids.append(oid) + _style_runtime.containers(self.figure,state,overlays=[(self.subplot_id,oid)]) self.figure.apply_settings(state) return self.figure._axes_for(self.subplot_id, oid) @@ -1158,6 +1349,7 @@ def set_polar(self, **options): return self.configure(polar=defaults) def fit(self, padding=0.05): + self.figure._require_data_edit() with self.figure._lock: self.activate() if _family(self.figure.get_settings()): @@ -1185,8 +1377,13 @@ def _set_bounds(self, dimension, low, high): state = self.figure.get_settings() kind = _family(state) if kind: + if kind == 'categorical': + raise NotImplementedError( + 'categorical bars use an automatic value range; renderer 0.18 ' + 'does not support manual axis limits' + ) value_axis = 'x' if kind == 'boxplot' and state[kind]['direction'] == 'Horizontal' else 'y' - if kind not in ('boxplot', 'radar') or dimension != value_axis: + if kind not in ('boxplot','radar') or dimension != value_axis: raise ValueError(f'{kind} does not support an adjustable {dimension} value axis') state[kind]['value_range'] = [low, high] self.figure.apply_settings(state) @@ -1210,6 +1407,7 @@ def set_draw_style(self, mode="precise", **options): self.figure.apply_settings(state) return self + @_style_runtime.styled def pie(self, values, *, labels=None, colors=None, explode=None, startangle=90, inner_radius=None, material="flat", tilt=0, depth=0, labels_position="outside", label_format="name_percent", **style): @@ -1220,6 +1418,7 @@ def pie(self, values, *, labels=None, colors=None, explode=None, startangle=90, ``startangle=90`` starts at the top; slices proceed clockwise. """ fig = self.figure + fig._require_data_edit() if len(fig.subplots) != 1 or self.overlay_id or self._config(fig.get_settings()).get('polar'): raise ValueError('bounded charts require a separate single-panel Figure') values = _array(values) @@ -1238,27 +1437,33 @@ def pie(self, values, *, labels=None, colors=None, explode=None, startangle=90, raise ValueError("unsupported radial label placement/format") from ._native import default_radial_json chart = json.loads(default_radial_json()) - title = fig.get_settings()["config"]["chart_title"]["text"] - chart.update(title=_text(title), font_size=11 * fig.dpi / 72, + chart.update(font_size=11 * fig.dpi / 72, kind="Pie" if inner_radius is None else {"Donut": {"inner_radius": float(inner_radius)}}, start_angle_degrees=-float(startangle), labels=placements[labels_position], label_format=formats[label_format]) - chart["style"].update(material=_material(material), tilt_degrees=float(tilt), depth=float(depth), **style) + chart["style"].update(material=_material(material), tilt_degrees=float(tilt), depth=float(depth)) + _merge(chart['style'],style) chart["slices"] = [dict(label=str(label), value=float(value), color=_color(color), explode=float(offset), style=None, labels=None, label_format=None, label_color=None) for value, label, color, offset in zip(values, labels, colors, explode)] with fig._lock: fig._require_open() + _chart_text.inherit_title(chart, self._config(fig.get_settings())) + if fig._style_defaults is not None: + state=fig.get_settings();state['radial']=chart + chart=_style_runtime.bounded(fig,state,'radial')['radial'] fig._native.set_radial(json.dumps(chart, allow_nan=False)) fig._changed() return RadialArtist(fig) + @_style_runtime.styled def donut(self, values, *, inner_radius=0.5, **kwargs): """Create a donut; accepts the same labels/material/style options as pie.""" return self.pie(values, inner_radius=inner_radius, **kwargs) def _add(self, x, y, render_type, label=None, id=None, extra_columns=(), config=None, bounds=None): fig = self.figure + fig._require_data_edit() with fig._lock: fig._require_open() if _family(fig.get_settings()) is not None: @@ -1292,15 +1497,16 @@ def _add(self, x, y, render_type, label=None, id=None, extra_columns=(), config= if self.overlay_id: self._overlay(document).setdefault('series', []).append(sid) uploaded = [] + spec = {"series_id": sid, "label": None, "x_column": names[0], "y_column": names[1], "render_type": render_type} + if label is not None: + rich = copy.deepcopy(fig.get_settings()["config"]["legend"]["content"]) + rich["segments"] = _segments(label, dpi=fig.dpi) + spec["label"] = rich + spec,document=_style_runtime.series(fig,document,spec,self.subplot_id) try: for name, array in [(names[0], x), (names[1], y), *extra_columns]: fig._native.upload_column(name, array) uploaded.append(name) - spec = {"series_id": sid, "label": None, "x_column": names[0], "y_column": names[1], "render_type": render_type} - if label is not None: - rich = copy.deepcopy(fig.get_settings()["config"]["legend"]["content"]) - rich["segments"] = _segments(label) - spec["label"] = rich fig._native.add_series_fitted( json.dumps(spec, allow_nan=False), json.dumps(document["config"], allow_nan=False), @@ -1311,10 +1517,12 @@ def _add(self, x, y, render_type, label=None, id=None, extra_columns=(), config= fig._native.remove_column(name) raise fig._next_series += 1 + _style_runtime._mark_used(fig) fig._changed() - return Artist(fig, sid) + return fig._artist_for(sid) def _add_fill_pair(self, x, lower, upper, line, label, sid): + self.figure._require_data_edit() fig = self.figure with fig._lock: state = fig.get_settings() @@ -1330,7 +1538,7 @@ def _add_fill_pair(self, x, lower, upper, line, label, sid): rich = None if label is not None: rich = copy.deepcopy(self._config(state)['legend']['content']) - rich['segments'] = _segments(label) + rich['segments'] = _segments(label, dpi=fig.dpi) specs = [dict(series_id=sid+'/lower', label=None, x_column=names[0], y_column=names[1], render_type={'Line': dict(line=lower_style)}), dict(series_id=sid, label=rich, x_column=names[0], y_column=names[2], @@ -1342,6 +1550,7 @@ def _add_fill_pair(self, x, lower, upper, line, label, sid): if self.overlay_id: self._overlay(state).setdefault('series', []).append(spec['series_id']) state['config']['active_layer'] = _document.target_layer(state, self.subplot_id, self.overlay_id) + specs=[_style_runtime.series(fig,state,spec,self.subplot_id)[0] for spec in specs] uploaded = [] try: for name, values in zip(names, (x, lower, upper)): @@ -1353,10 +1562,33 @@ def _add_fill_pair(self, x, lower, upper, line, label, sid): fig._native.remove_column(name) raise fig._next_series += 2 + _style_runtime._mark_used(fig) fig._changed() - return Artist(fig, sid) + return fig._artist_for(sid) - def plot(self, x, y=None, fmt=None, *, color=None, linewidth=1.5, linestyle="-", marker=None, markersize=4, label=None, id=None, interpolation="Linear", fill=None, fill_color=None): + @_style_runtime.styled + def plot(self, x, y=None, fmt=None, *, color=None, linewidth=1.5, linestyle="-", marker=None, markersize=4, label=None, id=None, interpolation="Linear", fill=None, fill_color=None, **kwargs): + """Plot a line, or a line with markers, from equal-length numeric arrays. + + With y omitted, use sample indices for X. linewidth/markersize are in + points; label adds a legend entry and id is a stable edit target. + Returns a one-item list of Artist handles. Use legend(loc='upper left') + for placement and Figure.update('axis_bottom', major_spacing=...) for + regular ticks. This is a plotting subset, not the full matplotlib API. + """ + aliases={'lw':'linewidth','ls':'linestyle','ms':'markersize','c':'color'} + if set(kwargs)-set(aliases)-{'alpha','drawstyle','gid'}:raise TypeError('unsupported plot arguments: '+', '.join(sorted(set(kwargs)-set(aliases)-{'alpha','drawstyle','gid'}))) + linewidth=kwargs.get('lw',linewidth);linestyle=kwargs.get('ls',linestyle) + markersize=kwargs.get('ms',markersize);color=kwargs.get('c',color) + if 'gid' in kwargs: + if id is not None:raise TypeError('supply id or gid, not both') + id=kwargs['gid'] + if 'drawstyle' in kwargs: + modes={'default':'Linear','steps-pre':'StepPre','steps-post':'StepPost','steps-mid':'StepMid'} + if kwargs['drawstyle'] not in modes:raise ValueError('unsupported drawstyle') + interpolation=modes[kwargs['drawstyle']] + alpha=kwargs.get('alpha') + if alpha is not None and (not math.isfinite(float(alpha)) or not 0<=float(alpha)<=1):raise ValueError('alpha must be in 0..1') marker_only_format = False if isinstance(y, str): if fmt is not None: @@ -1372,7 +1604,7 @@ def plot(self, x, y=None, fmt=None, *, color=None, linewidth=1.5, linestyle="-", remaining = remaining.replace(candidate, "", 1) break for c in remaining: - if c in "bgrkw" and color is None: + if c in "bgrkwcmy" and color is None: color = c elif c in _MARKERS and marker is None: marker = c @@ -1387,8 +1619,9 @@ def plot(self, x, y=None, fmt=None, *, color=None, linewidth=1.5, linestyle="-", if x.size != y.size: raise ValueError("x and y must have the same length") color = _color(color if color is not None else _COLORS[self.figure._next_series % len(_COLORS)]) - if not math.isfinite(float(linewidth)) or linewidth <= 0 or not math.isfinite(float(markersize)) or markersize <= 0: - raise ValueError("line and marker sizes must be positive and finite") + if alpha is not None:color['a']=float(alpha) + if not math.isfinite(float(linewidth)) or linewidth < 0 or not math.isfinite(float(markersize)) or markersize < 0: + raise ValueError("line and marker sizes must be nonnegative and finite") # Python API uses points; renderer uses pixel widths and marker radii. line = {"line_style": _LINES.get(linestyle, linestyle), "line_color": color, "line_width": float(linewidth) * self.figure.dpi / 72} if interpolation not in ('Linear', 'StepPre', 'StepPost', 'StepMid'): @@ -1403,17 +1636,47 @@ def plot(self, x, y=None, fmt=None, *, color=None, linewidth=1.5, linestyle="-", style = {"Scatter": {"scatter": scatter}} if marker_only_format else {"ScatterLine": {"line": line, "scatter": scatter}} return [self._add(x, y, style, label, id)] + @_style_runtime.styled def scatter(self, x, y, *, s=16, color=None, c=None, cmap='viridis', vmin=None, vmax=None, - size_domain=None, size_range=(2., 12.), size_scale='area', marker="o", label=None, id=None): - from ._features import colorbar + size_domain=None, size_range=None, size_scale='area', marker="o", alpha=None, label=None, id=None): + """Plot numeric X/Y points; return an Artist with a stable id. + + Scalar and array s are marker areas in points squared. Explicit + size_domain/size_range opt into Figgy's data-driven size mapping + extension (diameters in points), using area or radius scale. + color fixes the marker color; numeric c maps through cmap/vmin/vmax and + creates a shared color scale. label adds a legend entry. set_xlim/ylim + choose automatic ticks; Figure.update('axis_bottom', major_spacing=...) + sets regular spacing. Arbitrary set_xticks/set_yticks are not provided. + """ + from ._color_scale import colorbar x, y = _array(x), _array(y) if x.size != y.size or not x.size: raise ValueError('scatter requires equal nonempty x/y vectors') + explicit_colors=None + if c is not None: + if color is not None:raise ValueError('supply color or c, not both') + if isinstance(c,str):color,c=c,None + else: + colors=np.asarray(c) + if colors.dtype.kind in 'US' or (colors.ndim==2 and colors.shape[1] in (3,4)): + explicit_colors=[_color(v) for v in c] + if len(explicit_colors) not in (1,x.size):raise ValueError('explicit colors must have one color or one per point') + color=tuple(explicit_colors[0][k] for k in 'rgba') + c=None + if alpha is not None: + if not math.isfinite(float(alpha)) or not 0<=float(alpha)<=1:raise ValueError('alpha must be in 0..1') + if c is not None:raise NotImplementedError('independent scalar-mapped scatter opacity requires renderer support') sid = id or f'series_{self.figure._next_series}' cfg = copy.deepcopy(self._config(self.figure.get_settings())) style = dict(point_color=_color(color if color is not None else _COLORS[self.figure._next_series % len(_COLORS)]), point_shape=_MARKERS.get(marker, marker), point_size=math.sqrt(float(s))*self.figure.dpi/144 if np.isscalar(s) else 4.) columns = [] + if alpha is not None:style['point_color']['a']=float(alpha) + if explicit_colors is not None and len(explicit_colors)>1: + if alpha is not None: + for value in explicit_colors:value['a']=float(alpha) + style['point_style_overrides']=[dict(index=i,point_color=value) for i,value in enumerate(explicit_colors)] if c is not None: values = _array(c) if values.size != x.size: @@ -1422,13 +1685,19 @@ def scatter(self, x, y, *, s=16, color=None, c=None, cmap='viridis', vmin=None, columns.append((f'{sid}/color', values)) style['point_color_map'] = dict(column=f'{sid}/color') if np.isscalar(s): - if not math.isfinite(float(s)) or float(s) <= 0: - raise ValueError('s must be positive and finite') + if not math.isfinite(float(s)) or float(s) < 0: + raise ValueError('s must be nonnegative and finite') else: values = _array(s) - if values.size != x.size or not np.isfinite(values).all(): - raise ValueError('scatter size values must be finite and match x/y') - low, high = (float(values.min()), float(values.max())) if size_domain is None else map(float, size_domain) + if values.size != x.size or not np.isfinite(values).all() or (values<0).any(): + raise ValueError('scatter size values must be nonnegative, finite and match x/y') + direct_area=size_domain is None and size_range is None and size_scale=='area' + if direct_area: + low,high=0.,max(1.,float(values.max())) + size_range=(0.,math.sqrt(high)) + else: + low, high = (float(values.min()), float(values.max())) if size_domain is None else map(float, size_domain) + size_range=(2.,12.) if size_range is None else size_range if low == high and size_domain is None: high = low+max(1., abs(low)*0.01) if not math.isfinite(low) or not math.isfinite(high) or low >= high: @@ -1443,7 +1712,16 @@ def scatter(self, x, y, *, s=16, color=None, c=None, cmap='viridis', vmin=None, scale=size_scale.title()) return self._add(x, y, {'Scatter': dict(scatter=style)}, label, sid, extra_columns=columns, config=cfg) + @_style_runtime.styled def imshow(self, z, *, extent=None, origin="upper", cmap="viridis", vmin=None, vmax=None, interpolation="nearest", id=None): + """Draw a numeric matrix (at least 2x2) and its shared color scale. + + extent=(xmin,xmax,ymin,ymax) gives increasing cell-edge bounds; the default + is half a cell outside the sample indices. origin='upper' inverts Y; + 'lower' leaves it increasing. interpolation is nearest or bilinear. + Existing color-scale styling is preserved; explicit cmap replaces its + map. Returns the matrix Artist, editable by id with Figure.update. + """ z = np.asarray(z) if z.ndim != 2 or min(z.shape) < 2 or z.dtype.kind not in "fiu": raise ValueError("imshow expects a numeric matrix with at least 2 rows and columns") @@ -1468,36 +1746,60 @@ def imshow(self, z, *, extent=None, origin="upper", cmap="viridis", vmin=None, v high = low + 1 if not math.isfinite(low) or not math.isfinite(high) or low >= high: raise ValueError("vmin and vmax must be finite and increasing") - # Renderer defaults own colorbar layout; obtain them from a native helper. - from ._native import default_colorbar_json - bar = json.loads(default_colorbar_json()) - bar["colormap"] = _colormap(cmap) - bar["axis"]["min"], bar["axis"]["max"] = low, high - bar["axis"]["major_spacing"] = (high - low) / 5 + from ._color_scale import _set_colorbar with self.figure._lock: config = self._config(self.figure.get_settings()) - config["colorbar"] = bar + _set_colorbar(config,cmap,low,high) for key in ("left_y", "right_y"): config[key]["inverted"] = origin == "upper" return self._add(x, y, style, id=id, extra_columns=columns, config=config, bounds=ext) def set_title(self, text, **kwargs): - return self.figure.update("chart_title", _axes=self, text=text, **kwargs) + """Set the chart title, including newlines; keep other title styles. + + Options: fontsize (points), color, font, align, visible. Bounded charts + support up to 512 rich characters / eight lines, independently of their + category and value labels. Text set before construction is preserved. + """ + return self._set_label('title', text, kwargs) def set_xlabel(self, text, **kwargs): - return self.figure.update("axis_title_bottom", _axes=self, text=text, **kwargs) + """Name the horizontal axis, before or after adding the first chart. + + For horizontal bars/boxes this is the value axis. Bounded axis names are + single-line and share the model's label font; Cartesian titles also + accept fontsize, color and font. Use Figure.update for exact styling. + """ + return self._set_label('xlabel', text, kwargs) def set_ylabel(self, text, **kwargs): - if self.overlay_id: - state = self.figure.get_settings() - axis = self._overlay(state)['y_axis'] - axis['title_option']['text']['segments'] = _segments(text) - for key, value in kwargs.items(): - if key == 'fontsize': axis['title_option']['text']['font_size'] = float(value)*self.figure.dpi/72 - elif key == 'color': axis['title_option']['text']['color'] = _color(value) - else: raise ValueError(f'unsupported title property: {key}') - return self.figure.apply_settings(state) - return self.figure.update("axis_title_left", _axes=self, text=text, **kwargs) + """Name the vertical axis (the category axis on horizontal bars/boxes). + + The name survives first-chart construction. Bounded axis names are + single-line and share label typography; Cartesian titles support + fontsize, color and font. On twinx this edits only the overlay's title. + """ + return self._set_label('ylabel', text, kwargs) + + def _set_label(self, role, text, properties): + properties = dict(properties) + # Legacy Figgy aliases remain accepted; ordinary calls use Matplotlib names. + for old, new in [('font','fontfamily'), ('align','multialignment')]: + if old in properties: properties[new] = properties.pop(old) + offset = properties.pop('offset', None) + handle = Text(self, role) + with self.figure.batch_update(): + if _family(self.figure.get_settings()) and role != 'title': + allowed = {'fontsize','fontfamily'} + if set(properties)-allowed: + raise NotImplementedError('bounded axis labels share chart typography') + legacy = {'font' if k=='fontfamily' else k:v for k,v in properties.items()} + self.figure.update('axis_title_bottom' if role=='xlabel' else 'axis_title_left', _axes=self, text=text, **legacy) + else: handle.set(text=text, **properties) + if offset is not None: + # Existing offset= uses pixels; the new Text.set_offset uses points. + handle.set_offset(tuple(float(v)*72/self.figure.dpi for v in offset)) + return handle def _set_scale(self, axis, scale): self.figure._require_cartesian() @@ -1531,21 +1833,19 @@ def get_yscale(self): self.figure._require_cartesian() return "log" if (self._overlay(self.figure.get_settings())["y_axis"] if self.overlay_id else self._config(self.figure.get_settings())["left_y"])["scale"] == "Logarithmic" else "linear" - def set_xlim(self, left, right): + def set_xlim(self, left=None, right=None, *, emit=True, auto=False, xmin=None, xmax=None): """Set both X bounds and derive ticks/labels for the new range. Use ``set_xlim_without_tick_auto`` or an SSOT config patch to keep existing tick and label settings. """ - with self.figure._lock: - self.figure._require_open() - return self._set_bounds("x", left, right) + if auto:raise NotImplementedError('use fit() to recompute limits from resident data') + return self._limits('x',left,right,xmin,xmax) - def set_ylim(self, bottom, top): + def set_ylim(self, bottom=None, top=None, *, emit=True, auto=False, ymin=None, ymax=None): """Set both Y bounds and derive ticks/labels, as ``set_xlim`` does.""" - with self.figure._lock: - self.figure._require_open() - return self._set_bounds("y", bottom, top) + if auto:raise NotImplementedError('use fit() to recompute limits from resident data') + return self._limits('y',bottom,top,ymin,ymax) def _set_lim_keeping_ticks(self, keys, low, high): low, high = float(low), float(high) @@ -1579,29 +1879,118 @@ def set_ylim_without_tick_auto(self, bottom, top): self.figure._require_cartesian() return self._set_lim_keeping_ticks(("left_y", "right_y"), bottom, top) - def grid(self, visible=True): + def _invert_axis(self, dimension): + with self.figure._lock: + self.figure._require_cartesian() + state = self.figure.get_settings() + config = self._config(state) + if config.get('polar'): + raise ValueError('axis inversion requires a Cartesian panel; polar angles/radii have different semantics') + if dimension == 'y' and self.overlay_id: + axes = [self._overlay(state)['y_axis']] + else: + axes = [config[k] for k in (('bottom_x', 'top_x') if dimension == 'x' else ('left_y', 'right_y'))] + inverted = not axes[0]['inverted'] + for axis in axes: + axis['inverted'] = inverted + self.figure.apply_settings(state) + + def invert_xaxis(self): + """Toggle X direction on this Cartesian panel; keep limits and ticks. + + Limits stay increasing. On twinx the panel's shared X axis is inverted. + Bounded and polar charts do not use this Cartesian helper. + """ + return self._invert_axis('x') + + def invert_yaxis(self): + """Toggle Y direction without changing increasing limits or tick spacing. + + On twinx only the overlay's Y axis changes. Use this instead of passing + descending bounds to set_ylim. Bounded/polar charts are unsupported. + """ + return self._invert_axis('y') + + def grid(self, visible=None, which='major', axis='both', **kwargs): kind = _family(self.figure.get_settings()) if kind in ('categorical', 'boxplot', 'radar'): - return self.figure.configure_bounded(kind, grid=bool(visible)) + if kwargs or which!='major' or axis!='both': + raise NotImplementedError('bounded charts support a shared major grid only') + state=self.figure.get_settings() + return self.figure.configure_bounded(kind, grid=not state[kind]['grid'] if visible is None else bool(visible)) + if which not in ('major','minor','both') or axis not in ('x','y','both'): + raise ValueError('invalid grid axis or which') + aliases={'lw':'linewidth','ls':'linestyle','c':'color'} + kwargs={aliases.get(k,k):v for k,v in kwargs.items()} + if set(kwargs)-{'color','alpha','linewidth','linestyle'}:raise ValueError('unsupported grid properties') self.figure._require_cartesian() - state = self.figure.get_settings() - self._config(state)["grid"]["show_major_x"] = bool(visible) - self._config(state)["grid"]["show_major_y"] = bool(visible) - self.figure.apply_settings(state) + with self.figure.batch_update(): + state = self.figure.get_settings();grid=self._config(state)['grid'] + for dim in ('x','y'): + for level in ('major','minor'): + if axis not in (dim,'both') or which not in (level,'both'):continue + key='show_'+level+'_'+dim + grid[key]=(True if kwargs else not grid[key]) if visible is None else bool(visible) + self.figure.apply_settings(state) + if kwargs:self.tick_params(axis=axis,which=which,**{'grid_'+k:v for k,v in kwargs.items()}) - def legend(self, visible=True): + def legend(self, visible=True, *, loc=None, fontsize=None, prop=None, labelcolor=None, + borderpad=None, framealpha=None, facecolor=None, edgecolor=None, frameon=None): + """Show/hide the legend; Cartesian loc accepts upper/lower left/right. + + Renderer names TopLeft/TopRight/BottomLeft/BottomRight are also accepted. + Bounded legends support visibility only; unsupported placement raises + before editing. Use Figure.update('legend', fontsize=..., corner=...) + to edit an existing Cartesian legend without rebuilding its labels. + """ kind = _family(self.figure.get_settings()) if kind in ('categorical', 'boxplot', 'radar'): - return self.figure.configure_bounded(kind, legend=bool(visible)) + if loc is not None or any(v is not None for v in [prop,labelcolor,borderpad,framealpha,facecolor,edgecolor,frameon]): + raise ValueError('bounded legends support visibility only; loc is available on Cartesian charts') + if fontsize is not None:raise NotImplementedError('bounded legend fontsize is shared with other chart labels') + self.figure.configure_bounded(kind, legend=bool(visible)) + return Legend(self) self.figure._require_cartesian() + corner = None if loc is None else _legend_corner(loc) from ._native import rebuild_legend_json state = self.figure.get_settings() - colorbar = self._config(state).get("colorbar") - self._config(state)["legend"]["content"] = json.loads(rebuild_legend_json( - json.dumps(self._owned_series(state)), json.dumps(self._config(state)["legend"]["content"]), + config = self._config(state) + colorbar = config.get("colorbar") + owner = self._overlay(state) if self.overlay_id else config + if owner.get('legend') is None: + owner['legend'] = copy.deepcopy(config['legend']) + legend = owner['legend'] + legend["content"] = json.loads(rebuild_legend_json( + json.dumps(self._owned_series(state)), json.dumps(legend["content"]), json.dumps(colorbar["colormap"]) if colorbar else None)) - self._config(state)["legend"]["visible"] = bool(visible) + legend["visible"] = bool(visible) + if prop is not None: + if not isinstance(prop,dict) or set(prop)-{'family','size'}:raise NotImplementedError('legend prop supports family and size') + if 'family' in prop:legend['content']['font']=self.figure._check_font(prop['family']) + if 'size' in prop:fontsize=prop['size'] + if corner is not None: + legend['corner'] = corner + if fontsize is not None: + size=float(fontsize) + if not math.isfinite(size) or size<=0:raise ValueError('fontsize must be positive and finite') + legend['content']['font_size']=size*self.figure.dpi/72 + if facecolor is not None:legend['bg_color']=_color(facecolor) + if edgecolor is not None:legend['border_color']=_color(edgecolor) + if labelcolor is not None:legend['content']['color']=_color(labelcolor) + if borderpad is not None: + pad=float(borderpad) + if not math.isfinite(pad) or pad<0:raise ValueError('borderpad must be nonnegative and finite') + legend['padding']=pad*legend['content']['font_size'] + if framealpha is not None: + alpha=float(framealpha) + if not math.isfinite(alpha) or not 0<=alpha<=1:raise ValueError('framealpha must be in 0..1') + legend['bg_color']['a']=legend['border_color']['a']=alpha + if frameon is False:legend['bg_color']['a']=legend['border_color']['a']=0. + elif frameon is True and framealpha is None: + for key in ['bg_color','border_color']: + if legend[key]['a']==0:legend[key]['a']=self.figure._default_config['legend'][key]['a'] self.figure.apply_settings(state) + return Legend(self) def figure(*args, **kwargs): diff --git a/python/figgyplot/pyplot.py b/python/figgyplot/pyplot.py index fa3b574..5043adc 100644 --- a/python/figgyplot/pyplot.py +++ b/python/figgyplot/pyplot.py @@ -1,5 +1,6 @@ """A small stateful interface familiar to matplotlib.pyplot users.""" from .figure import Figure, subplots as _subplots +from ._style_runtime import use_style, style_context _current = None @@ -71,3 +72,6 @@ def axhspan(*args, **kwargs): return gca().axhspan(*args, **kwargs) def axvspan(*args, **kwargs): return gca().axvspan(*args, **kwargs) def arrow(*args, **kwargs): return gca().arrow(*args, **kwargs) def twinx(*args, **kwargs): return gca().twinx(*args, **kwargs) +def tick_params(*args, **kwargs): return gca().tick_params(*args, **kwargs) +def annotate(*args, **kwargs): return gca().annotate(*args, **kwargs) +def colorbar(mappable, **kwargs): return gcf().colorbar(mappable, **kwargs) diff --git a/python/figgyplot/review-feedback-v1.schema.json b/python/figgyplot/review-feedback-v1.schema.json index 41445e2..93c9a29 100644 --- a/python/figgyplot/review-feedback-v1.schema.json +++ b/python/figgyplot/review-feedback-v1.schema.json @@ -39,6 +39,12 @@ "work_id": { "type": "string" }, + "intent": { + "enum": [ + "revise", + "finish" + ] + }, "source": { "type": "object" }, @@ -95,7 +101,8 @@ "channel": { "enum": [ "api", - "ui" + "ui", + "ui_target" ] }, "text": { @@ -104,7 +111,13 @@ "id": { "type": "string" }, - "target": {} + "target": {}, + "attachments": { + "type": "array", + "items": { + "$ref": "#/$defs/image_attachment" + } + } } } }, @@ -244,6 +257,12 @@ "type": "string", "minLength": 1 }, + "intent": { + "enum": [ + "revise", + "finish" + ] + }, "manifest": { "type": "string", "minLength": 1 @@ -252,6 +271,595 @@ "type": "object" } } + }, + "preview": { + "type": "object", + "required": [ + "value", + "truncated" + ], + "properties": { + "value": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "truncated": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "summary_property": { + "type": "object", + "required": [ + "path", + "kind", + "before_exists", + "after_exists", + "before", + "after", + "operation_ids" + ], + "properties": { + "path": { + "type": "string" + }, + "kind": { + "enum": [ + "rich_text", + "add", + "remove", + "replace", + "move" + ] + }, + "before_exists": { + "type": "boolean" + }, + "after_exists": { + "type": "boolean" + }, + "before": { + "$ref": "#/$defs/preview" + }, + "after": { + "$ref": "#/$defs/preview" + }, + "operation_ids": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + }, + "text_changed": { + "type": "boolean" + }, + "formatting_changed": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "detail_selector": { + "type": "object", + "required": [ + "figure_id" + ], + "properties": { + "figure_id": { + "type": "string" + }, + "target": { + "type": "string" + } + }, + "additionalProperties": false + }, + "summary_object": { + "type": "object", + "required": [ + "target", + "label", + "scope", + "change_count", + "properties", + "comments", + "details", + "label_truncated" + ], + "properties": { + "target": { + "type": "string" + }, + "label": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "change_count": { + "type": "integer", + "minimum": 0 + }, + "properties": { + "type": "array", + "items": { + "$ref": "#/$defs/summary_property" + } + }, + "comments": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "text", + "target", + "channel" + ], + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "target": { + "type": [ + "string", + "null" + ] + }, + "channel": { + "type": "string" + }, + "label": { + "type": "string" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/$defs/image_attachment" + } + } + } + } + }, + "details": { + "$ref": "#/$defs/detail_selector" + }, + "label_truncated": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "detail_object": { + "type": "object", + "required": [ + "target", + "label", + "scope", + "change_count", + "properties", + "comments", + "details", + "snapshots", + "label_truncated" + ], + "properties": { + "target": { + "type": "string" + }, + "label": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "change_count": { + "type": "integer", + "minimum": 0 + }, + "properties": { + "type": "array", + "items": { + "$ref": "#/$defs/summary_property" + } + }, + "comments": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "text", + "target", + "channel" + ], + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "target": { + "type": [ + "string", + "null" + ] + }, + "channel": { + "type": "string" + }, + "label": { + "type": "string" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/$defs/image_attachment" + } + } + } + } + }, + "details": { + "$ref": "#/$defs/detail_selector" + }, + "snapshots": { + "type": "object", + "required": [ + "before", + "after" + ], + "properties": { + "before": { + "type": "object" + }, + "after": { + "type": "object" + } + }, + "additionalProperties": false + }, + "label_truncated": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "summary_figure": { + "type": "object", + "required": [ + "id", + "disposition", + "intent", + "change_count", + "comment_count", + "comments", + "objects", + "details", + "data_changed" + ], + "properties": { + "id": { + "type": "string" + }, + "disposition": { + "enum": [ + "accept", + "request_changes", + "skip" + ] + }, + "intent": { + "enum": [ + "revise", + "finish" + ] + }, + "change_count": { + "type": "integer", + "minimum": 0 + }, + "comment_count": { + "type": "integer", + "minimum": 0 + }, + "comments": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "text", + "target", + "channel" + ], + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "target": { + "type": [ + "string", + "null" + ] + }, + "channel": { + "type": "string" + }, + "label": { + "type": "string" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/$defs/image_attachment" + } + } + } + } + }, + "objects": { + "type": "array", + "items": { + "$ref": "#/$defs/summary_object" + } + }, + "details": { + "$ref": "#/$defs/detail_selector" + }, + "data_changed": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "summary": { + "type": "object", + "required": [ + "protocol", + "view", + "session_id", + "round_id", + "work_id", + "intent", + "source", + "figures", + "counts" + ], + "properties": { + "protocol": { + "const": 1 + }, + "view": { + "const": "summary" + }, + "session_id": { + "type": "string" + }, + "round_id": { + "type": "string" + }, + "work_id": { + "type": "string" + }, + "intent": { + "enum": [ + "revise", + "finish" + ] + }, + "source": { + "type": "object" + }, + "figures": { + "type": "array", + "items": { + "$ref": "#/$defs/summary_figure" + } + }, + "counts": { + "type": "object", + "required": [ + "figures", + "objects", + "changes", + "comments" + ], + "properties": { + "figures": { + "type": "integer", + "minimum": 0 + }, + "objects": { + "type": "integer", + "minimum": 0 + }, + "changes": { + "type": "integer", + "minimum": 0 + }, + "comments": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "details": { + "type": "object", + "required": [ + "protocol", + "view", + "session_id", + "round_id", + "work_id", + "figure_id", + "comments", + "objects", + "selection", + "changes" + ], + "properties": { + "protocol": { + "const": 1 + }, + "view": { + "const": "details" + }, + "session_id": { + "type": "string" + }, + "round_id": { + "type": "string" + }, + "work_id": { + "type": "string" + }, + "figure_id": { + "type": "string" + }, + "comments": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "text", + "target", + "channel" + ], + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "target": { + "type": [ + "string", + "null" + ] + }, + "channel": { + "type": "string" + }, + "label": { + "type": "string" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/$defs/image_attachment" + } + } + } + } + }, + "objects": { + "type": "array", + "items": { + "$ref": "#/$defs/detail_object" + } + }, + "selection": { + "type": "object", + "required": [ + "target", + "change_id" + ], + "properties": { + "target": { + "type": [ + "string", + "null" + ] + }, + "change_id": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "changes": { + "type": "array", + "items": { + "$ref": "#/$defs/change" + } + } + }, + "additionalProperties": false + }, + "image_attachment": { + "type": "object", + "required": [ + "id", + "name", + "file", + "sha256", + "mime_type", + "bytes", + "width", + "height" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "name": { + "type": "string" + }, + "file": { + "type": "string", + "pattern": "^attachments/[0-9a-f]{64}\\.png$" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "mime_type": { + "const": "image/png" + }, + "bytes": { + "type": "integer", + "minimum": 1 + }, + "width": { + "type": "integer", + "minimum": 1, + "maximum": 8192 + }, + "height": { + "type": "integer", + "minimum": 1, + "maximum": 8192 + }, + "path": { + "type": "string" + } + }, + "additionalProperties": false } } } diff --git a/python/figgyplot/review.py b/python/figgyplot/review.py index 48eaa48..9cdb58a 100644 --- a/python/figgyplot/review.py +++ b/python/figgyplot/review.py @@ -14,6 +14,7 @@ import threading import time import uuid +import base64 import numpy as np from ._review_diff import changes, persistent, preserved @@ -28,9 +29,30 @@ def _ui_comment(doc): return next((c['text'] for c in doc['comments'] if c.get('channel')=='ui'),'') -def _replace_ui_comment(doc, text): +def _replace_ui_comment(doc, text, attachments=None): + previous=next((c for c in doc['comments'] if c.get('channel')=='ui'),{}) + images=previous.get('attachments',[]) if attachments is None else attachments doc['comments']=[c for c in doc['comments'] if c.get('channel')!='ui'] - if text:doc['comments'].append(dict(id=_id(),text=text,target=None,channel='ui')) + if text or images: + comment=dict(id=previous.get('id') or _id(),text=text,target=None,channel='ui') + if images:comment['attachments']=images + doc['comments'].append(comment) + + +def _same_approved_content(previous, current): + """Approval covers exact persistent settings AND every source payload.""" + approval=previous['approval'] + return (approval is not None + and approval['persistent_hash']==current['baseline_hash'] + and previous['columns']==current['baseline_columns'] + and previous.get('bounded_data',{})==current.get('baseline_bounded_data',{}) + and previous['dpi']==current['dpi']) + + +def _carry_approval(previous, current, round_id): + current['approval']=dict(previous['approval'],revision=current['baseline_revision'], + settings=copy.deepcopy(current['baseline']),instance_id=current['instance_id'], + changes=[],comment_text='',carried_from=dict(round_id=round_id,approval_id=previous['approval']['id'])) def _source(value): @@ -108,7 +130,12 @@ class Session: """ protocol = 1 - def __init__(self, cache_dir=None, *, source=None, backend='native'): + def __init__(self, cache_dir=None, *, source=None, backend='native', review_font=None, review_font_size=14): + if review_font is not None: + if not isinstance(review_font,str) or not review_font.strip():raise ValueError('review_font must name an installed font family') + from ._review_font import font_data + font_data(review_font) + if not isinstance(review_font_size,(int,float)) or not 8<=review_font_size<=72:raise ValueError('review_font_size must be between 8 and 72') cache=Path(cache_dir) if cache_dir is not None else Path(tempfile.mkdtemp(prefix='figgyplot-review-')) if cache.exists() and (not cache.is_dir() or any(cache.iterdir())): raise ValueError('Use a dedicated empty review cache directory; use Session.open() for an existing cache') @@ -125,6 +152,7 @@ def __init__(self, cache_dir=None, *, source=None, backend='native'): if backend not in ('native','webgpu'): raise ValueError('Review backend must be native or webgpu') state = dict(protocol=1, session_id=_id(), generation=0, completed_work={}, + review_style=dict(font_family=review_font,font_size=review_font_size), rounds=[dict(id=_id(),number=1,status='building',source=_source(source), backend=backend,documents={},work_id=None)]) self._store.write(state) @@ -145,6 +173,7 @@ def open(cls, cache_dir): for r in state['rounds']: for doc in r['documents'].values(): for ref in session._store.references(doc):session._store.get(ref) + for ref in session._store.image_references(doc):session._store.get_image(ref) session._store.collect(state) return session @@ -264,6 +293,10 @@ def present(self): if threading.current_thread() is not threading.main_thread(): raise RuntimeError('Review presentation runs on the main thread') state=self.snapshot();r=state['rounds'][-1] + family=state.get('review_style',{}).get('font_family') + if family: + from ._review_font import font_data + font_data(family) prepared=[] try: for fid,doc in r['documents'].items(): @@ -276,7 +309,7 @@ def present(self): old=self._handles.get(fig.id) if old is None or old.closed: handle=fig.show(block=False) - handle.configure_review(json.dumps(dict(figure_id=fig.id,comments=self._comment_text(fig.id)))) + handle.configure_review(json.dumps(self._review_state(fig.id))) self._handles[fig.id]=handle else: from IPython.display import display @@ -294,20 +327,128 @@ def widget(self, figure): if figure.backend!='webgpu':raise ValueError('Notebook review requires backend=webgpu') if figure.id not in self._figures or self._figures[figure.id] is not figure: raise ValueError('Figure does not belong to this review session') - widget=figure.widget();widget.review=dict(enabled=True,figure_id=figure.id,comments=self._comment_text(figure.id)) + widget=figure.widget();review=self._review_state(figure.id) + images=review['attachments']+[image for c in review['target_comments'] for image in c.get('attachments',[])] + review['image_previews']={image['id']:'data:image/png;base64,'+base64.b64encode(self._store.get_image(image)).decode('ascii') for image in images} + family=review.get('font_family') + if family: + from ._review_font import font_data + widget.review_font_data=font_data(family) + widget.review=review self._widgets[figure.id]=widget return widget + def _review_state(self, figure_id): + state=self.snapshot();doc=state['rounds'][-1]['documents'][figure_id] + return dict(enabled=True,figure_id=figure_id,comments=_ui_comment(doc), + attachments=next((c.get('attachments',[]) for c in doc['comments'] if c.get('channel')=='ui'),[]), + cache_dir=str(self.cache_dir), + target_comments=[c for c in doc['comments'] if c.get('channel')=='ui_target'], + **state.get('review_style',{})) + + def _set_target_comments(self, figure_id, comments): + if not isinstance(comments,list):raise ValueError('Target comments must be a list') + ids=set() + for item in comments: + if not isinstance(item,dict) or set(item)-{'id','text','target','channel','label','attachments'} or not all(isinstance(item.get(k),str) and item[k].strip() for k in ('id','target')) or not isinstance(item.get('text'),str) or not (item['text'].strip() or item.get('attachments')) or item['id'] in ids or ('label' in item and not isinstance(item['label'],str)): + raise ValueError('Target comments require unique IDs, nonempty text and a stable target') + ids.add(item['id']) + with self._store.locked(): + state=self._store.read();doc=self._mutable(state,figure_id) + normalized=[] + for c in comments: + value=dict(c,channel='ui_target') + if 'attachments' in c:value['attachments']=self._normalize_images(c['attachments']) + normalized.append(value) + updated=[c for c in doc['comments'] if c.get('channel')!='ui_target']+normalized + if updated!=doc['comments']:doc['comments']=updated;self._store.write(state) + + def subscribe(self, *, after=None, timeout=60): + """Wait for one complete durable batch; does not pump UI or invoke a model.""" + if not isinstance(timeout,(int,float)) or not 0<=timeout<=3600:raise ValueError('timeout must be between 0 and 3600 seconds') + deadline=time.monotonic()+timeout + while True: + events=[event for event in self.events() if event['work_id']!=after] + if events:return events[0] + if self.status['state'] in ('completed','cancelled'):return None + remaining=deadline-time.monotonic() + if remaining<=0:return None + time.sleep(min(.05,remaining)) + + def connect(self, *, after=None, timeout=None): + """Present native review and wait for its feedback in the same process. + + The agent keeps this call pending. A separate native UI controller and + subscriber are unnecessary; ordinary present/run_events remain available. + """ + if timeout is not None and (not isinstance(timeout,(int,float)) or not 0<=timeout<=3600): + raise ValueError('timeout must be between 0 and 3600 seconds or None') + current=self.snapshot()['rounds'][-1] + if current['backend']!='native': + raise ValueError('connect() hosts native windows; use watch() and subscribe() for notebook review') + if current['status']=='building' and any(fid not in self._figures for fid in current['documents']): + raise RuntimeError('Call session.start() in the builder process before connecting to its cache') + self.present() + deadline=None if timeout is None else time.monotonic()+timeout + while True: + self.poll() + events=[event for event in self.events() if event['work_id']!=after] + if events:return events[0] + if self.status['state'] in ('completed','cancelled'):return None + if deadline is not None and time.monotonic()>=deadline:return None + time.sleep(.02) + def _comment_text(self, figure_id): return _ui_comment(self.snapshot()['rounds'][-1]['documents'][figure_id]) - def comment(self, figure_id, text, *, target=None): + def comment(self, figure_id, text, *, target=None, attachments=()): if not isinstance(text,str):raise ValueError('Review comment must be text') with self._store.locked(): state=self._store.read();doc=self._mutable(state,figure_id) - doc['comments'].append(dict(id=_id(),text=text,target=target,channel='api')) + comment=dict(id=_id(),text=text,target=target,channel='api') + if attachments:comment['attachments']=self._normalize_images(list(attachments)) + doc['comments'].append(comment) self._store.write(state) + def _normalize_images(self, images): + if not isinstance(images,list):raise ValueError('Review attachments must be a list') + result=[] + for item in images: + if isinstance(item,(str,Path)): + path=Path(item) + if path.stat().st_size>20*1024*1024:raise ValueError('Review images must be at most 20 MiB') + ref=self._store.put_image(path.read_bytes(),path.name) + elif isinstance(item,bytes):ref=self._store.put_image(item) + elif isinstance(item,dict) and 'data' in item: + data=item['data'] + if not isinstance(data,str) or len(data)>28*1024*1024:raise ValueError('Review image payload is too large or invalid') + ref=self._store.put_image(base64.b64decode(data,validate=True),item.get('name','Review image.png')) + elif isinstance(item,dict): + ref={k:item[k] for k in ('id','name','file','sha256','bytes','width','height','mime_type') if k in item} + self._store.verify_image(ref) + else:raise ValueError('Review attachments must be image files, bytes or image references') + if not any(v['id']==ref['id'] for v in result):result.append(ref) + return result + + def attach_image(self, figure_id, image, *, name='Review image.png'): + """Import an image for a review comment; returns a durable PNG reference.""" + with self._store.locked(): + state=self._store.read();doc=self._mutable(state,figure_id) + item=dict(data=base64.b64encode(image).decode('ascii'),name=name) if isinstance(image,bytes) else image + ref,=self._normalize_images([item]) + assets=doc.setdefault('image_assets',[]) + if not any(v['id']==ref['id'] for v in assets):assets.append(ref) + self._store.write(state) + return copy.deepcopy(ref) + + def _set_comment_images(self, figure_id, images): + with self._store.locked(): + state=self._store.read();doc=self._mutable(state,figure_id) + previous=next((c.get('attachments',[]) for c in doc['comments'] if c.get('channel')=='ui'),[]) + normalized=self._normalize_images(images) + if previous!=normalized: + _replace_ui_comment(doc,_ui_comment(doc),normalized);self._store.write(state) + def _set_comment_text(self, figure_id, text): if not isinstance(text,str):raise ValueError('Review comments must be text') with self._store.locked(): @@ -317,7 +458,7 @@ def _set_comment_text(self, figure_id, text): _replace_ui_comment(doc,text) self._store.write(state) - def submit(self, figure_id, disposition, *, comments=None, expected_revision=None): + def submit(self, figure_id, disposition, *, comments=None, expected_revision=None, intent='revise'): """Durably approve accepted state, then close views; never approve on close.""" if disposition not in ('accept','request_changes','skip'): raise ValueError('Disposition must be accept, request_changes or skip') @@ -325,30 +466,32 @@ def submit(self, figure_id, disposition, *, comments=None, expected_revision=Non if fig is None:raise ValueError('Restore the Figure before submitting it') if fig.backend=='webgpu' and any(not w._closed for w in fig._widgets): raise RuntimeError('Use await session.submit_async() to flush notebook edits') - return self._submit(figure_id,disposition,comments,expected_revision) + return self._submit(figure_id,disposition,comments,expected_revision,intent) - async def submit_async(self, figure_id, disposition, *, comments=None, expected_revision=None): + async def submit_async(self, figure_id, disposition, *, comments=None, expected_revision=None, intent='revise'): fig=self._figures[figure_id] if fig.backend=='webgpu':await fig.sync() - return self._submit(figure_id,disposition,comments,expected_revision) + return self._submit(figure_id,disposition,comments,expected_revision,intent) - def _submit(self, figure_id, disposition, comments, expected_revision): + def _submit(self, figure_id, disposition, comments, expected_revision, intent='revise'): if disposition not in ('accept','request_changes','skip'): raise ValueError('Unknown review disposition') + if intent not in ('revise','finish') or (intent=='finish' and disposition!='accept'): + raise ValueError('Finish intent requires an accepted Figure') if comments is not None and not isinstance(comments,str):raise ValueError('Review comments must be text') fig=self._figures[figure_id] with fig._lock, self._store.locked(): - approval=self._commit_approval(figure_id,disposition,comments,expected_revision) + approval=self._commit_approval(figure_id,disposition,comments,expected_revision,intent) # Closing a view may call observers that read the session. Release the # manifest lock before closing, on both first delivery and retries. self._close_views(fig,disposition) return copy.deepcopy(approval) - def _commit_approval(self, figure_id, disposition, comments, expected_revision): + def _commit_approval(self, figure_id, disposition, comments, expected_revision, intent='revise'): state=self._store.read();r=state['rounds'][-1];doc=r['documents'][figure_id] # Repeated delivery of the same approval never creates another work ID. if doc['approval'] is not None: - if doc['approval']['disposition']!=disposition or (comments is not None and doc['approval']['comment_text']!=comments): + if doc['approval']['disposition']!=disposition or doc['approval'].get('intent','revise')!=intent or (comments is not None and doc['approval']['comment_text']!=comments): raise ValueError('Figure already approved with different feedback') self._store.write(state) return doc['approval'] @@ -370,13 +513,16 @@ def _commit_approval(self, figure_id, disposition, comments, expected_revision): if comments is not None: _replace_ui_comment(doc,comments) for ref in self._store.references(doc):self._store.get(ref) - approval=dict(id=_id(),disposition=disposition,revision=revision,settings=approved, + approval=dict(id=_id(),disposition=disposition,intent=intent,revision=revision,settings=approved, instance_id=doc['instance_id'], persistent_hash=digest(persistent(approved)),changes=changes(doc['baseline'],approved), comment_text=_ui_comment(doc)) doc.update(approval=approval,draft=approved,draft_revision=revision) if all(d['approval'] is not None for d in r['documents'].values()): needs_work=any(d['approval']['disposition']=='request_changes' or d['approval']['changes'] or d['comments'] for d in r['documents'].values()) + finishing=any(d['approval'].get('intent')=='finish' for d in r['documents'].values()) and not any(d['approval']['disposition']=='request_changes' for d in r['documents'].values()) + r['intent']='finish' if finishing else 'revise' + needs_work=needs_work or finishing r.update(status='ready' if needs_work else 'completed',work_id=_id() if needs_work else None) self._store.write(state) self._store.collect(state) @@ -391,6 +537,7 @@ def _close_views(self, fig, disposition): def events(self): state=self.snapshot() return [dict(protocol=1,session_id=state['session_id'],round_id=r['id'],work_id=r['work_id'], + intent=r.get('intent','revise'), manifest=str(self.cache_dir/'manifest.json'),source=r['source']) for r in state['rounds'] if r['status']=='ready'] @@ -398,7 +545,7 @@ def events(self): def status(self): r=self.snapshot()['rounds'][-1] return dict(round=r['number'],state=r['status'],work_id=r['work_id'], - pending=[fid for fid,doc in r['documents'].items() if doc['approval'] is None], + pending=[] if r['status'] in ('completed','cancelled') else [fid for fid,doc in r['documents'].items() if doc['approval'] is None], error=r.get('error')) def feedback(self, work_id): @@ -407,6 +554,27 @@ def feedback(self, work_id): if r is None:raise ValueError('Stale or unknown review work ID') return dict(protocol=1,session_id=state['session_id'],round=copy.deepcopy(r)) + def feedback_summary(self, work_id): + """Readable object groups linked to every exact snapshot-diff operation.""" + from ._review_summary import summary + return self._image_paths(summary(self.feedback(work_id))) + + def feedback_details(self, work_id, figure_id, *, target=None, change_id=None): + """Inspect exact changes and object snapshots without another render.""" + from ._review_summary import details + return self._image_paths(details(self.feedback(work_id),figure_id,target=target,change_id=change_id)) + + def _image_paths(self, value): + def visit(item): + if isinstance(item,dict): + for image in item.get('attachments',[]):image['path']=str(self.cache_dir/image['file']) + for key,child in item.items(): + if key!='attachments':visit(child) + elif isinstance(item,list): + for child in item:visit(child) + visit(value) + return value + def claim(self, work_id, *, worker_id=None, seconds=1800): """Lease one staging writer before an agent edits source for this work.""" from .figure import _timeout @@ -477,6 +645,40 @@ def cancel(self): self._store.write(state) self.close() + def finish(self, work_id, *, claim_token=None, timeout=180): + """Save the accepted review and finish without opening another review. + + Recorded builders are executed and rendered once to verify the source + still reproduces the exact accepted settings and arrays. Manual sessions + without source retain their complete approved binary checkpoint. + """ + state=self.snapshot() + if work_id in state['completed_work']: + if work_id not in state.get('finished_work',{}): + raise ValueError('Work already regenerated; finish the current review round instead') + return state['finished_work'][work_id] + feedback=self.feedback(work_id) + if feedback['round']['source'].get('entrypoint'): + number=self.regenerate(work_id,claim_token=claim_token,timeout=timeout,_finish=True) + else: + if claim_token is None:claim_token=self.claim(work_id)['token'] + with self._store.locked(): + state=self._store.read();r=state['rounds'][-1] + if work_id in state['completed_work']: + if work_id not in state.get('finished_work',{}):raise ValueError('Work already regenerated') + return state['finished_work'][work_id] + if r['status']!='ready' or r['work_id']!=work_id:raise ValueError('Stale or unknown review work ID') + if r.get('claim',{}).get('token')!=claim_token or r['claim']['expires']<=time.time(): + raise ValueError('Source writer lease is invalid or expired') + for doc in r['documents'].values(): + for ref in self._store.references(doc):self._store.get(ref) + r.update(status='completed',intent='finish') + number=r['number'];state['completed_work'][work_id]=number + state.setdefault('finished_work',{})[work_id]=number + self._store.write(state);self._store.collect(state) + self.close() + return number + def poll(self): """Pump native input and durable submissions; call only on the main thread.""" from .desktop import poll_events @@ -489,7 +691,9 @@ def poll(self): request=json.loads(handle.review_request_json()) if request is None:continue try: - self.submit(fid,request['disposition'],comments=request['comments'],expected_revision=request['revision']) + self._set_target_comments(fid,request.get('target_comments',[])) + self._set_comment_images(fid,request.get('attachments',[])) + self.submit(fid,request['disposition'],comments=request['comments'],expected_revision=request['revision'],intent=request.get('intent','revise')) except Exception as exc: handle.review_result(str(exc)) failed=True @@ -505,9 +709,29 @@ def poll(self): doc.update(draft=fig.get_settings(),draft_revision=fig.revision);changed=True handle=self._handles.get(fid) if doc and doc['approval'] is None and handle is not None: - text=json.loads(handle.info_json())['review'].get('comments','') - if text!=_ui_comment(doc): - _replace_ui_comment(doc,text);changed=True + draft=json.loads(handle.review_draft_json()) + text=draft.get('comments') or '' + targeted=draft.get('target_comments') or [] + previous=[v for v in doc['comments'] if v.get('channel')=='ui_target'] + raw_images=draft.get('attachments') or [] + old_images=next((c.get('attachments',[]) for c in doc['comments'] if c.get('channel')=='ui'),[]) + try: + normalized=raw_images if raw_images==old_images else self._normalize_images(raw_images) + normalized_targets=previous + if targeted!=previous: + normalized_targets=[] + for item in targeted: + value=dict(item,channel='ui_target') + if 'attachments' in item:value['attachments']=self._normalize_images(item['attachments']) + normalized_targets.append(value) + if normalized_targets!=previous: + doc['comments']=[v for v in doc['comments'] if v.get('channel')!='ui_target']+normalized_targets;changed=True + if text!=_ui_comment(doc) or normalized!=old_images: + _replace_ui_comment(doc,text,normalized);changed=True + if normalized!=raw_images or normalized_targets!=targeted: + handle.review_images_result(json.dumps(dict(attachments=normalized,target_comments=normalized_targets))) + except (ValueError,OSError) as exc: + handle.review_result(str(exc)) if changed:self._store.write(state) def run_events(self, *, on_status=None): @@ -531,15 +755,19 @@ async def watch(self): self.close();self.present() await asyncio.sleep(.1) - def regenerate(self, work_id, *, superseded=None, claim_token=None, present=False, timeout=180): + def regenerate(self, work_id, *, superseded=None, claim_token=None, present=False, timeout=180, _finish=False): """Run updated source, render every result, reconcile edits, publish once.""" superseded=dict(superseded or {}) if any(not isinstance(reason,str) or not reason.strip() for reason in superseded.values()): raise ValueError('Superseded edits/Figures require non-empty reasons') state=self.snapshot() - if work_id in state['completed_work']:return state['completed_work'][work_id] + if work_id in state['completed_work']: + if _finish and work_id not in state.get('finished_work',{}):raise ValueError('Work already regenerated') + return state['completed_work'][work_id] if claim_token is None:claim_token=self.claim(work_id)['token'] feedback=self.feedback(work_id);old=feedback['round'];source=_source(old['source']) + if old.get('intent')=='finish' and not _finish: + raise ValueError('This is a finish request; use finish() rather than creating another review round') if old.get('claim',{}).get('token')!=claim_token or old['claim']['expires']<=time.time(): raise ValueError('Source writer lease is invalid or expired') known={c['id'] for doc in old['documents'].values() for c in doc['approval']['changes']}|{'figure:'+fid for fid in old['documents']} @@ -564,10 +792,23 @@ def regenerate(self, work_id, *, superseded=None, claim_token=None, present=Fals for change in doc['approval']['changes']: if change['id'] not in superseded and not preserved(change,current['documents'][fid]['baseline']): raise ValueError('Source did not reproduce approved edit: '+fid+' '+change['path']) + successor=current['documents'][fid] + same=_same_approved_content(doc,successor) + if _finish and not same: + raise ValueError('Finish must preserve the exact approved settings and data: '+fid) + if same and (doc['approval']['disposition']=='accept' or _finish): + _carry_approval(doc,successor,old['id']) + if _finish:successor['approval'].update(disposition='accept',intent='finish') + if _finish and set(current['documents'])!=set(old['documents']): + raise ValueError('Finish cannot add or remove Figures') + if _finish or all(doc['approval'] is not None for doc in current['documents'].values()): + current['status']='completed' if _source(source)['digest']!=source['digest']:raise ValueError('Source changed during candidate validation') with self._store.locked(): state=self._store.read() - if work_id in state['completed_work']:return state['completed_work'][work_id] + if work_id in state['completed_work']: + if _finish and work_id not in state.get('finished_work',{}):raise ValueError('Work already regenerated') + return state['completed_work'][work_id] active=state['rounds'][-1] if active['status']!='ready' or active['work_id']!=work_id: raise ValueError('Stale round; candidate was not published') @@ -580,6 +821,7 @@ def regenerate(self, work_id, *, superseded=None, claim_token=None, present=Fals current.update(number=active['number']+1,source=source,superseded=superseded) active['status']='completed';state['rounds'].append(current) state['completed_work'][work_id]=current['number'] + if _finish:state.setdefault('finished_work',{})[work_id]=current['number'] self._store.write(state);self._store.collect(state) number=current['number'] except Exception as exc: @@ -607,29 +849,49 @@ def __exit__(self,*args):self.close() def main(): parser=argparse.ArgumentParser(description=__doc__) - parser.add_argument('action',choices=['protocol','skill','status','events','feedback','claim','regenerate','show','cancel']) + parser.add_argument('action',choices=['protocol','skill','status','events','subscribe','connect','feedback','claim','regenerate','finish','show','cancel']) parser.add_argument('cache',nargs='?') parser.add_argument('work',nargs='?') parser.add_argument('--superseded',help='JSON file mapping edit IDs or figure:ID to reasons') + parser.add_argument('--after',help='Last delivered work ID for feedback subscription') parser.add_argument('--worker',help='Stable agent/staging-workspace identifier for a writer lease') parser.add_argument('--claim-token',help='Writer lease token returned by claim') - parser.add_argument('--timeout',type=float,default=180,help='Maximum candidate execution/render seconds') + parser.add_argument('--timeout',type=float,help='Maximum wait or candidate execution/render seconds; connect waits indefinitely by default') + parser.add_argument('--summary',action='store_true',help='Object-grouped feedback summary with links to exact change IDs') + parser.add_argument('--figure',help='Figure ID for exact feedback details') + parser.add_argument('--target',help='Stable editor/comment object ID within --figure') + parser.add_argument('--change',help='Exact change ID within --figure') args=parser.parse_args() + if (args.summary or args.figure is not None or args.target is not None or args.change is not None) and args.action!='feedback': + parser.error('summary/figure/target/change options apply only to feedback') + if args.summary and (args.figure is not None or args.target is not None or args.change is not None): + parser.error('--summary cannot be combined with detail filters') + if (args.target is not None or args.change is not None) and args.figure is None: + parser.error('--target and --change require --figure') package=Path(__file__).parent if args.action=='protocol': - print(json.dumps(dict(protocol=1,skill=str(package/'skills/figgyplot-review/SKILL.md'),schema=str(package/'review-feedback-v1.schema.json'),event_schema=str(package/'review-feedback-v1.schema.json')+'#/$defs/events')));return + schema=str(package/'review-feedback-v1.schema.json') + print(json.dumps(dict(protocol=1,skill=str(package/'skills/figgyplot-review/SKILL.md'),schema=schema,event_schema=schema+'#/$defs/events',summary_schema=schema+'#/$defs/summary',details_schema=schema+'#/$defs/details')));return if args.action=='skill': print((package/'skills/figgyplot-review/SKILL.md').read_text(encoding='utf-8'));return if not args.cache:parser.error('cache is required for this action') with Session.open(args.cache) as session: if args.action=='status':result=session.status elif args.action=='events':result=session.events() - elif args.action=='feedback':result=session.feedback(args.work) + elif args.action=='subscribe':result=session.subscribe(after=args.after,timeout=args.timeout if args.timeout is not None else 60) + elif args.action=='connect':result=session.connect(after=args.after,timeout=args.timeout) + elif args.action=='feedback': + if args.summary:result=session.feedback_summary(args.work) + elif args.figure is not None:result=session.feedback_details(args.work,args.figure,target=args.target,change_id=args.change) + else:result=session.feedback(args.work) elif args.action=='claim':result=session.claim(args.work,worker_id=args.worker) elif args.action=='regenerate': if not args.claim_token:parser.error('regenerate requires --claim-token from claim') superseded=json.loads(Path(args.superseded).read_text()) if args.superseded else None - result=dict(round=session.regenerate(args.work,superseded=superseded,claim_token=args.claim_token,timeout=args.timeout)) + result=dict(round=session.regenerate(args.work,superseded=superseded,claim_token=args.claim_token,timeout=args.timeout if args.timeout is not None else 180)) + elif args.action=='finish': + if not args.claim_token:parser.error('finish requires --claim-token from claim') + result=dict(round=session.finish(args.work,claim_token=args.claim_token,timeout=args.timeout if args.timeout is not None else 180),finished=True) elif args.action=='show':session.present();session.run_events();result=session.events() else:session.cancel();result={'cancelled':True} print(json.dumps(result,ensure_ascii=False,allow_nan=False)) diff --git a/python/figgyplot/skills/figgyplot-review/SKILL.md b/python/figgyplot/skills/figgyplot-review/SKILL.md index ca9bf1d..181e390 100644 --- a/python/figgyplot/skills/figgyplot-review/SKILL.md +++ b/python/figgyplot/skills/figgyplot-review/SKILL.md @@ -10,31 +10,156 @@ This skill ships with figgyplot. It uses `python -m figgyplot.review` and protoc copy this directory into its skill search path; no particular agent vendor, model SDK or background service is required. Ordinary plotting remains usable without installing this skill. +Discover these paths once at startup and reuse the same installed Python +interpreter throughout the session. The commands and export recipe below use +public APIs; routine feedback handling does not require searching package source. -1. Obtain the session cache path from the user/controller. Poll `events CACHE`. - No event means no complete batch is ready. Do not work on individual Figure - approval before the whole round is submitted. -2. Read `feedback CACHE WORK_ID`. Use the baseline and approved settings, exact - change operations and stable selectors. Read natural-language review comments - separately. They are instructions, not chart annotations. Binary references +For ordinary source edits, prefer the Matplotlib-shaped object API: +`ax.set_title`, `ax.set_xlabel`, `ax.set_ylabel`, `ax.tick_params`, +`ax.spines[side]`, `ax.xaxis`/`ax.yaxis`, returned Text/Legend handles, and +`fig.artist(stable_id).set(...)`. Sizes use points; `scatter(s=...)` uses areas +in points squared for both scalars and arrays. Use `fig.batch_update()` for one +atomic group of existing style/layout edits; construct data outside that block. +Bounded charts expose named `ax.chart` controls for shared labels, materials, +series colors and category names. Check the installed supported API before +assuming that every Matplotlib option exists. + +Use the scoped appearance objects for the rest of the supported renderer surface: +`artist.markers`/`.errors`/`.bins` for per-element edits and typed palettes, +`.vectors` for arrow scaling/color mapping, `.contours` for levels and labels, +`artist.set_field_fill(...)` and `.set_grid(...)` for scalar fields, +`artist.legend_label` for one legend entry, `ax.legend_handle.content` for custom +rich keys, `ax.get_overlay(id)` for an existing secondary Y layer, and +`ax.get_colorbar()` for scale placement/formatting. `fig.data.column(name)` and +`fig.data.grid(...)` reference resident data without reuploading. Use +`MarkerStyle`, `ErrorBarStyle`, `BinStyle`, `ContourLabel` and `TextRun` rather +than serialized renderer variants. Mapped/explicit point-color precedence is +intentional: a final point override stays fixed while the colormap changes. + +Bounded charts expose `chart.get_style()`/`set_style`, `series(id)`, +`cell(category_id, series_id)`, `slice(index, detail=True)` for split children, +`get_series()`/`get_categories()` for stable ID discovery, +`set_value_labels`, `decoration(name, ...).set_offset(...)` and `set_plot_bounds`. +Annotation handles expose `set_style`, `set_bbox`, `set_endpoints`, `set_label`, +`set_value` and `set_bounds` as appropriate. Prefer these controls to raw +`configure` trees. The source recipe requires matching IDs, registered columns, +panel ownership, DPI and bounded data topology; it preserves scientific arrays. +Exact recovery remains the full SSOT oracle, including editor/identity records. + +For scalar coloring, keep the numeric values connected with `scatter(c=values, +cmap=...)`, `imshow`, or the corresponding mapped constructor, and edit the +returned mappable with `set_cmap`/`set_clim`. Do not precompute point-color +overrides and draw a disconnected colorbar as a substitute for a scalar map. +Explicit per-point colors are appropriate when the requested colors are the +data or deliberate individual overrides. + +`fig.to_code()` produces a readable appearance recipe and retains the +destination Figure identity. It is not the exact-checkpoint oracle for this +review protocol. `fig.to_code(mode="exact")` and the accepted checkpoint retain +complete SSOT identities, tick policies and renderer-only state. Reconcile +review details against those exact values; do not waive a retained edit because +an appearance recipe looks similar. Use advanced SSOT patches only for a +documented capability without a public setter, and preserve the recorded data +and stable object ownership. + +1. Initialize the controller with a review font that supports the user's primary + language. Inspect installed fonts and glyph coverage; do not assume the default + Latin UI face can display the user's text. For Korean, an installed + `Noto Sans CJK KR` is one suitable choice. Pass the actual family name through + `Session(..., review_font="Noto Sans CJK KR", review_font_size=14)` before + presenting any window/widget. Use the user's language for review text. The + chosen face is loaded into native review inputs and transferred to notebook + browsers; an unavailable family is an initialization error. Do not replace + document typography merely to configure the review UI. +2. Connect feedback before asking the user to review. For a native session, + seal the builder's complete Figure set with `session.start()` before handing + its cache to a separate command, then + use `python -m figgyplot.review connect CACHE` as the default entry point. + Keep that execution-tool call pending: it hosts the native windows and returns + the complete feedback signal, so the user need not start a separate subscriber + or controller. It waits indefinitely by default; `--timeout SECONDS` is optional. + After regeneration, call `connect CACHE --after WORK_ID` again immediately. + For an already running controller, or notebook review with `watch()`, subscribe using + `python -m figgyplot.review subscribe CACHE --timeout 50`. After a delivered + work ID, use `--after WORK_ID` on later subscriptions. Keep the execution-tool + call pending so completion of the human review returns the feedback signal + directly to the agent. A timeout or finished session returns `null`; check + `status CACHE`, stop for `completed`/`cancelled`, otherwise subscribe again + without treating it as approval. `events CACHE` remains available for manual lookup. + No event means no complete batch is ready. Individual Figure approval does + not start source modification: the final disposition publishes the whole + batch and wakes the subscription. Do not ask the user to copy feedback back + into the conversation. +3. Start with `feedback CACHE WORK_ID --summary`. This groups the final difference + between the opening and submitted SSOT snapshots by the same stable object + IDs used by element comments. It is a derived view, not a replacement for the + exact changes. It lists every Figure, and groups objects with changes or + comments. Read all Figure-level and object-level comments. Accepted Figures + can still contain human edits that must be reproduced in source. + If `data_changed` is true, inspect the full Figure checkpoint's binary + references as well; equal axis bounds do not imply equal source arrays. + For an object's exact operation list and before/after snapshots, use + `feedback CACHE WORK_ID --figure FIGURE_ID --target OBJECT_ID`. + Every summary property's `operation_ids` links to the original operations; + inspect one with `--figure FIGURE_ID --change CHANGE_ID`. Keep IDs unchanged. + The summary/detail object `target` is the editor/comment address. Raw diff + `object_id` is a legacy grouping hint; use the explicit `target` for queries. + Rich text character/style operations appear as one summary property. Read + `formatting_changed` even when its displayed text is identical. A preview with + `truncated: true` requires exact detail before reproducing that value. Unknown + or shared ownership remains at Figure/panel scope; do not guess another target. + `feedback CACHE WORK_ID` still returns the complete protocol-1 checkpoint + when recovery or complete-state inspection requires it. Avoid dumping the + full manifest or unchanged snapshots into the model context routinely. + Read natural-language review comments + separately, including right-click element comments addressed by stable object + ID. They are instructions, not chart annotations. Binary references are recovery/input files; do not paste raw arrays into model messages. -3. Before editing source, run `claim CACHE WORK_ID --worker YOUR_STABLE_ID`. + Comments may also contain `attachments`: reference images with a name, PNG + dimensions, SHA-256 digest and file location. Summary/detail queries provide + each image's absolute `path`; raw feedback has a `file` relative to the cache + beside the event's manifest. Open these files with the agent's image-viewing + tool and interpret them together with the owning comment and stable target. + Do not infer an image's contents from its filename, paste base64 into context, + or treat a reference image as a chart SSOT change. An image-only comment still + requires inspection. If image viewing is unavailable, report that limitation + instead of claiming to have reviewed it. Images remain recoverable while their + review revision is retained; consume them before publishing its successor. + Check the event's `intent` before classifying any comment as a plot edit. + `finish` requests saving and termination, not another review iteration. + If the complete batch only asks to save/close/finish, route it to the same + finish action even when submitted through the older Request changes button. + A finish request on one Figure does not override another Figure's request + for chart changes; resolve that complete batch before terminating. + Do not infer a plot modification merely because a comment is nonempty. +4. Before editing source, run `claim CACHE WORK_ID --worker YOUR_STABLE_ID`. Keep its token and use one staging workspace/source writer for this session. If another worker holds the lease, wait or resume that worker. Reclaim expired leases after disconnect; never overwrite another active worker's source. -4. Inspect the recorded entry point/function, arguments, inputs and source digest. +5. Inspect the recorded entry point/function, arguments, inputs and source digest. Preserve stable Figure IDs. Update runnable source to retain each exact human edit and satisfy the comments. Source builders use `build(session, **arguments)` and `session.figure(id=...)`; they do not start an agent or approve themselves. -5. Run `regenerate CACHE WORK_ID --claim-token TOKEN`. This executes the builder, + For finish-only feedback, save requested exports from the accepted checkpoint + using the [PNG export recipe](#export-approved-checkpoints-as-png), including + approved Figures that no longer have an open review window. Reconcile any + exact human edits into source. Then run + `finish CACHE WORK_ID --claim-token TOKEN`. A recorded builder is rendered and + checked against the exact accepted settings and binary data, without opening + new review windows. This terminal validation can increment the round number; + use `status.state == "completed"`, not the round number, to confirm completion. + Do not call ordinary `regenerate`, `connect`, or `show` for an explicit finish + request. Verify terminal `status`, then stop subscribing. +6. For a batch requiring chart revisions, run + `regenerate CACHE WORK_ID --claim-token TOKEN`. This executes the builder, renders every candidate, checks retained edits and source stability, and atomically publishes one complete successor. The live controller displays it after publication. Renew the lease with the same worker ID for long work. -6. Only explicitly supersede an approved edit when feedback requires it. Supply +7. Only explicitly supersede an approved edit when feedback requires it. Supply `--superseded FILE` containing a JSON map from the operation's `id` (or `figure:ID` for a removed Figure) to a concrete reason. Never waive all edits merely to make validation pass. -7. On execution/render/stale-base failure, inspect the error and correct source. +8. On execution/render/stale-base failure, inspect the error and correct source. The previous checkpoint and pending work remain recoverable. Do not delete manifests or binary files. Repeated events and completion of the same work ID are idempotent. Application code owns revision-driven cleanup. @@ -44,3 +169,66 @@ live widgets with `await session.watch()` in Jupyter. Agent regeneration belongs outside that UI loop. `show CACHE` can resume a disconnected native controller. Manual window closure is not approval. `accept` without edits/comments finishes the round; `request_changes` submits another work batch after every disposition. + + +After publishing a successor, immediately return to the subscription step. +The controller presents only charts that still require approval. An accepted +chart whose persistent settings, DPI and source payloads are identical keeps +its approval; changed arrays require approval even if axis ranges are unchanged. +No vendor SDK is invoked by +subscription, and the agent must remain attached through its execution tools. +Closing an execution-tool connection does not discard pending durable feedback; +resume from the cache and last delivered work ID. + +## Export approved checkpoints as PNG + +Export after claiming the complete feedback batch and before `finish`. Use the +delivered event's cache path and work ID, and the claim's `token`. Build the export +map from the requested stable Figure IDs and output paths; the `line` and `donut` +entries below are examples. Include previously accepted Figures when requested, +even if only another chart was displayed in this round. + +```sh +EXPORTS='{"line":"/absolute/output/line-final.png","donut":"/absolute/output/donut-final.png"}' +python - "$CACHE" "$EXPORTS" <<'PY' +import json +import sys +from pathlib import Path +from figgyplot.review import Session + +exports = json.loads(sys.argv[2]) +with Session.open(sys.argv[1]) as session: + for figure_id, filename in exports.items(): + path = Path(filename) + path.parent.mkdir(parents=True, exist_ok=True) + figure = session.restore(figure_id, backend="native") + figure.savefig(path) +PY +``` + +`restore()` recovers the accepted settings and binary source data from the +checkpoint. Explicit `backend="native"` also permits synchronous PNG export of a +notebook review; it does not open a window. `savefig()` currently supports `.png`; +it keeps the checkpoint's DPI unless an explicit `dpi=` or `scale=` is requested. +Do not rerun the source builder just to export, call `present()`/`show()`, or +reopen an approved Figure. Use this export recipe only for a sealed, approved +batch. `restore()` claims editing ownership by replacing the checkpoint's Figure +instance ID; a separate `Session.open()` context does not isolate that change. +For a read-only preview while a review is still open, ask the active controller +to export its Figure, or restore an isolated copy of the checkpoint cache. Do not +restore the live reviewing cache merely to inspect or render it: that invalidates +the controller's existing Figure instance. + +Only after all requested files have been written successfully, finish the claimed +work and check its terminal state (`TOKEN` is the claim's token): + +```sh +python -m figgyplot.review finish "$CACHE" "$WORK_ID" --claim-token "$TOKEN" +python -m figgyplot.review status "$CACHE" +``` + +For `state: "completed"`, stop without opening another review or subscription. +If an export or validation fails, report the failure and retain the checkpoint; +do not mark the work completed or silently skip a requested output. For a finish +validation error about an approved edit, use the summary/detail queries to +reconcile that exact edit into the builder, then retry `finish` for the same work. diff --git a/python/figgyplot/styles.py b/python/figgyplot/styles.py new file mode 100644 index 0000000..9856553 --- /dev/null +++ b/python/figgyplot/styles.py @@ -0,0 +1,710 @@ +"""Portable, versioned appearance tokens; data and chart meaning remain owned by the Figure. + +Lengths are points, not renderer pixels. ``scales`` and ``text`` are opt-in on +both extraction and application. See docs/STYLE_TOKENS.md for matching rules. +""" +from __future__ import annotations + +import copy +from dataclasses import dataclass, field +import json +import math +from pathlib import Path +import warnings + +from . import _document + +def family(settings): + # Keep the token compiler independent of the constructor/decorator module. + return next((k for k in ('radial','categorical','boxplot','radar') if settings.get(k) is not None),None) + + +class StyleWarning(UserWarning): + """Some requested appearance has no compatible target.""" + + +@dataclass +class StyleReport: + applied: tuple = () + skipped: tuple = () + + +RGBA = dict.fromkeys(('r', 'g', 'b', 'a'), True) +RICH = dict(color=RGBA, font_size=True, font=True, align=True) +TITLE = dict(text=RICH, visible=True, offset_x=True, offset_y=True, top_margin=True) +LABEL = dict(visible=True, color=RGBA, font_size=True, label_visible=True, + label_font=True, label_offset_x=True, label_offset_y=True, + format=True, significant_digits=True) +AXIS = dict(label_style=LABEL, title_option=TITLE, tick=True, out_margin=True, + line_offset=True, line_visible=True, line_color=RGBA, line_width=True, + line_style=True, major_tick_length=True, minor_tick_length=True, minor_count=True) +GRID = {**{k:True for k in ('show_major_x','show_major_y','show_minor_x','show_minor_y')}, + **{p+'_'+k: RGBA if k=='color' else True + for p in ('major_x','major_y','minor_x','minor_y') for k in ('color','width','style')}} +LEGEND = dict(visible=True, content=RICH, corner=True, offset_x=True, offset_y=True, + padding=True, bg_color=RGBA, border_color=RGBA) +COLORBAR = dict(visible=True, side=True, thickness_px=True, gap_px=True, + length_frac=True, align=True, offset_x=True, offset_y=True, + colormap='json', nan_color=RGBA, border_color=RGBA, border_width=True, axis=AXIS) +LINE = dict(line_color=RGBA, line_width=True, line_style=True, fill_color=RGBA) +SCATTER = dict(point_color=RGBA, point_shape=True, point_size=True) +ERROR = dict(error_bar_color=RGBA, error_bar_width=True, error_bar_cap_size=True, cap_width=True) +BAR = dict(fill_color=RGBA, border_color=RGBA, border_width=True, gap_px=True, width_ratio=True) +VECTOR = dict(color=RGBA, width_px=True, pivot=True, head_length_px=True, head_width_px=True) +RENDER = dict(line=LINE, scatter=SCATTER, err_style=ERROR, bar=BAR, + style=VECTOR, contour=dict(line=LINE), + fill=dict(mode=True, shading=True, opacity=True)) +OUTLINE = dict(rim=True, separators=True, emphasis=True, width=True, color=RGBA) +BOUNDED_STYLE = { + 'radial': dict(material=True, tilt_degrees=True, depth=True, bevel=True, + gap_degrees=True, roughness=True, texture_strength=True, texture_scale=True, + light='json', shadow=True, inner_corner=True, outer_corner=True, outline=OUTLINE, + hover_lift=True, hover_brightness=True, gloss=True, texture_angle_degrees=True), + 'categorical': dict(material=True, corner_radius=True, outline=True, outline_width=True, + outline_color=RGBA, texture_strength=True, texture_scale=True, gloss=True, emphasis_brightness=True), + 'boxplot': dict(material=True, corner_radius=True, outline=True, outline_width=True, + outline_color=RGBA, median_width=True, median_color=RGBA, whisker_width=True, + whisker_color=RGBA, caps=True, cap_ratio=True, notch_depth=True, + mean_size=True, mean_color=RGBA, outlier_size=True, outlier_color=RGBA, + outlier_shape=True, outlier_filled=True, texture_strength=True, texture_scale=True, + gloss=True, emphasis_brightness=True), + 'radar': dict(grid_shape=True, spoke_color=RGBA, ring_color=RGBA, line_width_px=True, + fill_opacity=True, marker=True, marker_size_px=True, emphasis_brightness=True), +} +BOUNDED = dict(font_family=True, font_size=True, text_color=RGBA, label_color=RGBA, + background=RGBA, chart_title=TITLE, grid=True, legend=True, label_decimals=True, + group_width=True, bar_gap=True, box_gap=True) +ANNOTATION = dict(Text=dict(text=RICH,box_style=dict(fill=RGBA,border_color=RGBA,border_width=True,padding=True)), Arrow=dict(text=RICH, + style=dict(line=dict(color=RGBA,width=True,style=True),head_length=True,head_width=True)), + ReferenceLine=dict(style=dict(color=RGBA,width=True,style=True),label=RICH), Span=dict(fill=RGBA)) +PANEL = dict(chart_title=TITLE, grid=GRID, legend=LEGEND, colorbar=COLORBAR, + draw_style=dict.fromkeys(('mode','amplitude_px','wavelength_px','seed', + 'star_density','ribbon_width_px','ribbon_intensity','star_scale', + 'star_brightness','spread_px','structure_scale','faint_bias','glow', + 'nebula','dust','planet_rim','star_opacity','line_opacity'),True), + polar=dict(inner_radius_fraction=True,radial_label_angle_degrees=True), + **dict.fromkeys(('bottom_x','top_x','left_y','right_y'), AXIS)) +SCALE_KEYS = ('scale','min','max','major_spacing','inverted') +LENGTHS = set(('font_size','title_size','label_size','legend_size','line_width','point_size','error_bar_width','error_bar_cap_size', + 'cap_width','border_width','gap_px','thickness_px','padding','offset_x','offset_y', + 'label_offset_x','label_offset_y','out_margin','line_offset','major_tick_length', + 'minor_tick_length','top_margin','width_px','head_length_px','head_width_px', + 'head_length','head_width','width','corner_radius','outline_width','median_width', + 'whisker_width','mean_size','outlier_size','line_width_px','marker_size_px','bar_gap','box_gap')) +LENGTHS.update(('gap','amplitude_px','wavelength_px','ribbon_width_px','spread_px')) +TYPO = dict(font=True, font_size=True, title_size=True, label_size=True, + legend_size=True, title_scale=True, label_scale=True, legend_scale=True, color=RGBA) +SCALE_AXIS = dict.fromkeys((*SCALE_KEYS, 'tick_mode'), True) +TEXT_GROUPS = {'titles','axis_titles','legend_labels'} + + +def _project(value, schema): + if not isinstance(value, dict): + return {} + return {k:copy.deepcopy(v) if rule is True or rule=='json' else _project(v,rule) + for k,v in value.items() if (rule:=schema.get(k)) is not None and v is not None} + + +def _check(value, schema, path): + if not isinstance(value,dict): + raise ValueError(path+' must be a mapping') + for key,v in value.items(): + if key not in schema: + raise ValueError('unknown style field: '+path+'/'+key) + rule=schema[key] + if rule is True: + if not isinstance(v,(str,bool,int,float)): + raise ValueError(path+'/'+key+' must be a scalar') + elif rule!='json': + _check(v,rule,path+'/'+key) + + +def _normalize_colors(node): + from .figure import _color + if isinstance(node,dict): + for key,value in node.items(): + if key=='palette':node[key]=[copy.deepcopy(c) if isinstance(c,dict) else _color(c) for c in value] + elif (key in ('color','background','fill') or key.endswith('_color')) and isinstance(value,(str,list,tuple)): + node[key]=_color(value) + else:_normalize_colors(value) + elif isinstance(node,list): + for value in node:_normalize_colors(value) + + +def _units(value, multiplier): + if isinstance(value,dict): + return {k:(v*multiplier if k in LENGTHS and isinstance(v,(int,float)) and not isinstance(v,bool) + else _units(v,multiplier)) for k,v in value.items()} + if isinstance(value,list):return [_units(v,multiplier) for v in value] + return copy.deepcopy(value) + + +def _merge(target, patch): + for k,v in patch.items(): + if isinstance(v,dict) and isinstance(target.get(k),dict):_merge(target[k],v) + else:target[k]=copy.deepcopy(v) + + +def _panels(state): + return [state['config']]+[v['config'] for v in state['config'].get('subplots',[])] + + +def _panel_patch(cfg, raw, kind, skipped): + from .figure import _colormap + patch=copy.deepcopy(raw) + for key,message in [('colorbar','colorbar: target panel has no color scale'), + ('polar','polar appearance: target is Cartesian')]: + if key in patch and not cfg.get(key): + patch.pop(key);skipped.append(message) + if kind and 'draw_style' in patch: + patch.pop('draw_style');skipped.append('draw style: bounded charts use materials') + if 'draw_style' in patch:cfg.pop('draw_style',None) + if isinstance(patch.get('colorbar',{}).get('colormap'),str): + patch['colorbar']['colormap']=_colormap(patch['colorbar']['colormap']) + _merge(cfg,patch) + + +def _rich_text(node): + return copy.deepcopy(node.get('segments',[])) + + +def _panel_text(cfg): + return dict(titles=_rich_text(cfg['chart_title']['text']), + axis_titles={key:_rich_text(cfg[key]['title_option']['text']) + for key in ('bottom_x','top_x','left_y','right_y')}, + colorbar_title=_rich_text(cfg['colorbar']['axis']['title_option']['text']) if cfg.get('colorbar') else None, + overlays=[_rich_text(o['y_axis']['title_option']['text']) for o in cfg.get('overlays',[])]) + + +@dataclass +class StyleToken: + """One reusable token. Optional scale/text payloads are never implicitly applied.""" + appearance: dict = field(default_factory=dict) + scales: dict = None + text: dict = None + version: int = 1 + + def __post_init__(self): + self.appearance=copy.deepcopy(self.appearance) + self.scales=copy.deepcopy(self.scales) + self.text=copy.deepcopy(self.text) + self.validate() + + def validate(self): + if type(self.version) is not int or self.version!=1:raise ValueError('unsupported style token version') + a=self.appearance + allowed={'typography','background','palette','colormap','panel','panels','series','series_defaults', + 'roles','line_cycle','marker_cycle', + 'bounded','bounded_items','annotations','layout'} + if not isinstance(a,dict) or set(a)-allowed:raise ValueError('unknown appearance fields') + _normalize_colors(a) + for name,schema in [('typography',TYPO),('background',RGBA),('panel',PANEL),('series_defaults',RENDER)]: + if name in a:_check(a[name],schema,name) + for c in a.get('palette',[]):_check(c,RGBA,'palette') + if not isinstance(a.get('roles',{}),dict):raise ValueError('roles must be a mapping') + for name,style in a.get('roles',{}).items(): + if not isinstance(name,str) or not name:raise ValueError('style roles need nonempty names') + _check(style,RENDER,'roles/'+name) + for name in ('line_cycle','marker_cycle'): + if name in a and (not isinstance(a[name],list) or not a[name] or any(not isinstance(v,str) for v in a[name])): + raise ValueError(name+' must be a nonempty list of names') + valid_lines={'Solid','Dash','Dot','DashDot','DashDotDot','ShortDash','ShortDot','ShortDashDot', + 'LongDash','LongDashDot','LongDashDotDot'} + valid_markers={'Circle','CircleFilled','Square','SquareFilled','Triangle','TriangleFilled', + 'TriangleDown','TriangleDownFilled','Diamond','DiamondFilled','Plus','Cross','Star','StarFilled'} + valid_markers.update({'TriangleLeft','TriangleRight','TriangleLeftFilled','TriangleRightFilled', + 'PlusFilled','CrossFilled','Pentagon','Hexagon','Octagon','PentagonFilled','HexagonFilled','OctagonFilled'}) + for name,choices in [('line_cycle',valid_lines),('marker_cycle',valid_markers)]: + if any(v not in choices for v in a.get(name,[])):raise ValueError('unsupported '+name+' entry') + for panel in a.get('panels',[]): + _check(panel,dict(config=PANEL,overlays='json'),'panels') + for overlay in panel.get('overlays',[]):_check(overlay,dict(y_axis=AXIS),'overlays') + for spec in a.get('series',[]): + _check(spec,dict(kind=True,style=RENDER),'series') + for kind,style in a.get('bounded',{}).items(): + if kind not in BOUNDED_STYLE:raise ValueError('unknown bounded style family') + _check(style,dict(BOUNDED,style=BOUNDED_STYLE[kind]),'bounded/'+kind) + for kind,items in a.get('bounded_items',{}).items(): + if kind not in BOUNDED_STYLE:raise ValueError('unknown bounded item family') + for item in items:_check(item,dict(color=RGBA,style=BOUNDED_STYLE[kind]),'bounded_items') + for kind,styles in a.get('annotations',{}).items(): + if kind not in ANNOTATION:raise ValueError('unknown annotation kind') + for style in styles if isinstance(styles,list) else [styles]: + _check(style,ANNOTATION[kind],'annotations/'+kind) + if 'layout' in a: + layout=a['layout'];_check(layout,dict(gap=True,row_weights='json',column_weights='json',grid='json'),'layout') + if set(layout)!={'gap','row_weights','column_weights','grid'}:raise ValueError('layout needs gap, weights and grid') + grid=layout['grid'] + if not isinstance(grid,list) or not grid or not isinstance(grid[0],list) or not grid[0] or any(not isinstance(r,list) or len(r)!=len(grid[0]) for r in grid): + raise ValueError('layout grid must be rectangular') + if any(v is not None and (type(v) is not int or v<0) for r in grid for v in r):raise ValueError('layout grid uses integer panel slots') + for key,count in [('row_weights',len(grid)),('column_weights',len(grid[0]))]: + if not isinstance(layout[key],list) or len(layout[key])!=count or any(type(v) not in (int,float) or v<=0 for v in layout[key]):raise ValueError('invalid layout weights') + if not isinstance(layout['gap'],(int,float)) or layout['gap']<0:raise ValueError('layout gap must be nonnegative') + for payload in (self.scales,self.text): + if payload is not None and not isinstance(payload,dict):raise ValueError('optional style payload must be a mapping') + if self.scales is not None: + if set(self.scales)-{'panels','family','value_range','ring_spacing'}:raise ValueError('unknown scale fields') + for p in self.scales.get('panels',[]): + _check(p,dict(axes={k:SCALE_AXIS for k in ('bottom_x','top_x','left_y','right_y')}, + colorbar=SCALE_AXIS,overlays='json'),'scales') + for o in p.get('overlays',[]):_check(o,SCALE_AXIS,'scales/overlays') + if self.text is not None: + if set(self.text)-{'panels','series','family','bounded'}:raise ValueError('unknown text fields') + for p in self.text.get('panels',[]): + _check(p,dict(titles='json',axis_titles={key:'json' for key in ('bottom_x','top_x','left_y','right_y')}, + colorbar_title='json',overlays='json'),'text/panels') + if 'bounded' in self.text: + _check(self.text['bounded'],dict(title=True,title_segments='json',category_title=True,value_title=True,legend_labels='json'),'text/bounded') + # Also reject NaN/inf, non-JSON objects and accidentally supplied arrays. + json.dumps(self.to_dict(),allow_nan=False) + return self + + def to_dict(self): + return copy.deepcopy(dict(version=self.version,appearance=self.appearance,scales=self.scales,text=self.text)) + + def to_json(self, *, indent=2): + self.validate() + return json.dumps(self.to_dict(),ensure_ascii=False,allow_nan=False,indent=indent) + + @classmethod + def from_dict(cls,value): + if not isinstance(value,dict) or set(value)-{'version','appearance','scales','text'}: + raise ValueError('invalid style token document') + if 'version' not in value:raise ValueError('style token requires a version') + return cls(**value) + + @classmethod + def from_json(cls,value):return cls.from_dict(json.loads(value)) + + def save(self,path):Path(path).write_text(self.to_json()+'\n',encoding='utf-8') + + @classmethod + def load(cls,path):return cls.from_json(Path(path).read_text(encoding='utf-8')) + + @classmethod + def from_figure(cls,figure,**kwargs):return extract(figure,**kwargs) + + +def extract(figure, *, scales=False, text=False): + """Extract accepted SSOT. In notebooks await Figure.sync() before extraction.""" + if type(scales) is not bool or type(text) is not bool: + raise ValueError('scales and text extraction flags must be boolean') + with figure._lock: + state=figure.get_settings() + unit=72/figure.dpi + kind=family(state) + panels=_panels(state) + appearance=dict(panels=[]) + for cfg in panels: + p=dict(config=_units(_project(cfg,PANEL),unit),overlays=[ + dict(y_axis=_units(_project(o['y_axis'],AXIS),unit)) for o in cfg.get('overlays',[])]) + if not kind:p['config'].setdefault('draw_style',dict(mode='precise')) + appearance['panels'].append(p) + appearance['background']=copy.deepcopy(state.get('background',dict(r=1.,g=1.,b=1.,a=1.))) + appearance['series']=[dict(kind=next(iter(s['render_type'])), + style=_units(_project(next(iter(s['render_type'].values())),RENDER),unit)) for s in state['series']] + if kind: + chart=state[kind] + appearance['bounded']={kind:_units(_project(chart,dict(BOUNDED,style=BOUNDED_STYLE[kind])),unit)} + appearance['bounded_items']={kind:[_units(_project(v,dict(color=RGBA,style=BOUNDED_STYLE[kind])),unit) + for v in chart.get('slices',chart.get('series',[]))]} + appearance['background']=copy.deepcopy(chart['background']) + owner=state[kind] if kind else state['config'] + for item in owner.get('annotations',{}).get('items',[]): + tag,body=next(iter(item['kind'].items())) + appearance.setdefault('annotations',{}).setdefault(tag,[]).append(_units(_project(body,ANNOTATION[tag]),unit)) + layout=state['identity'].get('layout') + if layout: + slots=layout['placement'];ids=[v['id'] for v in state['identity']['panels']] + appearance['layout']=dict(gap=layout['gap']*unit,row_weights=copy.deepcopy(layout['row_weights']), + column_weights=copy.deepcopy(layout['column_weights']), + grid=[[None if slot is None or slot not in slots else ids.index(slots[slot]) for slot in row] for row in layout['grid']]) + scale_payload=None + if scales: + scale_payload=dict(panels=[]) + for i,cfg in enumerate(panels): + pid=state['identity']['panels'][i]['id'] + def axis_scale(axis,owner,key): + return dict(_project(axis,dict.fromkeys(SCALE_KEYS,True)), + tick_mode=state['identity'].get('tick_modes',{}).get(owner,{}).get(key,'Auto')) + p=dict(axes={key:axis_scale(cfg[key],pid,key) for key in ('bottom_x','top_x','left_y','right_y')}, + overlays=[axis_scale(o['y_axis'],state['identity']['panels'][i]['overlays'][j],'y_axis') for j,o in enumerate(cfg.get('overlays',[]))]) + if cfg.get('colorbar'):p['colorbar']=_project(cfg['colorbar']['axis'],dict.fromkeys(SCALE_KEYS,True)) + scale_payload['panels'].append(p) + if kind: + scale_payload['family']=kind + for key in ('value_range','ring_spacing'): + if key in state[kind]:scale_payload[key]=copy.deepcopy(state[kind][key]) + text_payload=None + if text: + text_payload=dict(panels=[_panel_text(c) for c in panels],series=[copy.deepcopy(s.get('label')) for s in state['series']]) + if kind: + chart=state[kind];text_payload['family']=kind + text_payload['bounded']={k:copy.deepcopy(chart[k]) for k in ('title','category_title','value_title') if k in chart} + if chart.get('chart_title'): + from .figure import _text + text_payload['bounded']['title'] = _text(chart['chart_title']['text']) + text_payload['bounded']['title_segments'] = copy.deepcopy(chart['chart_title']['text']['segments']) + text_payload['bounded']['legend_labels']=[s['label'] for s in chart.get('slices',chart.get('series',[]))] + return StyleToken(appearance,scale_payload,text_payload) + + +def _font_size(typo, role): + if role in typo:return typo[role] + if 'font_size' in typo:return typo['font_size']*typo.get(role.replace('_size','_scale'),1) + return None + + +def _typography(cfg, typo): + def rich(node,size): + for src,dst in [('font','font'),('color','color')]: + if src in typo:node[dst]=copy.deepcopy(typo[src]) + value=_font_size(typo,size) + if value is not None:node['font_size']=value + rich(cfg['chart_title']['text'],'title_size');rich(cfg['legend']['content'],'legend_size') + for key in ('bottom_x','top_x','left_y','right_y'): + axis=cfg[key];rich(axis['title_option']['text'],'label_size') + label=axis['label_style'] + for src,dst in [('font','label_font'),('color','color')]: + if src in typo:label[dst]=copy.deepcopy(typo[src]) + value=_font_size(typo,'label_size') + if value is not None:label['font_size']=value + if cfg.get('colorbar'): + # Same axis typography without requiring another chart model. + temp=dict(cfg,bottom_x=cfg['colorbar']['axis']) + for key in ('top_x','left_y','right_y'):temp[key]=cfg['colorbar']['axis'] + temp.pop('colorbar',None);_typography(temp,typo) + for overlay in cfg.get('overlays',[]): + temp=dict(cfg,bottom_x=overlay['y_axis']);temp.pop('overlays',None);temp.pop('colorbar',None) + for key in ('top_x','left_y','right_y'):temp[key]=overlay['y_axis'] + _typography(temp,typo) + + +def _axis_apply(axis,patch,limits): + allowed=SCALE_KEYS if limits else ('scale','inverted') + for key in allowed: + if key in patch:axis[key]=copy.deepcopy(patch[key]) + if axis['scale']=='Logarithmic' and axis['min']<=0: + raise ValueError('log style requires positive axis limits; set positive limits explicitly') + + +def _render_patch(body, patch): + """Appearance defaults can style existing components, never introduce kinds.""" + for key,value in patch.items(): + if key in body and isinstance(body[key],dict):_merge(body[key],value) + + +def _encode_series(body, appearance, index, role=None): + palette=appearance.get('palette',[]) + if palette: + color=palette[index%len(palette)] + for name,keys in [('line',['line_color']),('scatter',['point_color']),('bar',['fill_color']),('err_style',['error_bar_color']),('style',['color'])]: + if not isinstance(body.get(name),dict):continue + if name=='scatter' and body[name].get('point_color_map'):continue + if name=='style' and body[name].get('color_map'):continue + for key in keys: + if key in body[name]:body[name][key]=copy.deepcopy(color) + if isinstance(body.get('contour',{}).get('line'),dict): + body['contour']['line']['line_color']=copy.deepcopy(color) + block=index//max(1,len(palette)) + if appearance.get('line_cycle') and 'line' in body: + body['line']['line_style']=appearance['line_cycle'][block%len(appearance['line_cycle'])] + if appearance.get('line_cycle') and 'contour' in body: + body['contour']['line']['line_style']=appearance['line_cycle'][block%len(appearance['line_cycle'])] + if appearance.get('marker_cycle') and 'scatter' in body: + body['scatter']['point_shape']=appearance['marker_cycle'][(index%max(1,len(palette))+block)%len(appearance['marker_cycle'])] + if role in appearance.get('roles',{}):_render_patch(body,appearance['roles'][role]) + + +class _Candidate: + """Resolve appearance before a constructor uploads/commits new objects.""" + def __init__(self,figure,state,finalize=None): + self.state=copy.deepcopy(state);self.dpi=figure.dpi;self._lock=figure._lock + self._check_font=figure._check_font;self.finalize=finalize + + def get_settings(self):return copy.deepcopy(self.state) + def apply_settings(self,state,**kwargs):self.state=copy.deepcopy(state) + + +def _resolve(figure,state,token,finalize=None): + candidate=_Candidate(figure,state,finalize) + with warnings.catch_warnings(): + # Defaults may describe fields or families not constructed yet. + warnings.simplefilter('ignore',StyleWarning) + apply(candidate,token) + return candidate.state + + +def apply(figure, token, *, scales=False, text=False, size_scale=1.0, + panel_map=None, series_map=None, layout=False, strict=False, + expected_revision=None, limits=True, role_map=None): + """Resolve a token completely before one atomic accepted-settings edit.""" + from .figure import _colormap + if isinstance(token,str):token=preset(token) + if not isinstance(token,StyleToken):raise TypeError('style must be a StyleToken or preset name') + token.validate() + if any(type(flag) is not bool for flag in (scales,layout,strict,limits)): + raise ValueError('scales, layout, strict and limits flags must be boolean') + size_scale=float(size_scale) + if not math.isfinite(size_scale) or size_scale<=0:raise ValueError('size_scale must be positive and finite') + groups=TEXT_GROUPS if text is True else set() if text is False else set(text) + if not groups<=TEXT_GROUPS:raise ValueError('unknown text selection') + if scales and token.scales is None:raise ValueError('token contains no scales') + if groups and token.text is None:raise ValueError('token contains no text') + with figure._lock: + state=figure.get_settings();kind=family(state) + a=_units(token.appearance,figure.dpi/72*size_scale) + skipped=[];applied=[] + targets=_panels(state);ids=[p['id'] for p in state['identity']['panels']] + source=a.get('panels',[]) + if panel_map is None: + pairs=[(0,j) for j in range(len(targets))] if len(source)==1 else [(i,i) for i in range(min(len(source),len(targets)))] + if len(source)>1 and len(source)!=len(targets):skipped.append('panel count differs; unmatched panel styles (use panel_map)') + else: + pairs=[] + for key,value in panel_map.items(): + if type(key) is not int or key<0 or key>=len(source) or value not in ids:raise ValueError('invalid panel_map') + pairs.append((key,ids.index(value))) + if 'background' in a: + if kind:state[kind]['background']=copy.deepcopy(a['background']) + else:state['background']=copy.deepcopy(a['background']) + for cfg in targets: + if 'panel' in a:_panel_patch(cfg,a['panel'],kind,skipped) + if 'colormap' in a: + if cfg.get('colorbar'): + cfg['colorbar']['colormap']=_colormap(a['colormap']) if isinstance(a['colormap'],str) else copy.deepcopy(a['colormap']) + else:skipped.append('colormap: target panel has no color scale') + for src,dst in pairs: + spec=source[src];cfg=targets[dst] + _panel_patch(cfg,spec.get('config',{}),kind,skipped) + for j,overlay in enumerate(spec.get('overlays',[])): + if j=capacity and categorical and s['series_id'] not in (role_map or {}): + skipped.append('category encodings exhausted; supply explicit styles or split/group categories') + if kind: + chart=state[kind] + if kind in a.get('bounded',{}): + patch = a['bounded'][kind] + if patch.get('chart_title'): + # An appearance token omits wording. Seed from the target + # before merging its partial rich-title styles. + from ._chart_text import title_options + title_options(chart) + _merge(chart,patch) + elif a.get('bounded'):skipped.append('bounded style: source chart family differs') + items=chart.get('slices',chart.get('series',[]));itemstyles=a.get('bounded_items',{}).get(kind,[]) + if palette and len(items)>len(palette):skipped.append('bounded palette exhausted; supply explicit colors or group series/slices') + for i,item in enumerate(items): + if itemstyles:_merge(item,itemstyles[i%len(itemstyles)]) + if palette:item['color']=copy.deepcopy(palette[i%len(palette)]) + elif len(a.get('bounded',{}))==1:skipped.append('bounded style: target is Cartesian') + owner=state[kind] if kind else state['config'] + annotation_counts={} + for item in owner.get('annotations',{}).get('items',[]): + tag,body=next(iter(item['kind'].items())) + styles=a.get('annotations',{}).get(tag,[]) + if styles: + if not isinstance(styles,list):styles=[styles] + index=annotation_counts.get(tag,0);annotation_counts[tag]=index+1 + patch=copy.deepcopy(styles[index%len(styles)]) + for key in ('text','label'): + if key in patch and body.get(key) is None:patch.pop(key) + _merge(body,patch) + if scales: + payload=token.scales + for src,dst in pairs: + if src>=len(payload.get('panels',[])):continue + cfg=targets[dst];p=payload['panels'][src] + for key,patch in p['axes'].items(): + _axis_apply(cfg[key],patch,limits) + if limits and 'tick_mode' in patch: + state['identity'].setdefault('tick_modes',{}).setdefault(ids[dst],{})[key]=patch['tick_mode'] + if 'colorbar' in p: + if cfg.get('colorbar'):_axis_apply(cfg['colorbar']['axis'],p['colorbar'],limits) + else:skipped.append('color scale: no target colorbar') + for j,patch in enumerate(p.get('overlays',[])): + if j=len(payload.get('panels',[])):continue + p=payload['panels'][src];cfg=targets[dst] + if 'titles' in groups:cfg['chart_title']['text']['segments']=copy.deepcopy(p['titles']) + if 'axis_titles' in groups: + for key,segments in p['axis_titles'].items():cfg[key]['title_option']['text']['segments']=copy.deepcopy(segments) + if p.get('colorbar_title') is not None and cfg.get('colorbar'):cfg['colorbar']['axis']['title_option']['text']['segments']=copy.deepcopy(p['colorbar_title']) + for j,segments in enumerate(p.get('overlays',[])): + if j=len(labels):raise ValueError('missing source legend label') + next(s for s in state['series'] if s['series_id']==sid)['label']=copy.deepcopy(labels[index]) + if payload.get('family'): + if kind!=payload['family']:raise ValueError('bounded text requires the same family') + p=payload['bounded'];chart=state[kind] + if 'titles' in groups and 'title' in p: + from ._chart_text import set_title_text + set_title_text(chart, p['title'], segments=p.get('title_segments')) + if 'axis_titles' in groups: + for key in ('category_title','value_title'): + if key in p:chart[key]=p[key] + if 'legend_labels' in groups: + items=chart.get('slices',chart.get('series',[]));labels=p['legend_labels'] + if len(items)!=len(labels):raise ValueError('legend text requires matching item count') + for item,label in zip(items,labels):item['label']=label + # Explicit common typography overrides captured object styles. Extracted + # tokens do not invent a global override from one arbitrary title. + typo=a.get('typography',{}) + for cfg in targets:_typography(cfg,typo) + if kind: + chart=state[kind] + for src,dst in [('font','font_family'),('font_size','font_size'),('color','text_color' if 'text_color' in chart else 'label_color')]: + if src in typo:chart[dst]=copy.deepcopy(typo[src]) + if chart.get('chart_title'): + rich=chart['chart_title']['text'] + for key in ('font','color'): + if key in typo:rich[key]=copy.deepcopy(typo[key]) + value=_font_size(typo,'title_size') + if value is not None:rich['font_size']=value + for item in owner.get('annotations',{}).get('items',[]): + body=next(iter(item['kind'].values())) + for key in ('text','label'): + if isinstance(body.get(key),dict): + for field in ('font','font_size','color'): + if field in typo:body[key][field]=copy.deepcopy(typo[field]) + if isinstance(figure,_Candidate) and figure.finalize is not None:figure.finalize(state) + if kind: + maximum=64 if kind=='radial' else 32 + if not 8<=state[kind]['font_size']<=maximum: + raise ValueError('%s renderer font_size must be 8..%s logical pixels at this DPI; lower font_size or size_scale' % (kind,maximum)) + # Check fonts before committing any part of the candidate. + def check_fonts(node): + if isinstance(node,dict): + for key,value in node.items(): + if key in ('font','font_family','label_font') and value not in ('sans-serif','serif','monospace'): + figure._check_font(value) + else:check_fonts(value) + elif isinstance(node,list): + for value in node:check_fonts(value) + check_fonts(a) + skipped=tuple(dict.fromkeys(skipped)) + if strict and skipped:raise ValueError('incompatible style: '+'; '.join(skipped)) + if skipped:warnings.warn('; '.join(skipped),StyleWarning,stacklevel=3) + figure.apply_settings(state,expected_revision=expected_revision) + applied.append('appearance') + if scales:applied.append('scales') + if groups:applied.append('text') + return StyleReport(tuple(applied),skipped) + + +def available(): + from ._style_presets import PROFILES + return ('paper','report','presentation','dark','monochrome','colorblind')+tuple(PROFILES) + + +def describe(name): + """Source guidance, intended task, recommendations and known limitations.""" + from ._style_presets import PROFILES, describe as task_description + if name in PROFILES:return task_description(name) + if name not in available():raise ValueError('unknown preset: '+name) + return dict(name=name,purpose='Legacy generic appearance starting point', + limits=['Not a journal, accessibility or dashboard-task certification.'],sources=[]) + + +def preset(name, *, theme=None, density='comfortable'): + """Fresh, independent appearance-only preset. No title, label or axis domain.""" + from .figure import _color + from ._style_presets import PROFILES,build + if name in PROFILES:return build(name,theme,density) + if name not in available():raise ValueError('unknown preset; choose '+', '.join(available())) + if theme is not None or density!='comfortable':raise ValueError('theme and density options require a task profile') + dark=name=='dark';fg=_color('#e5e7eb' if dark else '#20242b') + bg=_color('#171b24' if dark else '#ffffff');grid=_color('#394254' if dark else '#d9dde3') + palette=['#0072b2','#e69f00','#009e73','#cc79a7','#d55e00','#56b4e9','#f0e442'] + if name=='monochrome':palette=['#161616','#666666','#a0a0a0'] + if dark:palette=['#56b4e9','#f0b45a','#5ad1a6','#df95cb','#ff9072'] + size=16 if name=='presentation' else 11 + axis=dict(line_color=fg,line_width=.8,tick='Outside',major_tick_length=4,minor_tick_length=2, + label_style=dict(color=fg,font_size=size,label_font=''), + title_option=dict(text=dict(color=fg,font_size=size+1,font=''))) + panel=dict(chart_title=dict(text=dict(color=fg,font_size=size+3,font=''),top_margin=size+9), + grid=dict(show_major_x=name in ('report','dark'),show_major_y=name!='paper', + major_x_color=grid,major_y_color=grid,major_x_width=.6,major_y_width=.6), + legend=dict(content=dict(color=fg,font_size=size-1,font=''),bg_color=dict(bg,a=.9),border_color=grid,padding=6), + **{key:copy.deepcopy(axis) for key in ('bottom_x','top_x','left_y','right_y')}) + bounded={k:dict(text_color=fg,grid=name!='paper') for k in ('categorical','boxplot','radar')} + bounded['radial']=dict(label_color=fg) + for k in ('categorical','boxplot'): + bounded[k]['style']=dict(outline_color=fg) + bounded['boxplot']['style'].update(median_color=fg,whisker_color=fg,outlier_color=fg) + bounded['radar']['style']=dict(spoke_color=grid,ring_color=grid) + return StyleToken(dict(typography=dict(font='',font_size=size,title_scale=(size+3)/size,label_scale=1, + legend_scale=(size-1)/size,color=fg),background=bg,palette=[_color(c) for c in palette],panel=panel, + series_defaults=dict(line=dict(line_width=1.2),scatter=dict(point_size=3), + err_style=dict(error_bar_width=1,error_bar_cap_size=3,cap_width=1), + bar=dict(border_width=.6),contour=dict(line=dict(line_width=1))),bounded=bounded)) diff --git a/python/figgyplot/ticker.py b/python/figgyplot/ticker.py new file mode 100644 index 0000000..939e91b --- /dev/null +++ b/python/figgyplot/ticker.py @@ -0,0 +1,145 @@ +"""Matplotlib-shaped regular tick locators; no Matplotlib runtime dependency.""" + +import math +import numpy as np + + +class MultipleLocator: + def __init__(self, base=1.0, offset=0.0): + self.base = float(base) + self.offset = float(offset) + if ( + not math.isfinite(self.base) + or self.base <= 0 + or not math.isfinite(self.offset) + ): + raise ValueError("base must be positive and finite; offset must be finite") + + def tick_values(self, vmin, vmax): + low, high = sorted((float(vmin), float(vmax))) + first = math.floor((low - self.offset) / self.base) * self.base + self.offset + return ( + first + np.arange(0, math.ceil((high - first) / self.base) + 2) * self.base + ) + + +class AutoMinorLocator: + def __init__(self, n=None): + self.n = 5 if n is None else int(n) + if self.n < 1: + raise ValueError("minor subdivisions must be positive") + + +class AutoLocator: + """Use the renderer's automatic regular tick calculation for the view.""" + + +class LogLocator: + """Regular logarithmic major ticks. The renderer uses base-10 exponents.""" + + def __init__(self, base=10.0): + self.base = float(base) + if not math.isfinite(self.base) or self.base <= 1: + raise ValueError( + "logarithmic locator base must be finite and greater than one" + ) + + +class SignificantFormatter: + """Figgy extension: numeric labels with significant digits and notation. + + ``style`` is plain, scientific or power. This is deliberately distinct from + Matplotlib's arbitrary string/callable formatters, which require explicit + per-tick labels that the current renderer does not support. + """ + + def __init__(self, precision=3, style="plain"): + self.precision = int(precision) + self.style = style + if not 1 <= self.precision <= 15 or style not in ( + "plain", + "scientific", + "power", + "degrees", + "radians", + ): + raise ValueError( + "precision must be 1..15; style plain, scientific, power, degrees or radians" + ) + + def _format(self): + return dict( + plain="Decimal", + scientific="Scientific", + power="Power", + degrees="Degrees", + radians="Radians", + )[self.style] + + +class TimestampFormatter(SignificantFormatter): + """Unix timestamp labels without changing source values or axis limits. + + Units are s/ms/us/ns; timezone is a fixed UTC offset in minutes. Pattern + supports %Y %m %d %H %M %S %f and %%. None uses calendar-aware labels. + """ + + def __init__( + self, + pattern=None, + *, + unit="s", + timezone=0, + fractional_digits=None, + calendar=True, + precision=3, + ): + super().__init__(precision) + if unit not in ("s", "ms", "us", "ns"): + raise ValueError("timestamp unit must be s, ms, us or ns") + if ( + not isinstance(timezone, (int, np.integer)) + or not -(2**31) <= timezone < 2**31 + ): + raise ValueError("timezone must be a signed integer UTC offset in minutes") + if fractional_digits is not None and ( + not isinstance(fractional_digits, (int, np.integer)) + or not 0 <= fractional_digits <= 9 + ): + raise ValueError("fractional_digits must be None or an integer from 0 to 9") + if pattern is not None: + if not isinstance(pattern, str): + raise TypeError("timestamp pattern must be a string") + import re + + if any( + not token or token not in "YmdHMSf%" + for token in re.findall(r"%(.)?", pattern) + ): + raise ValueError( + "supported timestamp patterns are %Y %m %d %H %M %S %f and %%" + ) + if not isinstance(calendar, bool): + raise TypeError("calendar must be bool") + self.pattern, self.unit, self.timezone = pattern, unit, int(timezone) + self.fractional_digits, self.calendar = fractional_digits, bool(calendar) + + def _format(self): + return { + "Timestamp": dict( + unit={ + "s": "Seconds", + "ms": "Milliseconds", + "us": "Microseconds", + "ns": "Nanoseconds", + }[self.unit], + timezone="Utc" + if self.timezone == 0 + else {"FixedOffsetMinutes": self.timezone}, + label="Auto" if self.pattern is None else {"Pattern": self.pattern}, + fractional="Auto" + if self.fractional_digits is None + else {"Fixed": self.fractional_digits}, + tick_policy="AutoCalendar" if self.calendar else "NumericSpacing", + ) + } diff --git a/python/figgyplot/webgpu.js b/python/figgyplot/webgpu.js index 2215406..7c5c2fc 100644 --- a/python/figgyplot/webgpu.js +++ b/python/figgyplot/webgpu.js @@ -13,6 +13,40 @@ async function loadKernel(model) { } function bytes(v) {return v instanceof DataView ? new Uint8Array(v.buffer,v.byteOffset,v.byteLength) : new Uint8Array(v.buffer ?? v,v.byteOffset ?? 0,v.byteLength ?? v.length);} const clone = value => JSON.parse(JSON.stringify(value)); +// Property retries compare actual before/after SSOT leaves, never guessed setter effects. +const owns=(object,key)=>Object.prototype.hasOwnProperty.call(object,key); +function equalValue(a,b){ + if(Object.is(a,b))return true; + if(!a||!b||typeof a!=='object'||typeof b!=='object'||Array.isArray(a)!==Array.isArray(b))return false; + const keys=Object.keys(a);return keys.length===Object.keys(b).length&&keys.every(key=>owns(b,key)&&equalValue(a[key],b[key])); +} +function propertyChanges(before,after,path=[],changes=[]){ + if(equalValue(before,after))return changes; + // Arrays are atomic: an external insertion/reordering must never redirect an index edit. + if(before&&after&&typeof before==='object'&&typeof after==='object'&&!Array.isArray(before)&&!Array.isArray(after)){ + for(const key of new Set([...Object.keys(before),...Object.keys(after)])){ + if(owns(before,key)&&owns(after,key))propertyChanges(before[key],after[key],[...path,key],changes); + else changes.push({path:[...path,key],before:before[key],after:after[key],had:owns(before,key),has:owns(after,key)}); + } + }else changes.push({path,before,after,had:true,has:true}); + return changes; +} +function propertyDraft(before,after){return {base:clone(before),changes:propertyChanges(before,after),attempts:0};} +function rebasePropertyDraft(draft,accepted){ + if(!equalValue(draft.base.identity?.figure,accepted.identity?.figure)||!equalValue(draft.base.identity?.panels,accepted.identity?.panels))return null; + const state=clone(accepted); + for(const change of draft.changes){ + let parent=state; + for(const key of change.path.slice(0,-1)){if(!parent||typeof parent!=='object'||!owns(parent,key))return null;parent=parent[key];} + const key=change.path.at(-1);if(key===undefined||!parent||typeof parent!=='object')return null; + const found=owns(parent,key),value=parent[key]; + const original=found===change.had&&(!found||equalValue(value,change.before)); + const applied=found===change.has&&(!found||equalValue(value,change.after)); + if(!original&&!applied)return null; + if(change.has)Object.defineProperty(parent,key,{value:clone(change.after),enumerable:true,configurable:true,writable:true});else delete parent[key]; + } + return state; +} const rendererVersion = '0.18.1'; const supportedSettings = settings => [1,2].includes(settings.schema_version) && ['0.17.0','0.18.0',rendererVersion].includes(settings.renderer_version); const text = rich => rich?.segments?.map(s=>s.text).join('') ?? ''; @@ -51,30 +85,106 @@ export function render({model,el}) { controls.forEach(control=>control.disabled=true); const client=crypto.randomUUID(); let chart,disposed=false,dirty=true,raf=0,selected=null,pointer=null,queue=Promise.resolve(); - let revision=model.get('state').revision,sequence=0,inflight=null,pending=null,barriers=[],local=clone(model.get('state').settings); + let revision=model.get('state').revision,sequence=0,inflight=null,pending=null,pendingDraft=null,barriers=[],local=clone(model.get('state').settings); let editHistory=model.get('state').edit_history??{},historyBusy=false; let propertyColumns=clone(model.get('state').property_columns??[]),propertyCatalog=clone(model.get('state').property_catalog??[]),controlQueue=Promise.resolve(),controlFinish=null; // Session metadata is separate from chart SSOT and ordinary inspector fields. const reviewPanel=document.createElement('section');reviewPanel.className='figgy-review';reviewPanel.hidden=true; - reviewPanel.innerHTML='
'; + reviewPanel.innerHTML='
'; root.append(reviewPanel); const reviewComment=reviewPanel.querySelector('textarea'),reviewStatus=reviewPanel.querySelector('[data-review-status]'); - let reviewBusy=false,reviewLoaded=false; + let reviewBusy=false,reviewLoaded=false,reviewTargets=[],reviewFontLoaded='',reviewAttachments=[],reviewImagesUploading=0; + const reviewImageRequests=new Map(),reviewImagePreviews=new Map(); + const imagePreview=document.createElement('dialog');imagePreview.className='figgy-review-image-preview';root.append(imagePreview); + function previewReviewImage(item){ + imagePreview.replaceChildren();const img=document.createElement('img');img.src=reviewImagePreviews.get(item.id)??'';img.alt=item.name; + const close=document.createElement('button');close.textContent='Close';close.onclick=()=>imagePreview.close();imagePreview.append(img,close);imagePreview.showModal(); + } + function uploadReviewImage(file){ + return new Promise(async(resolve,reject)=>{ + if(file.size>20*1024*1024){reject(Error('Review images must be at most 20 MiB'));return;} + const request=crypto.randomUUID();let timer; + try{ + const data=await file.arrayBuffer(); + timer=setTimeout(()=>{reviewImageRequests.delete(request);reject(Error('Image upload timed out; please try again'));},60000); + reviewImageRequests.set(request,{resolve:item=>{clearTimeout(timer);const old=reviewImagePreviews.get(item.id);if(old?.startsWith('blob:'))URL.revokeObjectURL(old);reviewImagePreviews.set(item.id,URL.createObjectURL(file));resolve(item);},reject:error=>{clearTimeout(timer);reject(Error(error));}}); + model.send({action:'review_image',request,name:file.name??'Clipboard image.png'},{},[data]); + }catch(error){clearTimeout(timer);reviewImageRequests.delete(request);reject(error);} + }); + } + function imageEditor(container,input,getItems,setItems,prefix){ + container.className='figgy-review-images'; + const attach=document.createElement('button');attach.dataset[prefix+'Attach']='';attach.textContent='Attach image…'; + const files=document.createElement('input');files.type='file';files.accept='image/png,image/jpeg,image/webp';files.multiple=true;files.hidden=true; + const hint=document.createElement('span');hint.textContent='Paste or drop an image here'; + const list=document.createElement('div');list.className='figgy-review-image-list'; + const error=document.createElement('span');error.setAttribute('role','alert'); + container.append(attach,files,hint,list,error); + const render=()=>{ + list.replaceChildren(); + for(const item of getItems()){ + const group=document.createElement('span'),view=document.createElement('button'),img=document.createElement('img'),remove=document.createElement('button'); + view.dataset[prefix+'Image']=item.id;view.title=item.name;img.src=reviewImagePreviews.get(item.id)??'';img.alt=item.name;view.append(img);view.onclick=()=>previewReviewImage(item); + remove.dataset[prefix+'ImageRemove']=item.id;remove.textContent='×';remove.title='Remove attachment';remove.setAttribute('aria-label',`Remove ${item.name}`); + remove.onclick=()=>{if(reviewBusy)return;setItems(getItems().filter(v=>v.id!==item.id));render();};group.append(view,remove);list.append(group); + } + attach.disabled=reviewBusy||!!(model.get('review')??{}).approved; + }; + const add=async files=>{ + if(reviewBusy||(model.get('review')??{}).approved)return; + reviewImagesUploading++;reviewState();error.textContent='Uploading image…'; + try{ + for(const file of files){const item=await uploadReviewImage(file);if(!getItems().some(v=>v.id===item.id))setItems([...getItems(),item]);} + error.textContent=''; + }catch(e){error.textContent=String(e);} + finally{reviewImagesUploading--;render();reviewState();} + }; + attach.onclick=()=>files.click();files.onchange=()=>{add([...files.files]);files.value='';}; + input.addEventListener('paste',event=>{const images=[...(event.clipboardData?.items??[])].filter(v=>v.kind==='file'&&v.type.startsWith('image/')).map(v=>v.getAsFile()).filter(Boolean);if(images.length){event.preventDefault();add(images);}}); + container.addEventListener('dragover',event=>{if(event.dataTransfer?.types.includes('Files'))event.preventDefault();}); + container.addEventListener('drop',event=>{const images=[...(event.dataTransfer?.files??[])].filter(v=>v.type.startsWith('image/'));if(images.length){event.preventDefault();add(images);}}); + render();return render; + } + function disposeReviewImages(){for(const pending of reviewImageRequests.values())pending.reject('Review closed');reviewImageRequests.clear();for(const url of reviewImagePreviews.values())if(url.startsWith('blob:'))URL.revokeObjectURL(url);reviewImagePreviews.clear();if(imagePreview.open)imagePreview.close();} + const generalImages=document.createElement('div');reviewComment.parentElement.after(generalImages); + const renderReviewImages=imageEditor(generalImages,reviewComment,()=>reviewAttachments,items=>{reviewAttachments=items;saveReviewDraft();},'review'); + const reviewDialog=document.createElement('dialog');reviewDialog.className='figgy-review-dialog';root.append(reviewDialog); + const reviewMenu=document.createElement('div');reviewMenu.className='figgy-review-menu';reviewMenu.popover='auto';root.append(reviewMenu); + const closeReviewMenu=()=>{if(reviewMenu.matches(':popover-open'))reviewMenu.hidePopover();}; + function openReviewMenu(objects,point){ + closeReviewMenu();reviewMenu.replaceChildren(); + for(const node of objects){const button=document.createElement('button');button.dataset.reviewTarget=node.key;button.textContent=`Add review comment · ${node.label}`;button.onclick=()=>editTargetComment(node.key,node.label);reviewMenu.append(button);} + if(!objects.length)return; + reviewMenu.style.left='0px';reviewMenu.style.top='0px';reviewMenu.showPopover(); + const rect=reviewMenu.getBoundingClientRect(); + reviewMenu.style.left=`${Math.max(8,Math.min(point[0],innerWidth-rect.width-8))}px`; + reviewMenu.style.top=`${Math.max(8,Math.min(point[1],innerHeight-rect.height-8))}px`; + reviewMenu.querySelector('button')?.focus({preventScroll:true}); + } function reviewState(){ const state=model.get('review')??{};reviewPanel.hidden=!state.enabled; - reviewPanel.querySelectorAll('button').forEach(button=>button.disabled=reviewBusy||model.get('status')!=='ready'||!!state.approved); - if(state.enabled&&!reviewLoaded){reviewComment.value=state.comments??'';reviewLoaded=true;} + reviewPanel.querySelectorAll('button').forEach(button=>button.disabled=reviewBusy||reviewImagesUploading>0||model.get('status')!=='ready'||!!state.approved); + reviewDialog.querySelectorAll('[data-review-target-save]').forEach(button=>button.disabled=reviewImagesUploading>0||reviewBusy); + if(state.enabled&&!reviewLoaded){reviewComment.value=state.comments??'';reviewTargets=clone(state.target_comments??[]);reviewAttachments=clone(state.attachments??[]);for(const [id,url] of Object.entries(state.image_previews??{}))reviewImagePreviews.set(id,url);reviewLoaded=true;renderReviewImages();} + reviewComment.style.fontSize=reviewDialog.style.fontSize=`${state.font_size??14}px`; + if(state.font_family&&reviewFontLoaded!==state.font_family){ + reviewFontLoaded=state.font_family; + const data=model.get('review_font_data'); + const bytes=data instanceof DataView?data.buffer.slice(data.byteOffset,data.byteOffset+data.byteLength):data; + const face=new FontFace(`figgy-review-${client}`,bytes); + face.load().then(font=>{document.fonts.add(font);root.style.setProperty('--figgy-review-font',JSON.stringify(font.family));}).catch(e=>{reviewStatus.textContent=`Review font could not be loaded: ${e}`;}); + } if(state.approved)reviewStatus.textContent='Review saved'; } - async function submitReview(disposition){ - if(reviewBusy)return; + async function submitReview(disposition,intent='revise'){ + if(reviewBusy||reviewImagesUploading>0)return; reviewBusy=true;reviewPanel.querySelectorAll('button,textarea').forEach(v=>v.disabled=true); reviewStatus.textContent='Saving review…'; try{ await finishInline(true);await controlQueue;await queue;await flush(); root.querySelector('.figgy-body').inert=true;root.querySelector('.figgy-toolbar').inert=true; trace('review.send',{revision,disposition}); - model.send({action:'review_submit',disposition,comments:reviewComment.value,revision}); + model.send({action:'review_submit',disposition,intent,comments:reviewComment.value,target_comments:reviewTargets,attachments:reviewAttachments,revision}); }catch(e){reviewFailure(String(e));} } function reviewFailure(message){ @@ -83,10 +193,43 @@ export function render({model,el}) { reviewStatus.textContent=message; reviewState(); } - const reviewMessage=message=>{if(message.action==='review_result'){trace('review.result',{error:message.error});reviewFailure(message.error??'Review could not be saved');}}; + const reviewMessage=message=>{if(message.action==='review_image_result'){const pending=reviewImageRequests.get(message.request);reviewImageRequests.delete(message.request);if(pending){if(message.error)pending.reject(message.error);else pending.resolve(message.attachment);}}else if(message.action==='review_result'){trace('review.result',{error:message.error});reviewFailure(message.error??'Review could not be saved');}}; reviewPanel.querySelector('[data-review-changes]').addEventListener('click',()=>submitReview('request_changes')); reviewPanel.querySelector('[data-review-accept]').addEventListener('click',()=>submitReview('accept')); - reviewComment.addEventListener('change',()=>model.send({action:'review_comment',comments:reviewComment.value})); + reviewPanel.querySelector('[data-review-finish]').addEventListener('click',()=>submitReview('accept','finish')); + function saveReviewDraft(){model.send({action:'review_comment',comments:reviewComment.value,target_comments:reviewTargets,attachments:reviewAttachments});} + reviewComment.addEventListener('input',saveReviewDraft); + function editTargetComment(target,label,existing=null){ + closeReviewMenu();reviewDialog.replaceChildren(); + const heading=document.createElement('h3');heading.textContent=`Review comment · ${label}`; + const input=document.createElement('textarea');input.dataset.reviewTargetText='';input.value=existing?.text??'';input.rows=4; + const save=document.createElement('button');save.dataset.reviewTargetSave='';save.textContent='Save comment'; + const cancel=document.createElement('button');cancel.textContent='Cancel';cancel.onclick=()=>reviewDialog.close(); + let attachments=clone(existing?.attachments??[]);const images=document.createElement('div'); + imageEditor(images,input,()=>attachments,items=>{attachments=items;},'reviewTarget'); + save.onclick=()=>{if(reviewImagesUploading||(!input.value.trim()&&!attachments.length))return;const item={id:existing?.id??crypto.randomUUID(),target,label,text:input.value,channel:'ui_target'};if(attachments.length)item.attachments=attachments;reviewTargets=reviewTargets.filter(v=>v.id!==item.id);reviewTargets.push(item);saveReviewDraft();reviewDialog.close();}; + reviewDialog.append(heading,input,images,save,cancel);reviewDialog.showModal();input.focus(); + } + const commentsButton=document.createElement('button');commentsButton.dataset.reviewTargetList='';commentsButton.textContent='Element comments';reviewPanel.querySelector('[data-review-changes]').parentElement.append(commentsButton); + commentsButton.onclick=()=>{reviewDialog.replaceChildren();for(const item of reviewTargets){const row=document.createElement('section'),name=document.createElement('strong'),text=document.createElement('p'),edit=document.createElement('button'),remove=document.createElement('button');name.textContent=item.label??item.target;text.textContent=item.text;edit.textContent='Edit';edit.onclick=()=>{reviewDialog.close();editTargetComment(item.target,item.label??item.target,item);};remove.textContent='Delete';remove.onclick=()=>{reviewTargets=reviewTargets.filter(v=>v.id!==item.id);saveReviewDraft();row.remove();};row.append(name,text,edit,remove);reviewDialog.append(row);}const close=document.createElement('button');close.textContent='Close';close.onclick=()=>reviewDialog.close();reviewDialog.append(close);reviewDialog.showModal();}; + canvas.addEventListener('contextmenu',event=>{if(!chart||!(model.get('review')??{}).enabled||reviewBusy)return;event.preventDefault();event.stopPropagation();const point=localPoint(event),clientPoint=[event.clientX,event.clientY];enqueue(async()=>{ + await geometryFrame();let hits=[]; + const index=kind?0:panelAt(point)??0; + const owner=kind?snapshot()[kind]:snapshot().config; + let annotation=null; + if(index===0&&owner.annotations?.visible!==false&&owner.annotations?.items?.length){await annotationGeometry();annotation=chart.hit_annotation(...point);} + if(Number.isInteger(annotation))hits=[{layer:'Annotations',target:kind?`annotation_${annotation}`:`annotation:${owner.annotations.items[annotation].id}`}]; + else if(kind){const target=chart.hit_decoration(...point,true);if(target)hits=[{layer:'Base',target}];if(!hits.length||hits.every(v=>v.target==='data_area')){const hit=JSON.parse(chart.pick(...point,false));if(hit)hits=[{layer:'Base',target:kind==='radial'?`slice_${hit.group}_${hit.index}`:hit.series_id}];}} + else{hits=decorationsAt(point,index);if(hits.every(v=>v.target.endsWith('data_area'))){const data=await pickPanelCandidates(point,index);if(data.length)hits=data.map(v=>({layer:v.layer,target:v.ref.series_id}));}} + const objects=hits.map(hit=>allNodes().find(v=>v.node.target===hostTarget(hit.target)&&sameLayer(v.node.layer,hit.layer))?.node).filter(Boolean); + openReviewMenu(objects,clientPoint); + });}); + root.addEventListener('pointerdown',event=>{if(!reviewMenu.contains(event.target))closeReviewMenu();}); + root.addEventListener('contextmenu',event=>{ + const row=event.target.closest('[role=treeitem]');if(!row||!(model.get('review')??{}).enabled||reviewBusy)return; + const node=allNodes().find(v=>v.node.key===row.dataset.node)?.node;if(!node?.target)return; + event.preventDefault();event.stopPropagation();openReviewMenu([node],[event.clientX,event.clientY]); + }); model.on('change:review',reviewState);model.on('change:status',reviewState);model.on('msg:custom',reviewMessage);reviewState(); function historyControls(){for(const action of ['undo','redo']){const button=root.querySelector(`[data-${action}]`);button.disabled=historyBusy||!!inflight||!!pending||!editHistory[`can_${action}`];}root.querySelectorAll('[data-reset-edits]').forEach(button=>button.disabled=historyBusy||!!inflight||!!pending||!editHistory.can_reset);} function replayEdit(action,args={}){ @@ -152,7 +295,7 @@ export function render({model,el}) { const overlaps=index===null?[]:overlappingPanels(index);switchChart.hidden=overlaps.length<2; if(!switchChart.hidden){const [x,y,w]=panelRect(index);switchChart.textContent=`Switch chart (${overlaps.length})`;switchChart.style.left=`${(x+w)/canvas.width*100}%`;switchChart.style.top=`${y/canvas.height*100}%`;switchChart.onclick=()=>enqueue(()=>openChooser(overlaps.map(panelChoice),[x+w,y+30]));} const name=index===null?'':text(panelConfig(index).chart_title?.text)||panelId(index); - root.querySelector('[data-hint]').textContent=hasPanels()?(index===null?'Click a chart to select it, then click or drag inside to edit':`${name} selected · Click an axis or data to edit · Click another chart to switch · Esc: leave chart`):'Click an element to edit · Drag to move · Wheel to zoom · Esc: clear · Arrows: move selection'; + root.querySelector('[data-hint]').textContent=(kind==='radar'||(!kind&&configOf(local).polar))?'Polar coordinates · Wheel zoom disabled · Set radial range in Properties':hasPanels()?(index===null?'Click a chart to select it, then click or drag inside to edit':`${name} selected · Click an axis or data to edit · Click another chart to switch · Esc: leave chart`):'Click an element to edit · Drag to move · Wheel to zoom · Esc: clear · Arrows: move selection'; } function layersInPanel(index){const cfg=panelConfig(index);return [baseLayer(index),...(cfg.overlays??[]).map((_,i)=>index===0?{Overlay:i}:{SubplotOverlay:[index-1,i]})];} function setPointerLayer(layer){chart.set_active_layer(JSON.stringify(layer));local.config.active_layer=clone(layer);} @@ -373,7 +516,7 @@ export function render({model,el}) { // Optional CI diagnostics. Never read the WASM chart or wait on GPU work. function diagnostics(){return {kind:kind??'cartesian',busy,queued:queuedOperations, dirty,frame_pending:!!pendingFrame,frame_error:frameError?String(frameError):null, - edit_inflight:inflight?.sequence??null,edit_pending:!!pending, + edit_inflight:inflight?.sequence??null,edit_pending:!!pending,accepted_revision:model.get('state').revision,local_revision:revision, canvas:[canvas.width,canvas.height],disposed};} function trace(stage,details={}){try{globalThis.__figgyTrace?.(stage,{client,...diagnostics(),...details});}catch{}} const requested=new Promise(resolve=>{startInit=resolve;}); @@ -441,8 +584,8 @@ export function render({model,el}) { catch(e){chart.set_series('[]');chart.set_config(JSON.stringify(old.config));chart.set_series(JSON.stringify(old.series));chart.set_config(JSON.stringify(old.config));clearColor(old.background);local=old;throw e;} local={...clone(settings),config:JSON.parse(chart.get_config()),series:JSON.parse(chart.get_series())};dirty=true;error.textContent=''; } - function post(){if(inflight || !pending)return;inflight={sequence:++sequence,settings:pending};pending=null;historyControls();stats.kernel_edit_messages++;model.send({action:'edit',client,sequence,revision,settings:inflight.settings});} - function commit(){local=snapshot();pending=clone(local);post();properties();} + function post(){if(inflight || !pending)return;inflight={sequence:++sequence,settings:pending,draft:pendingDraft};pending=null;pendingDraft=null;historyControls();stats.kernel_edit_messages++;trace('edit.post',{sequence,revision});model.send({action:'edit',client,sequence,revision,settings:inflight.settings});} + function commit(draft=null){local=snapshot();pending=clone(local);pendingDraft=draft;post();properties();} function idle(){if(!inflight && !pending){for(const resolve of barriers.splice(0))resolve();}} function flush(){post();return !inflight&&!pending ? Promise.resolve() : new Promise(resolve=>barriers.push(resolve));} let exportedCode=model.get('state').code ?? ''; @@ -597,6 +740,8 @@ export function render({model,el}) { fields.addEventListener('focusout',()=>setTimeout(()=>{if(formRefreshPending&&!sidebarPointer&&!disposed){formRefreshPending=false;enqueue(()=>properties());}},0)); function properties(refresh=true){ if(!chart)return; + if(pointer?.moved){formRefreshPending=true;return;} + stats.inspector_rebuilds=(stats.inspector_rebuilds??0)+1; let restoreControl=null; try{ if(refresh){local=snapshot();if(kind)decorationIds=JSON.parse(chart.decorations()).map(d=>d.id).filter(id=>id!=='chart_title');} @@ -615,17 +760,29 @@ export function render({model,el}) { if(inspectorLayer&&!sameLayer(inspectorLayer,layerOf(local))){const pending=document.createElement('p');pending.textContent='Selecting object…';fields.append(pending);return;} const target=/^(subplot_|overlay_)/.test(inspectedTarget)?inspectedTarget.split(':').pop():inspectedTarget,cfg=configOf(local); if(['series/','bounded/series/slice_'].some(prefix=>inspectedObject(inspectedTarget)?.id.startsWith(prefix))){root.querySelector('[data-settings]').value=JSON.stringify(local,null,2);root.querySelector('[data-code]').value=code();return;} + const propertyLayer=clone(layerOf(local)),propertyOwners=JSON.stringify(local.identity?.panels); function applyProperty(apply,value){ - const before=snapshot(),state=clone(before); + const base=snapshot(),before=clone(base),state=clone(base); + if(JSON.stringify(before.identity?.panels)!==propertyOwners)throw Error('stale edit: chart ownership changed; select the object again'); + const owner=s=>kind?s[kind]:s.config,visibleLayer=clone(owner(state).active_layer??'Base'); + owner(before).active_layer=clone(propertyLayer);owner(state).active_layer=clone(propertyLayer); apply(state,value); const secondary=before.config.active_layer?.Overlay??before.config.active_layer?.SubplotOverlay?.[1]; if(!kind&&!(secondary!==undefined&&/^(axis|tick_labels)_right$/.test(target)))reconcilePairedAxisEdit({config:configOf(before)},{config:configOf(state)},target); if(/^axis_(bottom|top|left|right)$/.test(target))for(const key of ['bottom_x','left_y'])syncTickSpacing(state,key); - install(state);commit(); + owner(state).active_layer=visibleLayer; + install(state);commit(propertyDraft(base,snapshot())); + } + function submitProperty(apply,value,onError=()=>{}){ + // A draft is not an optimistic full-document edit. Wait outside the + // renderer queue for earlier acknowledgements, then apply only its field + // to the newest accepted state. ACK processing needs that queue itself. + const operation=controlQueue.then(async()=>{await queue;await flush();return enqueue(()=>applyProperty(apply,value),true);}); + controlQueue=operation.catch(e=>{onError();fail(e);}); } function field(label,value,type,apply){ const row=document.createElement('label');row.append(document.createTextNode(label==='visible'&&target.startsWith('axis_')&&!target.startsWith('axis_title_')?'Axis line':caption(label)));const input=document.createElement('input');input.type=type;input.value=value;input.dataset.property=label; - input.addEventListener('change',()=>{const value=type==='number'?Number(input.value):input.value;enqueue(()=>{applyProperty(apply,value);});});row.append(input);fields.append(row);return input; + input.addEventListener('change',()=>{const value=type==='number'?Number(input.value):input.value;trace('property.changed',{field:label,type});submitProperty(apply,value);});row.append(input);fields.append(row);return input; } function color(label,value,find){ const hex='#'+['r','g','b'].map(k=>Math.round(value[k]*255).toString(16).padStart(2,'0')).join(''); @@ -634,7 +791,7 @@ export function render({model,el}) { function choice(label,value,values,apply){ const row=document.createElement('label');row.append(document.createTextNode(label==='visible'&&target.startsWith('axis_')&&!target.startsWith('axis_title_')?'Axis line':caption(label)));const input=document.createElement('select');input.dataset.property=label; for(const v of values){const opt=document.createElement('option');opt.value=v;opt.textContent=label==='label_format'?({Decimal:'Decimal (123.45)',Scientific:'Scientific (1.23e2)',Power:'Power (1.23 × 10²)',Degrees:'Degrees (90°)',Radians:'Radians (π/2)',Timestamp:'Timestamp'}[v]):label==='tick_direction'?(v==='None'?'Hidden':v==='Both'?'Both sides':v):v==='Logarithmic'?'Logarithmic (base 10)':v;input.append(opt);}input.value=value; - input.addEventListener('change',()=>{const next=input.value;enqueue(()=>{try{applyProperty(apply,next);}catch(e){input.value=value;throw e;}});});row.append(input);fields.append(row); + input.addEventListener('change',()=>submitProperty(apply,input.value,()=>{input.value=value;}));row.append(input);fields.append(row); } function scaleControl(label,axis,find,dimension){ choice(label,axis.scale,['Linear','Logarithmic'],(s,v)=>{ @@ -653,7 +810,7 @@ export function render({model,el}) { find(s).significant_digits=v; });digits.min='1';digits.max='15';digits.step='1'; } - function toggle(label,value,apply){const row=document.createElement('label');row.append(document.createTextNode(label==='visible'&&target.startsWith('axis_')&&!target.startsWith('axis_title_')?'Axis line':caption(label)));const input=document.createElement('input');input.type='checkbox';input.checked=value;input.dataset.property=label;input.addEventListener('change',()=>enqueue(()=>{applyProperty(apply,input.checked);}));row.append(input);fields.append(row);} + function toggle(label,value,apply){const row=document.createElement('label');row.append(document.createTextNode(label==='visible'&&target.startsWith('axis_')&&!target.startsWith('axis_title_')?'Axis line':caption(label)));const input=document.createElement('input');input.type='checkbox';input.checked=value;input.dataset.property=label;input.addEventListener('change',()=>submitProperty(apply,input.checked));row.append(input);fields.append(row);} function legendSeries(state){ const cfg=activePanel(state),layer=state.config.active_layer,overlay=layer?.Overlay??layer?.SubplotOverlay?.[1]; const index=layer?.Subplot??layer?.SubplotOverlay?.[0],children=state.config.subplots??[]; @@ -822,11 +979,21 @@ export function render({model,el}) { let groupIndex=0; for(const [group,entries]of groups){ const section=document.createElement('details');section.dataset.propertySection=group; - const sectionKey=JSON.stringify([object.id,group]),defaultOpen=primary&&groupIndex++<2; + const sectionKey=JSON.stringify([object.id,group]),defaultOpen=(primary&&groupIndex++<2)||['Figure size','Panel geometry','Plot area'].includes(group); section.open=propertySections.get(sectionKey)??defaultOpen; section.addEventListener('toggle',()=>propertySections.set(sectionKey,section.open)); const heading=document.createElement('summary');heading.textContent=group;section.append(heading); for(const field of entries){ + if(field.kind==='rectangle'){ + const current=field.value.length?field.value:(kind?JSON.parse(chart.decorations()).find(d=>d.id==='data_area')?.rect:null); + if(!current)continue; + const inputs=[]; + for(const [i,key]of ['x','y','width','height'].entries()){ + const row=document.createElement('label');row.textContent=caption(key);const input=document.createElement('input');input.type='number';input.step='any';input.value=current[i];input.dataset.layoutComponent=key;inputs.push(input); + input.addEventListener('change',()=>replayEdit('property',{object_id:object.id,property_id:field.id,value:inputs.map(v=>Number(v.value)),base_value:field.value}));row.append(input);section.append(row); + } + continue; + } if(field.kind==='action'){ const button=document.createElement('button');button.textContent=field.label;button.dataset.catalogField=field.id; button.addEventListener('click',()=>replayEdit('property',{object_id:object.id,property_id:field.id,value:true,base_value:field.value}));section.append(button);continue; @@ -911,8 +1078,8 @@ export function render({model,el}) { } async function exporting(scale=1){trace('export.flush_started',{scale});await controlQueue;await flush();trace('export.flush_completed');return enqueue(async()=>{cancelChooser();await waitForFrame();stats.png_exports++;trace('export.render_started',{scale});const data=bytes(await chart.export_png(scale));trace('export.render_completed',{bytes:data.byteLength});return data;},true);} const download=data=>{const url=URL.createObjectURL(new Blob([data],{type:'image/png'})),a=document.createElement('a');a.href=url;a.download='figure.png';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}; - function onState(){enqueue(()=>{const state=model.get('state');propertyColumns=clone(state.property_columns??propertyColumns);propertyCatalog=clone(state.property_catalog??propertyCatalog);editHistory=state.edit_history??{};historyControls();outlineNodes=clone(state.outline??outlineNodes);boundedView=clone(state.bounded_view??boundedView);exportedCode=state.code ?? exportedCode;if(state.revision>revision && !inflight){ - revision=state.revision;pending=null; + function onState(){enqueue(()=>{const state=model.get('state');trace('state.received',{incoming_revision:state.revision});propertyColumns=clone(state.property_columns??propertyColumns);propertyCatalog=clone(state.property_catalog??propertyCatalog);editHistory=state.edit_history??{};historyControls();outlineNodes=clone(state.outline??outlineNodes);boundedView=clone(state.bounded_view??boundedView);exportedCode=state.code ?? exportedCode;if(state.revision>revision && !inflight){ + revision=state.revision;pending=null;pendingDraft=null; if(!chart){local=clone(state.settings);return;} try {install(state.settings);properties();} catch(e){fail(e);model.send({action:'rejected',revision,settings:snapshot(),error:String(e)});} @@ -920,11 +1087,22 @@ export function render({model,el}) { function onMessage(message){ if(message.client && message.client!==client)return; if(message.action==='ack')enqueue(()=>{ - if(message.sequence!==inflight?.sequence)return; - outlineNodes=clone(message.state.outline??outlineNodes);boundedView=clone(message.state.bounded_view??boundedView);const latest=pending;inflight=null;revision=message.state.revision;exportedCode=message.state.code ?? exportedCode; - if(message.error){pending=null;install(message.state.settings);fail(message.error);} - else if(latest){pending=latest;install(latest);post();}else{install(message.state.settings);} - propertyColumns=clone(message.state.property_columns??propertyColumns);propertyCatalog=clone(message.state.property_catalog??propertyCatalog);editHistory=message.state.edit_history??{};historyControls();properties();idle(); + trace('edit.ack',{sequence:message.sequence,incoming_revision:message.state.revision,error:message.error,has_pending:!!pending});if(message.sequence!==inflight?.sequence)return; + // A queued full-document edit keeps its original base and stale guard. + // Deferred form drafts have no snapshot to rebase; use the newest accepted head. + const current=model.get('state'),accepted=(message.error||!pending)&¤t.revision>message.state.revision?current:message.state; + outlineNodes=clone(accepted.outline??outlineNodes);boundedView=clone(accepted.bounded_view??boundedView);const latest=pending,draft=inflight.draft;inflight=null;revision=accepted.revision;exportedCode=accepted.code ?? exportedCode; + let retryError=null; + if(message.error?.startsWith('stale edit:')&&draft&&draft.attempts<1&&!latest){ + const rebased=rebasePropertyDraft(draft,accepted.settings); + if(rebased){ + try{install(rebased);pending=snapshot();pendingDraft={...propertyDraft(accepted.settings,pending),attempts:draft.attempts+1};trace('property.retry',{sequence:message.sequence,revision});post();properties();return;} + catch(e){retryError=e;} + } + } + if(message.error){pending=null;pendingDraft=null;install(accepted.settings);fail(retryError??message.error);} + else if(latest){pending=latest;install(latest);post();}else{install(accepted.settings);} + propertyColumns=clone(accepted.property_columns??propertyColumns);propertyCatalog=clone(accepted.property_catalog??propertyCatalog);editHistory=accepted.edit_history??{};historyControls();properties();idle(); }); else if(message.action==='history_result')enqueue(()=>{ try{ @@ -1026,7 +1204,7 @@ export function render({model,el}) { const panels=panelsAt(point);if(focusIndex()===null&&panels.length>1){openChooser(panels.map(panelChoice),point);return;} const at=panelAt(point);selected=null;chart.on_press(-100,-100);chart.on_release();chart.set_picked_data('null'); if(at!==null)setPointerLayer(baseLayer(at)); - local=snapshot();focusedPanel=at===null?null:panelId(at);observedLayer=clone(local.config.active_layer??'Base');wheelArea=null;dirty=true;commit(); + local=snapshot();focusedPanel=at===null?null:panelId(at);observedLayer=clone(local.config.active_layer??'Base');wheelArea=null;if(at!==null&&root.querySelector('[data-area]').checked){selected=elementName('data_area');inspectorTarget=selected;showProperties();}dirty=true;commit(); } canvas.addEventListener('pointerdown',e=>{if(!chart||e.button!==0)return;inspectorOwner=null;if(inlineText){finishInline(true);canvas.focus({preventScroll:true});return;}e.preventDefault();canvas.focus({preventScroll:true});canvas.setPointerCapture(e.pointerId);const point=localPoint(e),pointSize=[canvas.width,canvas.height]; if(drawingTool!=='select'){ @@ -1091,6 +1269,7 @@ export function render({model,el}) { canvas.addEventListener('wheel',e=>{ if(chooser){e.preventDefault();return;} if(inlineText){e.preventDefault();return;} + if(chart&&(kind==='radar'||(!kind&&configOf(snapshot()).polar)))return; if(chart&&kind&&boundedView){ const horizontal=boundedView.axis==='x';if(horizontal?e.shiftKey&&!e.ctrlKey:e.ctrlKey&&!e.shiftKey)return; e.preventDefault();const point=localPoint(e),delta=e.deltaY||e.deltaX; @@ -1168,6 +1347,6 @@ export function render({model,el}) { model.send({action:'ready',client,info:stats});initializedResolve();raf=requestAnimationFrame(frame); },true); }catch(e){initializedReject(e);if(!disposed){fail(e);status.textContent='WebGPU failed';model.send({action:'failed',error:String(e)});}}})(); - return ()=>{window.removeEventListener('pointerup',releaseSidebar,true);window.removeEventListener('pointercancel',releaseSidebar,true);window.removeEventListener('blur',blurDrawing);disposed=true;controlFinish?.();controlFinish=null;startInit();initializedReject(Error('Widget disposed'));cancelAnimationFrame(raf);observer.disconnect();model.off('change:state',onState);model.off('msg:custom',onMessage);model.off('change:review',reviewState);model.off('change:status',reviewState);model.off('msg:custom',reviewMessage);queue.finally(async()=>{try{await pendingFrame;}catch{}chart?.free();chart=null;}).catch(()=>{});model.send({action:'unmounted',client});root.remove();}; + return ()=>{window.removeEventListener('pointerup',releaseSidebar,true);window.removeEventListener('pointercancel',releaseSidebar,true);window.removeEventListener('blur',blurDrawing);disposed=true;controlFinish?.();controlFinish=null;startInit();initializedReject(Error('Widget disposed'));cancelAnimationFrame(raf);observer.disconnect();model.off('change:state',onState);model.off('msg:custom',onMessage);model.off('change:review',reviewState);model.off('change:status',reviewState);model.off('msg:custom',reviewMessage);if(reviewDialog.open)reviewDialog.close();disposeReviewImages();queue.finally(async()=>{try{await pendingFrame;}catch{}chart?.free();chart=null;}).catch(()=>{});model.send({action:'unmounted',client});root.remove();}; } export default {render}; diff --git a/python/figgyplot/widget.py b/python/figgyplot/widget.py index 67925b4..a23fd25 100644 --- a/python/figgyplot/widget.py +++ b/python/figgyplot/widget.py @@ -96,6 +96,7 @@ class FigureWidget(anywidget.AnyWidget): draw_styles = traitlets.Dict().tag(sync=True) auto_prepare = traitlets.Bool(True).tag(sync=True) review = traitlets.Dict().tag(sync=True) + review_font_data = traitlets.Bytes().tag(sync=True) def __init__(self, figure, *, auto_prepare=True): self.figure = figure @@ -119,9 +120,11 @@ def __init__(self, figure, *, auto_prepare=True): def refresh(self): if not self._handling: settings = self.figure.get_settings() + from ._codegen import editor_code_payload from ._native import object_outline_json, bounded_view_json catalog=self.figure._editor_catalog_transport() - self.state = {"settings": settings, "revision": self.figure.revision, "code": self.figure.to_code(), + self.state = {"settings": settings, "revision": self.figure.revision, + **editor_code_payload(settings,self.figure._default_config,self.figure.dpi), "edit_history": self.figure.edit_history, "property_catalog": catalog['objects'], "property_columns": catalog['columns'], @@ -132,10 +135,23 @@ def refresh(self): def _command(self, widget, content, buffers): action = content.get("action") - if action == "review_comment": + if action == 'review_image': + try: + session=getattr(self.figure,'_review_session',None) + if session is None or not self.review.get('enabled') or session._figures.get(self.figure.id) is not self.figure: + raise RuntimeError('This Figure is not an active review view') + if len(buffers)!=1:raise ValueError('Expected one review image buffer') + attachment=session.attach_image(self.figure.id,bytes(buffers[0]),name=content.get('name','Review image.png')) + self.send(dict(action='review_image_result',request=content['request'],attachment=attachment)) + except Exception as exc:self.send(dict(action='review_image_result',request=content.get('request'),error=str(exc))) + elif action == "review_comment": session = getattr(self.figure, '_review_session', None) if session is not None and self.review.get('enabled'): - try:session._set_comment_text(self.figure.id, content.get('comments', '')) + try: + if session._figures.get(self.figure.id) is not self.figure:raise RuntimeError('Stale review view from a previous round') + session._set_comment_text(self.figure.id, content.get('comments', '')) + if 'target_comments' in content:session._set_target_comments(self.figure.id,content['target_comments']) + if 'attachments' in content:session._set_comment_images(self.figure.id,content['attachments']) except Exception as exc:self.send(dict(action='review_result',error=str(exc))) elif action == "review_submit": try: @@ -147,8 +163,10 @@ def _command(self, widget, content, buffers): # The browser submits only after its input queue and accepted # edit acknowledgements are drained. Verify that revision in the # common commit path; do not start another barrier inside a comm. + session._set_target_comments(self.figure.id,content.get('target_comments',[])) + session._set_comment_images(self.figure.id,content.get('attachments',[])) session._submit(self.figure.id, content['disposition'], - content.get('comments'), content['revision']) + content.get('comments'), content['revision'], content.get('intent','revise')) except Exception as exc: self.send(dict(action='review_result',error=str(exc))) elif action == "mounted": diff --git a/scripts/benchmark_wrapper_edits.py b/scripts/benchmark_wrapper_edits.py new file mode 100644 index 0000000..4934348 --- /dev/null +++ b/scripts/benchmark_wrapper_edits.py @@ -0,0 +1,115 @@ +"""Measure wrapper work with a CPU notebook model; no browser or GPU timing. + +Run against an installed wheel: python scripts/benchmark_wrapper_edits.py +The JSON reports warmed median latency, Python allocation peaks, and operation +counts separately. Counts are deterministic; timings are diagnostic, not gates. +""" +import copy +import json +import statistics +import time +import tracemalloc +import figgyplot as fg +from figgyplot import _document, _matplotlib + +result = {} + + +def measure(name, operation, repetitions=9): + operation() + durations = [] + for _ in range(repetitions): + t = time.perf_counter() + operation() + durations.append((time.perf_counter() - t) * 1000) + tracemalloc.start() + operation() + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + result[name] = { + "median_ms": statistics.median(durations), + "peak_python_bytes": peak, + } + + +with fg.Figure( + backend="webgpu", + figsize=(30, 30), + grid=[[f"p{r*5+c}" for c in range(5)] for r in range(5)], +) as fig: + for panel in fig.subplots: + a = fig.subplots[panel].scatter([0, 1], [1, 2], c=[1, 2], id=panel + "-data") + bar = fig.colorbar(a, label="Value") + + def costs(name, fn): + counts = {"snapshots": 0, "full_document_deepcopies": 0, "temporary_figures": 0} + get = fig.get_settings + deep = copy.deepcopy + proxy = _matplotlib._StateFigure + + def snapshot(): + counts["snapshots"] += 1 + return get() + + def copied(value, memo=None, *args, **kwargs): + if memo is None and isinstance(value, dict) and "schema_version" in value: + counts["full_document_deepcopies"] += 1 + return deep(value, memo, *args, **kwargs) + + def temporary(*args, **kwargs): + counts["temporary_figures"] += 1 + return proxy(*args, **kwargs) + + fig.get_settings = snapshot + copy.deepcopy = copied + _matplotlib._StateFigure = temporary + try: + fn() + finally: + fig.get_settings = get + copy.deepcopy = deep + _matplotlib._StateFigure = proxy + measure(name, fn) + result[name].update(counts) + + costs( + "colorbar_ticks_25_panels", + lambda: bar.ax.tick_params(labelsize=13, direction="in", length=5), + ) + costs("colorbar_label_25_panels", lambda: bar.set_label("Value", fontsize=13)) + costs("colorbar_lookup_25_panels", lambda: fig.colorbar(a)) + costs( + "mapped_artist_update_25_panels", + lambda: fig.update(a.id, cmap="gray", vmin=10, vmax=20), + ) + state = fig.get_settings() + + def stage(): + with fig.batch_update(): + fig.apply_settings(state) + + costs("batch_snapshot_25_panels", stage) + for n in (100, 1000): + groups = { + str(i): {"axis": "x", "members": [str(i), str(i + 1)]} + for i in reversed(range(n)) + } + meta = {"groups": groups} + measure( + f"linked_chain_{n}", lambda: _document.tick_owners(meta, "0", "bottom_x"), 3 + ) + state = fig.get_settings() + for n in (100, 1000): + cfg = state["config"] + state["identity"]["panels"] = [{"id": str(i), "overlays": []} for i in range(n)] + cfg["subplots"] = [{"config": {"chart_area": {}}} for _ in range(n - 1)] + state["identity"]["layout"] = { + "grid": [[str(i) for i in range(n)]], + "placement": {str(i): str(i) for i in range(n)}, + "row_weights": [1], + "column_weights": [1] * n, + "gap": 0, + "size": [n * 100, 100], + } + measure(f"grid_place_{n}", lambda: _document.place_grid(state)) +print(json.dumps(result, indent=2)) diff --git a/scripts/ci-preflight.sh b/scripts/ci-preflight.sh index d1b01c5..186483e 100644 --- a/scripts/ci-preflight.sh +++ b/scripts/ci-preflight.sh @@ -2,7 +2,8 @@ set -euo pipefail export FIGGYPLOT_MATURIN_ACTION=${FIGGYPLOT_MATURIN_ACTION:-.ci/maturin-action/action.yml} uv run --no-project --with actionlint-py --with shellcheck-py actionlint +uv run --no-project --with shellcheck-py shellcheck scripts/install_ci_runtime.sh uv run --no-project python scripts/check_assets.py uv run --no-project --with 'PyYAML>=6,<7' python scripts/check_wheel_workflow.py "$FIGGYPLOT_MATURIN_ACTION" -uv run --no-project --with 'PyYAML>=6,<7' --with 'packaging>=24' --with pytest python -m pytest -q tests/test_wheel_workflow.py tests/test_packaged_assets.py tests/test_release.py tests/test_interaction_guard.py tests/test_harness_diagnostics.py tests/test_ci_execution.py +uv run --no-project --with 'PyYAML>=6,<7' --with 'packaging>=24' --with pytest python -m pytest -q tests/test_wheel_workflow.py tests/test_packaged_assets.py tests/test_release.py tests/test_interaction_guard.py tests/test_harness_diagnostics.py tests/test_ci_execution.py tests/test_ci_runtime.py diff --git a/scripts/install_ci_runtime.sh b/scripts/install_ci_runtime.sh new file mode 100644 index 0000000..295d3f5 --- /dev/null +++ b/scripts/install_ci_runtime.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Runtime packages for the pinned ubuntu-24.04 CI image. Do not depend on its +# rotating mirrorlists or unrelated vendor repositories (for example .NET). +set -euo pipefail + +runtime_sources=$(mktemp --suffix=.sources) +trap 'rm -f "$runtime_sources"' EXIT +cat > "$runtime_sources" <<'SOURCES' +Types: deb +URIs: https://archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates noble-backports +Components: main restricted universe multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg + +Types: deb +URIs: https://security.ubuntu.com/ubuntu/ +Suites: noble-security +Components: main restricted universe multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg +SOURCES + +apt_sources=(-o "Dir::Etc::sourcelist=$runtime_sources" -o 'Dir::Etc::sourceparts=-') +sudo apt-get "${apt_sources[@]}" update +sudo apt-get "${apt_sources[@]}" install -y \ + git mesa-vulkan-drivers libvulkan1 libxkbcommon0 libxkbcommon-x11-0 \ + xvfb xdotool xclip openbox wmctrl fonts-noto-cjk diff --git a/scripts/profile_annotation_drag.py b/scripts/profile_annotation_drag.py new file mode 100644 index 0000000..47881ff --- /dev/null +++ b/scripts/profile_annotation_drag.py @@ -0,0 +1,134 @@ +"""Measure real native input without using input-driver wait time as frame time. + +Run an installed release wheel with DISPLAY and FIGGY_PROFILE_HOST=1. Requires +xdotool and an X11 window manager. Stage times are CPU wall times on this host; +they include renderer API calls but are not hardware GPU timestamps or FPS. +""" +import argparse +import json +import os +from pathlib import Path +import subprocess +import time + +import figgyplot as fg +from figgyplot.desktop import poll_events + +CONTROLLER = None + + +def pump(): + for _ in range(4): + if CONTROLLER is not None:CONTROLLER.poll() + else:poll_events() + time.sleep(.003) + + +def main(): + global CONTROLLER + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('output', type=Path) + parser.add_argument('--moves', type=int, default=32) + parser.add_argument('--burst', action='store_true', help='queue all pointer motions before pumping a frame') + parser.add_argument('--review', action='store_true', help='also pump the durable review controller') + args = parser.parse_args() + os.environ['FIGGY_PROFILE_HOST'] = '1' + result = {'kind': 'CPU wall time, excludes input-driver waits; not physical GPU FPS', 'burst':args.burst, 'review':args.review, 'cases': []} + writes=[] + if args.review: + from figgyplot.review import Session + from figgyplot import _review_store + original=_review_store.atomic_write + def timed_write(*a,**kw): + started=time.perf_counter() + try:return original(*a,**kw) + finally:writes.append((time.perf_counter()-started)*1000) + _review_store.atomic_write=timed_write + for width, height, kind in [(960, 600, 'Text'), (1440, 900, 'Text'), (960, 600, 'Arrow')]: + owner=Session(backend='native') if args.review else fg.Figure(figsize=(width/120,height/120),dpi=120,backend='native') + with owner: + fig=owner.figure(id='plot',figsize=(width/120,height/120),dpi=120) if args.review else owner + ax = fig.axes[0] + ax.plot([0, 1, 2], [0, 2, 1], id='signal') + ax.text(.7, .7, 'Preserve this annotation', coords='ratio', id='fixed') + if kind == 'Text': + ax.text(.3, .4, 'Move this annotation', coords='ratio', id='moving') + else: + ax.arrow(.3, .4, .15, .1, coords='ratio', id='moving') + if args.review: + owner.present();window=owner._handles['plot'];CONTROLLER=owner + else:window=fig.show(block=False) + try: + for _ in range(12): + pump() + wid = subprocess.check_output(['xdotool', 'search', '--pid', str(os.getpid()), '--name', '^Figgy$'], text=True).splitlines()[-1] + def send(*command): + before = json.loads(window.info_json())['surface_frames'] + subprocess.run(['xdotool', *map(str, command)], check=True) + deadline = time.monotonic()+10 + while True: + pump() + if json.loads(window.info_json())['surface_frames'] > before: + return + if time.monotonic() > deadline: + raise RuntimeError(('input not presented', command, window.info_json())) + subprocess.run(['xdotool', 'windowmove', wid, '10', '10', 'windowraise', wid, 'windowfocus', wid], check=True) + pump() + bounds = json.loads(fig._native.bounds_json('annotation:moving'))['rect'] + panel = json.loads(window.info_json())['chart_panel'] + x = round(panel[0]+(bounds[0]+bounds[2]/2)*panel[2]/width) + y = round(panel[1]+(bounds[1]+bounds[3]/2)*panel[3]/height) + send('mousemove', '--window', wid, x, y) + send('mousedown', 1) + send('mouseup', 1) + time.sleep(.5) # Avoid interpreting the next press as a double click. + send('mousedown', 1) + before = fig.get_settings() + counters = fig.info['host_work'] + writes_before=len(writes) + if args.burst: + commands = [] + for step in range(1, args.moves+1): + commands += ['mousemove', '--window', wid, str(x+step*2), str(y+step)] + subprocess.run(['xdotool', *commands], check=True) + deadline = time.monotonic()+10 + while True: + pump() + observed = json.loads(window.info_json()).get('pointer_input', {}).get('position') + if observed == [x+args.moves*2, y+args.moves]: + break + assert time.monotonic()=r.x&&point[0]<=r.x+r.width&&point[1]>=r.y&&point[1]<=r.y+r.height).map(|_|(renderer::LayerRef::Base,e.id.element_id()))).collect::>(); + if candidates.len()>1 {candidates.retain(|(_,t)|t!="data_area");} + if candidates.iter().all(|(_,t)|t=="data_area") { + let mut probe=c.data.document.as_ref().unwrap().chart.clone();probe.select_at(&surface.frame,point,false); + let hit=probe.selected_json(); + if let Some(id)=hit["series_id"].as_str().map(str::to_owned).or_else(||hit["index"].as_u64().map(|i|format!("slice_{}_{i}",hit["group"].as_u64().unwrap_or(0)))) {candidates=vec![(renderer::LayerRef::Base,id)];} + } + if candidates.is_empty() {candidates.push((renderer::LayerRef::Base,"data_area".into()));} + candidates + }; + self.editor.review_target(p,&c,targets); + } if self.editor.drawing.tool != crate::drawing::Tool::Select { // press_origin is cleared when egui processes a release. // A short press/release can arrive in the same frame. @@ -473,6 +489,7 @@ impl SurfaceWindow { if let Err(e) = prepared { error = Some(e); } + self.editor.review_context_ui(root); if let Some((space, logical_size)) = inline_space { self.editor.inline_text_ui( root, @@ -622,6 +639,7 @@ impl SurfaceWindow { "layout_probe":self.editor.layout_probe, "column_uploads":c.data.uploads,"rgba_readbacks":c.data.readbacks,"png_encodes":c.data.png_encodes, "revision":c.data.revision, + "property_catalog_builds":self.editor.property_builds, "decorations":self.bounded_surface.as_ref().map(|s|s.frame.decorations().entries().filter_map(|(id,e)|s.frame.decorations().bounds(id).map(|r|(e.id.element_id(),json!([r.x,r.y,r.width,r.height])))).collect::>()), "decoration_selected":c.data.document.as_ref().unwrap().chart.decorations().selected, diff --git a/src/desktop.rs b/src/desktop.rs index d2905b3..284c3e4 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -53,6 +53,29 @@ impl WindowHandle { "null".into() } } + fn review_draft_json(&self) -> String { + let state = self.control.review.lock().unwrap(); + json!({"comments":state["comments"],"target_comments":state["target_comments"],"attachments":state["attachments"]}).to_string() + } + fn review_images_result(&self, json: &str) -> PyResult<()> { + let value: Value = parse(json)?; + let mut state = self.control.review.lock().unwrap(); + state["attachments"] = value["attachments"].clone(); + if let Some(comments) = state["target_comments"].as_array_mut() { + for comment in comments { + if let Some(updated) = value["target_comments"] + .as_array() + .and_then(|items| items.iter().find(|v| v["id"] == comment["id"])) + { + if updated.get("attachments").is_some() { + comment["attachments"] = updated["attachments"].clone(); + } + } + } + } + self.refresh(); + Ok(()) + } fn review_result(&self, error: &str) { let mut state = self.control.review.lock().unwrap(); state["request"] = Value::Null; @@ -94,6 +117,8 @@ struct SurfaceWindow { ui_renderer: egui_wgpu::Renderer, view: Option, styles: Vec, + style_specs: Vec, + last_stamp: Option, last_revision: u64, last_scale: f32, last_pool_generation: u32, @@ -218,6 +243,8 @@ impl SurfaceWindow { ui_renderer, view: None, styles: vec![], + style_specs: vec![], + last_stamp: None, last_revision: u64::MAX, last_scale: 0., last_pool_generation: 0, @@ -349,7 +376,7 @@ impl SurfaceWindow { }) } fn sidebar_pixels(&self) -> u32 { - (editor::INSPECTOR_WIDTH * self.ctx.pixels_per_point()).round() as u32 + (self.editor.inspector_width * self.ctx.pixels_per_point()).round() as u32 } fn sync_inspector_size(&mut self) { if self.settings_only || self.inspector_overlay() { @@ -624,6 +651,16 @@ impl SurfaceWindow { } self.panel_focus .select(c.data.chart.config(), c.data.identity.as_ref(), at); + if self.edit_area + && let Some(index) = at + { + self.selected = Some(if index == 0 { + "data_area".into() + } else { + format!("subplot_{}:data_area", index - 1) + }); + self.editor.inspector_open = true; + } self.last_revision = u64::MAX; self.window.request_redraw(); return Ok(()); @@ -736,12 +773,43 @@ impl SurfaceWindow { } else if self.dragging && let Some(target) = &self.selected { - c.drag( - target, - (self.cursor.0 - old.0) / scale, - (self.cursor.1 - old.1) / scale, - self.handle.as_deref(), - )?; + let stamp = c + .renderer + .chart_render_stamp(c.data.chart_id) + .map_err(runtime)?; + let map = self.view.as_ref().map(|view| view.annotation_map()); + let reusable = target.starts_with("annotation:") + && !stamp.needs_raster_since(self.last_stamp.as_ref()) + && self.last_panel == Some(panel) + && self.last_scale == scale + && map.is_some_and(|map| { + c.annotation_index(target).ok().is_some_and(|i| { + map.layer().items.get(i).map(|v| &v.id) + == c.data + .chart + .config() + .annotations + .items + .get(i) + .map(|v| &v.id) + }) + }); + if reusable { + c.drag_annotation_with_map( + map.unwrap(), + target, + self.cursor.0 - old.0, + self.cursor.1 - old.1, + self.handle.as_deref(), + )?; + } else { + c.drag( + target, + (self.cursor.0 - old.0) / scale, + (self.cursor.1 - old.1) / scale, + self.handle.as_deref(), + )?; + } self.last_input = Some(input_received); self.window.request_redraw(); } @@ -997,6 +1065,9 @@ impl SurfaceWindow { } } WindowEvent::MouseWheel { delta, .. } => { + if active_coordinate_config(&display).polar.is_some() { + return Ok(()); + } // Decorations and the surrounding canvas do not own plot zoom. let Ok(area) = active_coordinate_config(&display).data_area() else { return Ok(()); @@ -1058,6 +1129,7 @@ impl SurfaceWindow { if c.data.document.is_some() && !self.settings_only { return self.frame_bounded(&mut c); } + let frame_started = c.data.host_work.start(); c.renderer .ensure_target_format(self.config.format) .map_err(runtime)?; @@ -1111,6 +1183,7 @@ impl SurfaceWindow { } else { "Click a chart to select it, then click or drag inside to edit".into() }; + let ui_started = c.data.host_work.start(); let mut output = ctx.run_ui(input, |root| { if self.chooser.as_ref().is_some_and(|v| { v.rect.is_positive() @@ -1135,6 +1208,65 @@ impl SurfaceWindow { previous_selection != self.selected && self.selected.is_some(), ); self.fit_stage_width(); + if self.editor.review_enabled() + && root.input(|i| i.pointer.secondary_clicked()) + && self.editor.stage.contains(egui::pos2( + self.cursor.0 / ctx.pixels_per_point(), + self.cursor.1 / ctx.pixels_per_point(), + )) + { + let (display, panel, scale) = self.display(&c); + let index = interaction::panels_at(&display, self.cursor) + .first() + .copied() + .unwrap_or(0); + let mut targets = interaction::decorations_at(&display, index, self.cursor) + .into_iter() + .map(|(layer, target)| (layer, target)) + .collect::>(); + if index == 0 && c.data.chart.config().annotations.visible { + if let Some(i) = self.view.as_ref().and_then(|v| { + let map = v.annotation_map(); + map.element(map.hit_test([self.cursor.0, self.cursor.1])?) + .map(|a| a.index) + }) { + targets = vec![( + renderer::LayerRef::Annotations, + format!( + "annotation:{}", + c.data.chart.config().annotations.items[i].id + ), + )]; + } + } + if targets.iter().all(|(_, v)| v.ends_with("data_area")) { + if let Ok(hits) = c.pick_panel_candidates( + index, + GpuPickRequest { + canvas_position_px: [self.cursor.0, self.cursor.1], + display_panel_px: panel, + display_scale: scale, + max_distance_px: 6., + }, + ) { + if !hits.is_empty() { + targets = hits + .into_iter() + .map(|h| (h.layer, h.reference.series_id().to_owned())) + .collect(); + } + } + } + self.editor.review_target( + egui::pos2( + self.cursor.0 / ctx.pixels_per_point(), + self.cursor.1 / ctx.pixels_per_point(), + ), + &c, + targets, + ); + } + self.editor.review_context_ui(root); // Tree/toolbar edits cancel a stale chooser before applying anything. if self .chooser @@ -1215,6 +1347,7 @@ impl SurfaceWindow { }); } }); + c.data.host_work.finish("native_ui", ui_started); if std::mem::take(&mut self.editor.history_replayed) { fn contains(nodes: &[navigation::Node], target: &str) -> bool { nodes @@ -1288,15 +1421,21 @@ impl SurfaceWindow { egui::vec2(r.width as f32 / dpr, r.height as f32 / dpr), ) .shrink(2.); - output.shapes.push(egui::epaint::ClippedShape { - clip_rect: self.editor.stage, - shape: egui::Shape::rect_stroke( - rect, - 3., - egui::Stroke::new(1.5, egui::Color32::from_rgb(38, 99, 173)), - egui::StrokeKind::Inside, - ), - }); + // This is a chart-stage decoration. Keep it below egui's + // floating windows and menus, including their opaque fills. + // Appending it paints the outline through review text inputs. + output.shapes.insert( + 0, + egui::epaint::ClippedShape { + clip_rect: self.editor.stage, + shape: egui::Shape::rect_stroke( + rect, + 3., + egui::Stroke::new(1.5, egui::Color32::from_rgb(38, 99, 173)), + egui::StrokeKind::Inside, + ), + }, + ); } } if !self.settings_only && self.view.is_none() { @@ -1306,12 +1445,19 @@ impl SurfaceWindow { .map_err(runtime)?, ); } + let current_stamp = c + .renderer + .chart_render_stamp(c.data.chart_id) + .map_err(runtime)?; + let specs = c.gpu_series(&c.data.series); + let view_changed = scale != self.last_scale || self.last_panel != Some(panel); + let pool_changed = self.last_pool_generation != c.renderer.pool().generation(); if !self.settings_only && (c.data.revision != self.last_revision || self.selected != self.rendered_selection - || scale != self.last_scale - || self.last_pool_generation != c.renderer.pool().generation() - || self.last_panel != Some(panel)) + || current_stamp.needs_draw_since(self.last_stamp.as_ref()) + || view_changed + || pool_changed) { let preview_target = self .chooser @@ -1337,17 +1483,38 @@ impl SurfaceWindow { .selected .as_deref() .and_then(|target| c.annotation_index(target).ok()); - c.renderer - .refresh_axis_with_selection( - self.view.as_mut().unwrap(), - &Chart::new(display.clone()), - display.chart_area.0, - &boxes, - ) - .map_err(runtime)?; + // Follow the renderer's invalidation contract, like its web host. + // A document revision alone does not mean axes or styles changed. + let refresh_axis = current_stamp.needs_raster_since(self.last_stamp.as_ref()) + || view_changed + || self.last_revision == u64::MAX + || (self.selected != self.rendered_selection + && self + .selected + .iter() + .chain(self.rendered_selection.iter()) + .any(|target| !target.starts_with("annotation:"))); + if refresh_axis { + let raster_started = c.data.host_work.start(); + c.renderer + .refresh_axis_with_selection( + self.view.as_mut().unwrap(), + &Chart::new(display.clone()), + display.chart_area.0, + &boxes, + ) + .map_err(runtime)?; + c.data + .host_work + .finish("native_axis_refresh", raster_started); + } // Let the renderer draw the selection from the current annotation // raster. A box taken from the previous view is stale after an edit. - if self.view.as_ref().unwrap().annotation_selected() != annotation_selected { + if (!refresh_axis + && current_stamp.needs_annotation_raster_since(self.last_stamp.as_ref())) + || self.view.as_ref().unwrap().annotation_selected() != annotation_selected + { + let annotation_started = c.data.host_work.start(); c.renderer .refresh_annotations( self.view.as_mut().unwrap(), @@ -1356,31 +1523,39 @@ impl SurfaceWindow { annotation_selected, ) .map_err(runtime)?; + c.data + .host_work + .finish("native_annotation_refresh", annotation_started); } - let specs = c.gpu_series(&c.data.series); - self.styles = specs - .iter() - .zip(&c.data.series) - .map(|(s, local)| { - let mut style_spec = s.clone(); - style_spec.series_id = local.series_id.clone(); - c.renderer - .create_style_for_series_scaled(&style_spec, scale) - .map_err(runtime) - }) - .collect::>>()?; + if self.style_specs != specs || scale != self.last_scale || pool_changed { + let styles_started = c.data.host_work.start(); + self.styles = specs + .iter() + .zip(&c.data.series) + .map(|(s, local)| { + let mut style_spec = s.clone(); + style_spec.series_id = local.series_id.clone(); + c.renderer + .create_style_for_series_scaled(&style_spec, scale) + .map_err(runtime) + }) + .collect::>>()?; + c.data.host_work.finish("native_styles", styles_started); + self.style_specs = specs.clone(); + } + self.last_stamp = Some(current_stamp); self.last_revision = c.data.revision; self.rendered_selection = self.selected.clone(); self.last_scale = scale; self.last_pool_generation = c.renderer.pool().generation(); self.last_panel = Some(panel); } - let specs = c.gpu_series(&c.data.series); let series = specs .iter() .zip(&self.styles) .map(|(config, style)| renderer::Series { config, style }) .collect::>(); + let prepare_started = c.data.host_work.start(); let prepared = if self.settings_only { None } else { @@ -1394,6 +1569,8 @@ impl SurfaceWindow { .map_err(runtime)?, ) }; + c.data.host_work.finish("native_prepare", prepare_started); + let present_started = c.data.host_work.start(); let frame = match self.surface.get_current_texture() { wgpu::CurrentSurfaceTexture::Success(f) | wgpu::CurrentSurfaceTexture::Suboptimal(f) => f, @@ -1457,6 +1634,9 @@ impl SurfaceWindow { .submit(extra.into_iter().chain(std::iter::once(encoder.finish()))); c.renderer.queue().present(frame); c.renderer.end_gpu_frame(); + c.data + .host_work + .finish("native_submit_present", present_started); for id in &output.textures_delta.free { self.ui_renderer.free_texture(id); } @@ -1512,7 +1692,8 @@ impl SurfaceWindow { let index=view.annotation_selected()?;let id=view.annotation_map().find(index)?;let bounds=view.annotation_map().selection_box(id)?; Some(json!({"index":index,"rect":[bounds.rect.x,bounds.rect.y,bounds.rect.width,bounds.rect.height]})) }); - *self.control.stats.lock().unwrap() = json!({"pointer_sequence":self.pointer_sequence,"pointer_input":self.pointer_input,"layout_probe":self.editor.layout_probe,"choice_candidates":self.chooser.as_ref().map(|v|v.choices.iter().map(|v|json!({"key":v.key,"label":v.label,"detail":v.detail})).collect::>()),"choice_preview":self.chooser.as_ref().and_then(|v|v.preview()).map(|v|&v.key),"focused_panel":self.panel_focus.id,"annotation_selection":annotation_selection,"host":"winit-wgpu","layout_mode":self.edit_area,"drawing_tool":self.editor.drawing.tool.name(),"window_role":if self.settings_only { "settings" } else { "chart" },"inspector_overlay":self.inspector_overlay(),"picked_values":self.editor.picked,"selection_value_readbacks":c.data.selection_value_readbacks,"error":self.editor.error,"controls":controls,"decorations":decorations,"selected":self.selected,"properties_open":self.editor.inspector_open,"chart_panel":[panel.x,panel.y,panel.width,panel.height],"gpu_context_id":c.context.id,"figure_id":c.data.figure_id,"pool_used_bytes":c.renderer.pool().used_bytes(),"surface_frames":self.frames,"last_input_to_submit_ms":self.latency_ms,"scale_factor":self.window.scale_factor(),"surface_size":[self.config.width,self.config.height],"column_uploads":c.data.uploads,"rgba_readbacks":c.data.readbacks,"png_encodes":c.data.png_encodes,"revision":c.data.revision}); + c.data.host_work.finish("native_frame", frame_started); + *self.control.stats.lock().unwrap() = json!({"property_catalog_builds":self.editor.property_builds,"pointer_sequence":self.pointer_sequence,"pointer_input":self.pointer_input,"layout_probe":self.editor.layout_probe,"choice_candidates":self.chooser.as_ref().map(|v|v.choices.iter().map(|v|json!({"key":v.key,"label":v.label,"detail":v.detail})).collect::>()),"choice_preview":self.chooser.as_ref().and_then(|v|v.preview()).map(|v|&v.key),"focused_panel":self.panel_focus.id,"annotation_selection":annotation_selection,"host":"winit-wgpu","layout_mode":self.edit_area,"drawing_tool":self.editor.drawing.tool.name(),"window_role":if self.settings_only { "settings" } else { "chart" },"inspector_overlay":self.inspector_overlay(),"picked_values":self.editor.picked,"selection_value_readbacks":c.data.selection_value_readbacks,"error":self.editor.error,"controls":controls,"decorations":decorations,"selected":self.selected,"properties_open":self.editor.inspector_open,"chart_panel":[panel.x,panel.y,panel.width,panel.height],"gpu_context_id":c.context.id,"figure_id":c.data.figure_id,"pool_used_bytes":c.renderer.pool().used_bytes(),"surface_frames":self.frames,"last_input_to_submit_ms":self.latency_ms,"scale_factor":self.window.scale_factor(),"surface_size":[self.config.width,self.config.height],"column_uploads":c.data.uploads,"rgba_readbacks":c.data.readbacks,"png_encodes":c.data.png_encodes,"revision":c.data.revision}); Ok(()) } } @@ -1650,6 +1831,17 @@ impl ApplicationHandler for App { w.control.close.store(true, Ordering::Release); return; } + if let WindowEvent::KeyboardInput { event: ref key, .. } = event { + let paste = key.state == ElementState::Pressed + && !key.repeat + && ((w.modifiers.control_key() || w.modifiers.super_key()) + && matches!(&key.logical_key,Key::Character(v) if v.eq_ignore_ascii_case("v")) + || w.modifiers.shift_key() && key.logical_key == Key::Named(NamedKey::Insert)); + if paste && w.editor.paste_review_image() { + w.window.request_redraw(); + return; + } + } let response = w.input.on_window_event(&w.window, &event); let pointer = match &event { WindowEvent::CursorMoved { position, .. } => Some(json!({ diff --git a/src/edit_history.rs b/src/edit_history.rs index c930685..b31adea 100644 --- a/src/edit_history.rs +++ b/src/edit_history.rs @@ -96,6 +96,9 @@ impl FigureAccess<'_> { self.data.history.begin(state, self.data.revision, label); } pub(crate) fn end_edit(&mut self) { + if self.data.history.gesture.is_none() { + return; + } let state = serde_json::to_value(self.snapshot()).unwrap(); self.data.history.finish(state, self.data.revision); } diff --git a/src/editor.rs b/src/editor.rs index 28a864d..1f9c1ca 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1,5 +1,6 @@ //! Native editing controls. Chart validation/rendering remain renderer APIs. use super::*; +mod review; use egui::{Color32, RichText, Stroke}; pub const INSPECTOR_WIDTH: f32 = 320.; @@ -7,6 +8,17 @@ pub const INSPECTOR_WIDTH: f32 = 320.; pub(crate) struct Editor { pub(crate) review: Option>>, pub inspector_open: bool, + pub inspector_width: f32, + review_font: String, + review_context: review::Context, + pub(super) property_cache: Option<( + String, + renderer::LayerRef, + Option, + u64, + crate::property_catalog::Object, + )>, + pub(crate) property_builds: u64, pub selection_hint: String, pub panel_focused: bool, pub(super) drawing: crate::drawing::Drawing, @@ -17,6 +29,7 @@ pub(crate) struct Editor { pub advanced_requested: bool, pub picked: Option, pub(super) navigator: navigation::Navigator, + navigation_cache: Option<(u64, Vec)>, save_path: String, raw: String, text_ranges: HashMap>, @@ -36,6 +49,11 @@ impl Default for Editor { Self { review: None, inspector_open: false, + inspector_width: INSPECTOR_WIDTH, + review_font: String::new(), + review_context: Default::default(), + property_cache: None, + property_builds: 0, selection_hint: String::new(), panel_focused: true, drawing: Default::default(), @@ -46,6 +64,7 @@ impl Default for Editor { advanced_requested: false, picked: None, navigator: navigation::Navigator::default(), + navigation_cache: None, save_path: "figure.png".into(), raw: String::new(), text_ranges: HashMap::new(), @@ -71,17 +90,40 @@ impl Editor { if state.is_null() { return; } + let family = state["font_family"].as_str().unwrap_or(""); + if !family.is_empty() && family != self.review_font { + match crate::ui_fonts::install_review(root.ctx(), family) { + Ok(()) => self.review_font = family.into(), + Err(e) => self.error = e, + } + root.ctx().request_repaint(); + return; + } + let font = egui::FontId::new( + state["font_size"].as_f64().unwrap_or(14.) as f32, + if self.review_font.is_empty() { + egui::FontFamily::Proportional + } else { + egui::FontFamily::Name("figgy-review".into()) + }, + ); let pending = !state["request"].is_null(); + let mut attachments = state["attachments"].as_array().cloned().unwrap_or_default(); let mut comments = state["comments"].as_str().unwrap_or("").to_owned(); egui::Panel::bottom("review_comments") - .exact_size(148.) + .exact_size(if attachments.is_empty(){180.}else{262.}) .frame( egui::Frame::NONE .fill(Color32::from_rgb(245, 248, 252)) .inner_margin(12), ) .show(root, |ui| { - ui.label(RichText::new("Review comments").strong()); + ui.horizontal(|ui|{ + ui.label(RichText::new("Review comments").strong()); + let button=ui.button(format!("Element comments ({})",state["target_comments"].as_array().map_or(0,Vec::len))); + self.controls.insert("review_target_list".into(),button.rect); + if button.clicked(){self.review_context.list=true;} + }); ui.add_enabled_ui(!pending, |ui| { let input = egui::ScrollArea::vertical() .id_salt("review_comment_scroll") @@ -90,7 +132,7 @@ impl Editor { .auto_shrink([false, false]) .show(ui, |ui| { ui.add( - egui::TextEdit::multiline(&mut comments) + egui::TextEdit::multiline(&mut comments).font(font.clone()) .desired_width(f32::INFINITY) .desired_rows(2) .hint_text("Describe changes for the next version…"), @@ -100,17 +142,20 @@ impl Editor { "review_comments".into(), input.inner.rect.intersect(input.inner_rect), ); + self.review_context.general_focused=input.inner.has_focus(); + self.review_context.images.editor(ui,&mut attachments,state["cache_dir"].as_str().unwrap_or(""),"review",&mut self.controls); ui.horizontal(|ui| { - for (key, label, disposition) in [ - ("review_changes", "Request changes", "request_changes"), - ("review_accept", "Accept", "accept"), + for (key, label, disposition, intent) in [ + ("review_changes", "Request changes", "request_changes", "revise"), + ("review_accept", "Accept", "accept", "revise"), + ("review_finish", "Save and finish", "accept", "finish"), ] { let button = ui.button(label); self.controls.insert(key.into(), button.rect); if button.clicked() { c.end_edit(); - state["request"] = json!({"disposition":disposition, - "comments":comments,"revision":c.data.revision,"finalized":false}); + state["request"] = json!({"disposition":disposition,"intent":intent, + "comments":comments,"attachments":attachments,"target_comments":state["target_comments"],"revision":c.data.revision,"finalized":false}); state["error"] = json!(""); } } @@ -127,6 +172,7 @@ impl Editor { }); }); state["comments"] = json!(comments); + state["attachments"] = json!(attachments); } pub(crate) fn finalize_review(&self, revision: u64) { @@ -548,7 +594,7 @@ impl Editor { .map_err(runtime)?; Python::attach(|py| { py.import("figgyplot._codegen")? - .getattr("settings_to_code")? + .getattr("editor_settings_to_code")? .call1((settings, defaults, c.data.code_dpi))? .extract::() }) @@ -585,7 +631,9 @@ impl Editor { .inner_margin(egui::Margin::symmetric(18, 3))) .show(root, |ui| { let help = ui.label(RichText::new( - "Ctrl + wheel: Zoom X · Shift + wheel: Zoom Y · Wheel: Zoom XY · Drag plot: Pan · Click: Edit") + if interaction::panel(c.data.chart.config(),interaction::panel_index(c.data.chart.config().active_layer())).polar.is_some() { + "Polar coordinates · Wheel zoom disabled · Set ranges in Properties · Click: Edit" + } else {"Ctrl + wheel: Zoom X · Shift + wheel: Zoom Y · Wheel: Zoom XY · Drag plot: Pan · Click: Edit"}) .size(12.).color(Color32::from_rgb(105, 116, 131))); self.controls.insert("navigation_help".into(), help.rect); }); @@ -623,14 +671,26 @@ impl Editor { // Menus use Foreground. Keep the panel below them so reopening // a menu cannot let its parent panel intercept the item click. .order(egui::Order::Middle) - .fixed_pos(egui::pos2(area.right() - INSPECTOR_WIDTH, area.top())) + .fixed_pos(egui::pos2(area.right() - self.inspector_width, area.top())) .movable(false) .show(root.ctx(), |ui| { + let left = + egui::Rect::from_min_size(ui.min_rect().min, egui::vec2(7., area.height())); + let resize = ui + .interact(left, egui::Id::new("inspector_resize"), egui::Sense::drag()) + .on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + self.controls.insert("inspector_resize".into(), resize.rect); + if resize.dragged() { + self.inspector_width = (self.inspector_width + - ui.input(|i| i.pointer.delta().x)) + .clamp(280., 640.); + } egui::Frame::NONE .fill(Color32::from_rgb(248, 249, 251)) .inner_margin(18) .show(ui, |ui| { - ui.set_width(INSPECTOR_WIDTH - 36.); + ui.set_width(self.inspector_width - 36.); + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); ui.set_height((area.height() - 36.).max(100.)); self.inspector(ui, c, selected); }); @@ -659,10 +719,20 @@ impl Editor { } }); }); - let nodes = navigation::nodes(&c.snapshot()); + self.navigator.review_enabled = self.review_enabled(); + if self + .navigation_cache + .as_ref() + .is_none_or(|(revision, _)| *revision != c.data.revision) + { + let started = c.data.host_work.start(); + self.navigation_cache = Some((c.data.revision, navigation::nodes(&c.snapshot()))); + c.data.host_work.finish("native_navigation", started); + } + let nodes = &self.navigation_cache.as_ref().unwrap().1; if let Some(node) = self.navigator.show( ui, - &nodes, + nodes, c.data.chart.config().active_layer(), selected.as_deref(), &mut self.controls, @@ -678,6 +748,7 @@ impl Editor { *selected = node.target; self.picked = None; } + self.take_tree_review_target(); ui.add_space(6.); let context = format!("{:?}/{:?}", c.data.chart.config().active_layer(), selected); let context_changed = context != self.inspector_context; @@ -1126,16 +1197,19 @@ impl Editor { } fn properties(&mut self, ui: &mut egui::Ui, c: &mut FigureAccess<'_>, target: &str) { - let snapshot = c.snapshot(); - let object = crate::property_catalog::selected( - &snapshot, - target, - c.data.chart.config().active_layer(), - self.inspector_owner.as_deref(), - ); + if target.starts_with("annotation:") && c.data.document.is_none() { + // The basic form may accept a text edit. Describe its resulting + // revision once, rather than building the full catalog on both + // sides of each keystroke. + self.basic_properties(ui, c, target); + self.catalog_properties(ui, c, target, false); + return; + } + let object = self.inspector_object(ui, c, target); let primary = object.as_ref().is_some_and(|v| { v.id.starts_with("series/") || v.id.starts_with("figure/") + || target.ends_with("data_area") || target .rsplit(':') .next() @@ -1150,21 +1224,30 @@ impl Editor { self.catalog_properties(ui, c, target, false); } fn basic_properties(&mut self, ui: &mut egui::Ui, c: &mut FigureAccess<'_>, target: &str) { - let mut state = serde_json::to_value(c.snapshot()).unwrap(); if let Some(id) = target.strip_prefix("annotation:") { - if let Some(item) = state["config"]["annotations"]["items"] - .as_array_mut() - .and_then(|items| items.iter_mut().find(|v| v["id"] == id)) + if let Some(index) = c + .data + .chart + .config() + .annotations + .items + .iter() + .position(|v| v.id == id) { - let changed = self.annotation_properties(ui, item); - if self.delete_annotation { - state["config"]["annotations"]["items"] - .as_array_mut() - .unwrap() - .retain(|v| v["id"] != id); - } + let mut item = + serde_json::to_value(&c.data.chart.config().annotations.items[index]).unwrap(); + let changed = self.annotation_properties(ui, &mut item); if changed || self.delete_annotation { - match c.apply_settings(&state.to_string()) { + let result = (|| { + let mut config = c.data.chart.config().clone(); + if self.delete_annotation { + config.annotations.items.remove(index); + } else { + config.annotations.items[index] = parse(&item.to_string())?; + } + c.commit_config(config) + })(); + match result { Ok(_) => self.error.clear(), Err(error) => self.error = error.to_string(), } @@ -1172,6 +1255,7 @@ impl Editor { } return; } + let mut state = serde_json::to_value(c.snapshot()).unwrap(); let scoped_path = match c.data.chart.config().active_layer() { renderer::LayerRef::Subplot(i) | renderer::LayerRef::SubplotOverlay(i, _) => { format!("/config/subplots/{i}/config") @@ -1577,10 +1661,9 @@ fn text_field( return false; }; ui.label(label); - let response = ui.add( - egui::TextEdit::singleline(&mut value) - .hint_text(hint) - .desired_width(f32::INFINITY), + let response = ui.add_sized( + [ui.available_width(), ui.spacing().interact_size.y], + egui::TextEdit::singleline(&mut value).hint_text(hint), ); controls.insert(key.into(), response.rect); if response.changed() { diff --git a/src/editor/review.rs b/src/editor/review.rs new file mode 100644 index 0000000..c1f9d4e --- /dev/null +++ b/src/editor/review.rs @@ -0,0 +1,256 @@ +use super::*; + +#[derive(Default)] +pub(super) struct Context { + menu: Option<(egui::Pos2, Vec<(String, String)>)>, + target: Option<(String, String)>, + text: String, + dialog_point: egui::Pos2, + editing: Option, + pub(super) list: bool, + pub(super) attachments: Vec, + pub(super) images: crate::review_images::ImageUi, + pub(super) general_focused: bool, + pub(super) target_focused: bool, +} +impl Editor { + pub(crate) fn paste_review_image(&mut self) -> bool { + let target_focused = + self.review_context.target.is_some() && self.review_context.target_focused; + if !self.review_enabled() || (!target_focused && !self.review_context.general_focused) { + return false; + } + let item = match crate::review_images::clipboard() { + Ok(item) => item, + Err(error) => { + if error == "Clipboard does not contain an image" { + return false; + } + self.review_context.images.error = error; + return true; + } + }; + if target_focused { + crate::review_images::append(&mut self.review_context.attachments, item); + } else if let Some(shared) = &self.review { + let mut state = shared.lock().unwrap(); + if !state["attachments"].is_array() { + state["attachments"] = json!([]); + } + let items = state["attachments"].as_array_mut().unwrap(); + crate::review_images::append(items, item); + } + true + } + pub(crate) fn review_enabled(&self) -> bool { + self.review.as_ref().is_some_and(|shared| { + let state = shared.lock().unwrap(); + state["enabled"] == true && state["request"].is_null() + }) + } + pub(crate) fn take_tree_review_target(&mut self) { + if let Some(node) = self.navigator.comment_target.take() { + self.review_context.target = Some((node.key, node.label)); + self.review_context.text.clear(); + self.review_context.editing = None; + self.review_context.attachments.clear(); + } + } + pub(crate) fn review_target( + &mut self, + point: egui::Pos2, + c: &FigureAccess<'_>, + targets: Vec<(renderer::LayerRef, String)>, + ) { + if !self.review_enabled() { + return; + } + fn label(nodes: &[navigation::Node], id: &str) -> Option { + for node in nodes { + if node.key == id { + return Some(node.label.clone()); + } + if let Some(v) = label(&node.children, id) { + return Some(v); + } + } + None + } + let snapshot = c.snapshot(); + self.review_context.dialog_point = point; + let outline = navigation::nodes(&snapshot); + let objects = targets + .into_iter() + .filter_map(|(layer, target)| { + crate::property_catalog::selected(&snapshot, &target, layer, None).map(|object| { + ( + object.id.clone(), + label(&outline, &object.id).unwrap_or_else(|| object_name(&target, c.data)), + ) + }) + }) + .collect::>(); + if !objects.is_empty() { + self.review_context.menu = Some((point, objects)); + } + } + pub(crate) fn review_context_ui(&mut self, root: &mut egui::Ui) { + let Some(shared) = self.review.clone() else { + return; + }; + let mut state = shared.lock().unwrap(); + if state.is_null() || !state["request"].is_null() { + return; + } + if root.input(|i| i.key_pressed(egui::Key::Escape)) + && !self.review_context.images.dismiss_preview() + { + self.review_context.menu = None; + self.review_context.target = None; + self.review_context.list = false; + } + let font = egui::FontId::new( + state["font_size"].as_f64().unwrap_or(14.) as f32, + if self.review_font.is_empty() { + egui::FontFamily::Proportional + } else { + egui::FontFamily::Name("figgy-review".into()) + }, + ); + if let Some((point, targets)) = self.review_context.menu.clone() { + let area = egui::Area::new(egui::Id::new("review_context_menu")) + .order(egui::Order::Foreground) + .fixed_pos(point) + .show(root.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + for (id, label) in targets { + let button = ui.button(format!("Add review comment · {label}")); + self.controls + .insert(format!("review_target:{id}"), button.rect); + if button.clicked() { + self.review_context.target = Some((id, label)); + self.review_context.text.clear(); + self.review_context.editing = None; + self.review_context.attachments.clear(); + self.review_context.menu = None; + } + } + }); + }); + if root.input(|i| { + i.key_pressed(egui::Key::Escape) + || i.pointer.any_pressed() + && i.pointer + .interact_pos() + .is_some_and(|p| !area.response.rect.contains(p)) + }) { + self.review_context.menu = None; + } + } + if let Some((target, label)) = self.review_context.target.clone() { + egui::Window::new("Element review comment").id(egui::Id::new("review_target_dialog")).collapsible(false).default_width(380.).default_pos(self.review_context.dialog_point+egui::vec2(10.,10.)).show(root.ctx(),|ui|{ + ui.label(label.clone()); + let input=ui.add(egui::TextEdit::multiline(&mut self.review_context.text).font(font.clone()).desired_width(f32::INFINITY).desired_rows(4)); + self.controls.insert("review_target_text".into(),input.rect); + self.review_context.target_focused=input.has_focus(); + self.review_context.images.editor(ui,&mut self.review_context.attachments,state["cache_dir"].as_str().unwrap_or(""),"review_target",&mut self.controls); + ui.horizontal(|ui|{ + let save=ui.add_enabled(!self.review_context.text.trim().is_empty() || !self.review_context.attachments.is_empty(),egui::Button::new("Save comment")); + self.controls.insert("review_target_save".into(),save.rect); + if save.clicked(){ + if !state["target_comments"].is_array(){state["target_comments"]=json!([]);} + let comments=state["target_comments"].as_array_mut().unwrap(); + let id=self.review_context.editing.take().unwrap_or_else(||format!("ui-{}-{}",std::process::id(),std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos())); + comments.retain(|v|v["id"]!=id); + let mut comment=json!({"id":id,"target":target,"label":label,"text":self.review_context.text,"channel":"ui_target"}); + if !self.review_context.attachments.is_empty(){comment["attachments"]=json!(self.review_context.attachments);} + comments.push(comment); + self.review_context.target=None; + } + let cancel=ui.button("Cancel");self.controls.insert("review_target_cancel".into(),cancel.rect); + if cancel.clicked(){self.review_context.target=None;} + }); + }); + } + if self.review_context.list { + let mut open = true; + egui::Window::new("Element review comments") + .id(egui::Id::new("review_target_list")) + .open(&mut open) + .default_width(420.) + .show(root.ctx(), |ui| { + let mut remove = None; + for (i, item) in state["target_comments"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .enumerate() + { + ui.group(|ui| { + ui.label( + item["label"] + .as_str() + .or_else(|| item["target"].as_str()) + .unwrap_or("Chart element"), + ); + ui.label( + RichText::new(item["text"].as_str().unwrap_or("")) + .font(font.clone()), + ); + let edit = ui.button("Edit"); + self.controls.insert( + format!("review_target_edit:{}", item["id"].as_str().unwrap()), + edit.rect, + ); + if edit.clicked() { + self.review_context.target = Some(( + item["target"].as_str().unwrap().into(), + item["label"].as_str().unwrap_or("Chart element").into(), + )); + self.review_context.text = item["text"].as_str().unwrap().into(); + self.review_context.editing = + Some(item["id"].as_str().unwrap().into()); + self.review_context.attachments = + item["attachments"].as_array().cloned().unwrap_or_default(); + } + let delete = ui.button("Delete"); + self.controls.insert( + format!("review_target_delete:{}", item["id"].as_str().unwrap()), + delete.rect, + ); + if delete.clicked() { + remove = Some(i); + } + }); + } + if let Some(i) = remove { + state["target_comments"].as_array_mut().unwrap().remove(i); + } + }); + self.review_context.list = open; + } + let mut image_ids = std::collections::HashSet::new(); + for image in state["attachments"].as_array().into_iter().flatten().chain( + state["target_comments"] + .as_array() + .into_iter() + .flatten() + .flat_map(|c| c["attachments"].as_array().into_iter().flatten()), + ) { + if let Some(id) = image["id"].as_str() { + image_ids.insert(id.to_owned()); + } + } + if self.review_context.target.is_some() { + for image in &self.review_context.attachments { + if let Some(id) = image["id"].as_str() { + image_ids.insert(id.to_owned()); + } + } + } + self.review_context.images.retain(&image_ids); + self.review_context + .images + .preview(root.ctx(), state["cache_dir"].as_str().unwrap_or("")); + } +} diff --git a/src/host_work.rs b/src/host_work.rs new file mode 100644 index 0000000..0c7c0ba --- /dev/null +++ b/src/host_work.rs @@ -0,0 +1,54 @@ +//! Opt-in, bounded timings of binding work. These are CPU wall times, not GPU timings. +use serde_json::{Value, json}; +use std::{cell::RefCell, collections::BTreeMap, time::Instant}; + +#[derive(Default)] +struct Samples { + count: u64, + total_ms: f64, + recent_ms: std::collections::VecDeque, +} + +pub(crate) struct Profile { + enabled: bool, + samples: RefCell>, +} + +impl Default for Profile { + fn default() -> Self { + Self { + enabled: std::env::var("FIGGY_PROFILE_HOST").as_deref() == Ok("1"), + samples: Default::default(), + } + } +} + +impl Profile { + pub fn start(&self) -> Option { + self.enabled.then(Instant::now) + } + pub fn finish(&self, name: &'static str, start: Option) { + if let Some(start) = start { + let ms = start.elapsed().as_secs_f64() * 1000.; + let mut samples = self.samples.borrow_mut(); + let entry = samples.entry(name).or_default(); + entry.count += 1; + entry.total_ms += ms; + entry.recent_ms.push_back(ms); + if entry.recent_ms.len() > 120 { + entry.recent_ms.pop_front(); + } + } + } + pub fn snapshot(&self) -> Value { + let entries = self.samples.borrow().iter().map(|(name, entry)| { + let mut values: Vec<_> = entry.recent_ms.iter().copied().collect(); + values.sort_by(f64::total_cmp); + (name.to_string(), json!({"count":entry.count,"total_ms":entry.total_ms, + "recent_count":values.len(),"p50_ms":values[values.len()/2], + "p95_ms":values[((values.len() as f64 * 0.95).ceil() as usize-1).min(values.len()-1)], + "max_ms":values.last()})) + }).collect::>(); + Value::Object(entries) + } +} diff --git a/src/identity.rs b/src/identity.rs index d92185b..ebb45ec 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -415,9 +415,13 @@ impl FigureAccess<'_> { } pub(super) fn annotation_view(&self) -> PyResult { + let started = self.data.host_work.start(); let chart = Chart::new(gpu::config(self.data.figure_id, self.data.chart.config())); - self.renderer + let result = self + .renderer .create_chart_view(&chart, chart.config().chart_area.0) - .map_err(runtime) + .map_err(runtime); + self.data.host_work.finish("annotation_view", started); + result } } diff --git a/src/lib.rs b/src/lib.rs index 51ce634..d68e391 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,7 @@ use renderer::{ }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +mod review_images; use std::{ collections::HashMap, sync::{Arc, Mutex}, @@ -202,6 +203,7 @@ struct FigureCore { columns: HashMap, revision: u64, history: edit_history::History, + host_work: host_work::Profile, uploads: u64, readbacks: u64, png_encodes: u64, @@ -598,9 +600,12 @@ impl FigureAccess<'_> { .ok_or_else(|| invalid(format!("unknown decoration: {target}"))) } fn commit_config(&mut self, config: Config) -> PyResult<()> { + let started = self.data.host_work.start(); let mut state = self.snapshot(); state.config = config; - self.apply_settings(&serde_json::to_string(&state).map_err(runtime)?) + let result = self.apply_settings_model(state); + self.data.host_work.finish("commit_config", started); + result } fn snapshot(&self) -> Settings { Settings { @@ -1006,6 +1011,7 @@ impl FigureCore { columns: HashMap::new(), revision: 0, history: Default::default(), + host_work: Default::default(), uploads: 0, readbacks: 0, png_encodes: 0, @@ -1197,9 +1203,14 @@ impl FigureAccess<'_> { Ok(()) } fn apply_settings(&mut self, json: &str) -> PyResult<()> { + let started = self.data.host_work.start(); + let result = parse(json).and_then(|next| self.apply_settings_model(next)); + self.data.host_work.finish("apply_settings", started); + result + } + fn apply_settings_model(&mut self, mut next: Settings) -> PyResult<()> { let before = serde_json::to_value(self.snapshot()).map_err(runtime)?; let base_revision = self.data.revision; - let mut next: Settings = parse(json)?; if !self.data.history.restoring_identities() { identity::track_identities(&before, &mut next)?; } @@ -1555,6 +1566,18 @@ impl FigureAccess<'_> { .map(|h| format!("{h:?}"))) } fn drag(&mut self, target: &str, dx: f32, dy: f32, handle: Option<&str>) -> PyResult { + let started = self.data.host_work.start(); + let result = self.drag_inner(target, dx, dy, handle); + self.data.host_work.finish("drag", started); + result + } + fn drag_inner( + &mut self, + target: &str, + dx: f32, + dy: f32, + handle: Option<&str>, + ) -> PyResult { if !dx.is_finite() || !dy.is_finite() { return Err(invalid("drag must be finite")); } @@ -1563,25 +1586,7 @@ impl FigureAccess<'_> { } if target.starts_with("annotation:") { let view = self.annotation_view()?; - let map = view.annotation_map(); - let id = map - .find(self.annotation_index(target)?) - .ok_or_else(|| invalid("annotation is not visible"))?; - let mut config = self.data.chart.config().clone(); - let moved = if let Some(handle) = handle { - let handle = ALL_HANDLES - .into_iter() - .find(|h| format!("{h:?}") == handle) - .ok_or_else(|| invalid("unknown resize handle"))?; - map.resize_by(&mut config.annotations, id, handle, dx, dy) - } else { - map.drag_by(&mut config.annotations, id, dx, dy) - }; - if moved == Some(renderer::layout::NudgeResult::Moved) { - self.commit_config(config)?; - return Ok(true); - } - return Ok(false); + return self.drag_annotation_with_map(view.annotation_map(), target, dx, dy, handle); } let id = self.element(target)?; @@ -1607,6 +1612,55 @@ impl FigureAccess<'_> { Ok(false) } } + /// The presented view already owns the renderer's measured anchor space. + /// Deltas must be in that map's pixels (surface pixels for a native view). + fn drag_annotation_with_map( + &mut self, + map: &renderer::annotation::AnnotationMap, + target: &str, + dx: f32, + dy: f32, + handle: Option<&str>, + ) -> PyResult { + let started = self.data.host_work.start(); + if !dx.is_finite() || !dy.is_finite() { + return Err(invalid("drag must be finite")); + } + let index = self.annotation_index(target)?; + if map.layer().items.get(index).map(|item| &item.id) + != self + .data + .chart + .config() + .annotations + .items + .get(index) + .map(|item| &item.id) + { + return Err(invalid( + "annotation frame must be refreshed after reordering", + )); + } + let id = map + .find(index) + .ok_or_else(|| invalid("annotation is not visible"))?; + let mut config = self.data.chart.config().clone(); + let moved = if let Some(handle) = handle { + let handle = ALL_HANDLES + .into_iter() + .find(|h| format!("{h:?}") == handle) + .ok_or_else(|| invalid("unknown resize handle"))?; + map.resize_by(&mut config.annotations, id, handle, dx, dy) + } else { + map.drag_by(&mut config.annotations, id, dx, dy) + }; + let moved = moved == Some(renderer::layout::NudgeResult::Moved); + if moved { + self.commit_config(config)?; + } + self.data.host_work.finish("annotation_move", started); + Ok(moved) + } fn pick_json(&mut self, py: Python<'_>, x: f32, y: f32) -> PyResult { if self.data.document.is_some() { return py.detach(|| self.pick_document(x, y)); @@ -1685,7 +1739,7 @@ impl FigureAccess<'_> { + u64::from(t.row_bytes) * u64::from(t.height) }); json!({"adapter": self.context.adapter_info, "gpu_context_id":self.context.id,"figure_id":self.data.figure_id,"gpu_sharing":"process","active_figures":self.renderer.chart_order().len(),"figure_column_bytes":self.data.columns.values().map(|v|v.0 as u64 * 8).sum::(), "revision": self.data.revision, "column_uploads": self.data.uploads, "rgba_readbacks": self.data.readbacks, "png_encodes": self.data.png_encodes, "columns": self.data.columns.len(), - "selection_value_readbacks":self.data.selection_value_readbacks,"pool_capacity_bytes": self.renderer.pool().capacity(), "pool_used_bytes": self.renderer.pool().used_bytes(), + "host_work":self.data.host_work.snapshot(),"selection_value_readbacks":self.data.selection_value_readbacks,"pool_capacity_bytes": self.renderer.pool().capacity(), "pool_used_bytes": self.renderer.pool().used_bytes(), "known_gpu_bytes": self.renderer.gpu_memory_usage().total_bytes() + host_bytes + self.data.document.as_ref().map_or(0, |r| r.gpu_bytes()), "allocation_mode": if self.data.document.is_some() { "bounded" } else { "resident" }, "preparation": self.data.preparation}).to_string() } } @@ -1699,6 +1753,7 @@ mod drawing; mod edit_history; mod editor; mod gpu; +mod host_work; mod identity; mod interaction; mod navigation; @@ -1905,17 +1960,25 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(desktop::run_events, m)?)?; m.add_function(wrap_pyfunction!(persistent_settings_json, m)?)?; m.add_function(wrap_pyfunction!(default_colorbar_json, m)?)?; + m.add_function(wrap_pyfunction!(canonical_style_state_json, m)?)?; m.add_function(wrap_pyfunction!(rebuild_legend_json, m)?)?; m.add_function(wrap_pyfunction!(bounded::default_radial_json, m)?)?; m.add_function(wrap_pyfunction!(bounded::default_bounded_json, m)?)?; m.add_function(wrap_pyfunction!(draw_style_specs_json, m)?)?; m.add_function(wrap_pyfunction!(font_family_available, m)?)?; + m.add_function(wrap_pyfunction!(review_font_data, m)?)?; m.add_function(wrap_pyfunction!(text_segments_json, m)?)?; m.add_function(wrap_pyfunction!(segments_plain_text, m)?)?; m.add_function(wrap_pyfunction!(fitted_chart_title_margin, m)?)?; + m.add_function(wrap_pyfunction!(fitted_colorbar_title_margin, m)?)?; m.add_function(wrap_pyfunction!(fitted_range_json, m)?)?; m.add_function(wrap_pyfunction!(navigation::object_outline_json, m)?)?; m.add_function(wrap_pyfunction!(property_catalog::editor_catalog_json, m)?)?; + m.add_function(wrap_pyfunction!( + property_catalog::review_object_bindings_json, + m + )?)?; + m.add_function(wrap_pyfunction!(review_images::review_image_json, m)?)?; m.add_function(wrap_pyfunction!(property_catalog::editor_property_json, m)?)?; m.add_function(wrap_pyfunction!(navigation::bounded_view_json, m)?)?; m.add_function(wrap_pyfunction!(axis_edit::sync_linked_axis_edit_json, m)?)?; @@ -1992,6 +2055,36 @@ fn fitted_chart_title_margin(config: &str) -> PyResult { Ok(config.chart_title.top_margin) } +/// Grow the default colourbar label band using the renderer's painted bounds. +/// Python uses this CPU-only helper for both native and notebook label setters. +/// Manual title offsets and explicit layout edits stay owned by the caller. +#[pyfunction] +fn fitted_colorbar_title_margin(config: &str) -> PyResult { + let config: Config = parse(config)?; + let bar = config + .colorbar + .as_ref() + .ok_or_else(|| invalid("no colorbar"))?; + let margin = bar.axis.out_margin; + let measure = CpuTextMeasure::for_style(&config.draw_style); + let (Some(title), Some(ticks)) = ( + ColorBarTitleElement.bounds(&config, &measure), + ColorBarLabelElement.bounds(&config, &measure), + ) else { + return Ok(margin); + }; + let gap = (bar.axis.title_option.text.font_size * 0.25).max(4.0); + let overlap = match bar.side { + Side::Right => ticks.x + ticks.width + gap - title.x, + Side::Left => title.x + title.width + gap - ticks.x, + Side::Bottom => ticks.y + ticks.height + gap - title.y, + Side::Top => title.y + title.height + gap - ticks.y, + }; + // The renderer centers the title halfway through out_margin; doubling + // the missing separation moves its inner edge past the tick labels. + Ok(margin + 2.0 * overlap.max(0.0)) +} + #[pyfunction] fn fitted_range_json(config: &str, axis: &str, min: f64, max: f64) -> PyResult { if !min.is_finite() || !max.is_finite() || min >= max { @@ -2008,6 +2101,17 @@ fn default_colorbar_json() -> PyResult { serde_json::to_string(&default::default_colorbar_options()).map_err(runtime) } +/// Canonical CPU serialization used by the public-setter export compiler. +/// Apply the same f32/default omission rules as accepted native/browser state; +/// never fit ranges, inspect columns, acquire a Figure lock, or render here. +#[pyfunction] +fn canonical_style_state_json(settings: &str) -> PyResult { + // Keep the exact typed serializer used by accepted settings_json. + // to_value(f32) would expose promoted binary approximation digits. + let state: Settings = parse(settings)?; + serde_json::to_string(&state).map_err(invalid) +} + #[pyfunction] #[pyo3(signature = (series, rich, colormap=None))] fn rebuild_legend_json(series: &str, rich: &str, colormap: Option<&str>) -> PyResult { @@ -2026,3 +2130,9 @@ fn rebuild_legend_json(series: &str, rich: &str, colormap: Option<&str>) -> PyRe } serde_json::to_string(&rich).map_err(runtime) } + +#[pyfunction] +fn review_font_data(py: Python<'_>, family: &str) -> PyResult<(Py, u32)> { + let (bytes, index) = ui_fonts::resolve(family).map_err(invalid)?; + Ok((PyBytes::new(py, &bytes).unbind(), index)) +} diff --git a/src/navigation.rs b/src/navigation.rs index 674567f..ad7d968 100644 --- a/src/navigation.rs +++ b/src/navigation.rs @@ -509,6 +509,8 @@ pub(super) fn bounded_view_json(settings: &str) -> PyResult { #[derive(Default)] pub(super) struct Navigator { + pub review_enabled: bool, + pub comment_target: Option, pub filter: String, open: HashMap, last_selection: String, @@ -557,13 +559,21 @@ impl Navigator { } ui.label(egui::RichText::new("OBJECTS").strong().small()); ui.horizontal(|ui| { - let search = ui.add( + let width = (ui.available_width() - 24. - ui.spacing().item_spacing.x).max(20.); + let search = ui.add_sized( + [width, ui.spacing().interact_size.y], egui::TextEdit::singleline(&mut self.filter) - .hint_text("Find a panel, axis or series…") - .desired_width(ui.available_width() - 30.), + .hint_text("Find a panel, axis or series…"), ); controls.insert("object_search".into(), search.rect); - if ui.small_button("×").on_hover_text("Clear search").clicked() { + if ui + .add_sized( + [24., ui.spacing().interact_size.y], + egui::Button::new("×").small(), + ) + .on_hover_text("Clear search") + .clicked() + { self.filter.clear(); reveal = true; } @@ -656,6 +666,14 @@ impl Navigator { if selected && reveal { response.scroll_to_me(Some(egui::Align::Center)); } + if self.review_enabled && node.target.is_some() { + response.context_menu(|ui| { + if ui.button("Add review comment…").clicked() { + self.comment_target = Some((*node).clone()); + ui.close(); + } + }); + } if response.clicked() { response.request_focus(); if node.target.is_some() || node.kind == "layer" { diff --git a/src/property_catalog.rs b/src/property_catalog.rs index ef23efe..7c6c9b1 100644 --- a/src/property_catalog.rs +++ b/src/property_catalog.rs @@ -2,6 +2,7 @@ //! Descriptions are derived from accepted SSOT; they are not a second model. use super::*; use renderer::annotation::AnnotationSpace; +mod layout; mod links; mod optional; @@ -16,7 +17,7 @@ pub(crate) struct Field { pub choices: Vec, pub integer: bool, } -#[derive(Serialize)] +#[derive(Clone, Serialize)] pub(crate) struct Object { pub id: String, pub target: Option, @@ -345,6 +346,139 @@ fn panel_path(layer: renderer::LayerRef) -> String { _ => "/config".into(), } } +/// SSOT roots use the same layer/decoration addresses as the editor. Review +/// summaries consume these bindings instead of inventing another object model. +fn decoration_path(node: &navigation::Node) -> Option { + let short = node.target.as_deref()?.rsplit(':').next()?; + let panel = panel_path(node.layer); + Some(match short { + "chart_title" => format!("{panel}/chart_title"), + "legend" => format!("{panel}/legend"), + "data_area" => format!("{panel}/chart_area"), + _ if short.starts_with("colorbar") => format!("{panel}/colorbar"), + _ if short.starts_with("axis_") || short.starts_with("tick_labels_") => { + let side = short.rsplit('_').next()?; + let axis = match side { + "bottom" => "bottom_x", + "top" => "top_x", + "left" => "left_y", + _ => "right_y", + }; + let base = match node.layer { + renderer::LayerRef::Overlay(i) | renderer::LayerRef::SubplotOverlay(_, i) + if side == "right" => + { + format!("{panel}/overlays/{i}/y_axis") + } + _ => format!("{panel}/{axis}"), + }; + if short.starts_with("axis_title_") { + format!("{base}/title_option") + } else if short.starts_with("tick_labels_") { + format!("{base}/label_style") + } else { + base + } + } + _ => return None, + }) +} + +#[pyfunction] +pub(crate) fn review_object_bindings_json(settings: &str) -> PyResult { + fn paths(state: &Value, node: &navigation::Node) -> Vec { + let family = ["radial", "categorical", "boxplot", "radar"] + .into_iter() + .find(|k| state[*k].is_object()); + let target = node.target.as_deref().unwrap_or(""); + if node.kind == "figure" { + return vec![String::new()]; + } + if let Some((_, id)) = node.key.split_once("/annotation/") { + let owner = family.map_or("/config".into(), |f| format!("/{f}")); + let root = format!("{owner}/annotations/items"); + return state + .pointer(&root) + .and_then(Value::as_array) + .and_then(|v| v.iter().position(|v| v["id"] == id)) + .map(|i| vec![format!("{root}/{i}")]) + .unwrap_or_default(); + } + if let Some(family) = family { + let root = format!("/{family}"); + if node.kind == "panel" { + return vec![root]; + } + if target == "chart_title" { + return vec![format!("{root}/title"), format!("{root}/chart_title")]; + } + if target == "legend" { + return vec![format!("{root}/legend")]; + } + if node.key.starts_with("bounded/series/") { + for name in ["series", "slices", "split/children"] { + let path = format!("{root}/{name}"); + if let Some(items) = state.pointer(&path).and_then(Value::as_array) { + if let Some(i) = items.iter().enumerate().find_map(|(i, v)| { + let fallback = if name == "slices" { + format!("slice_0_{i}") + } else if name == "split/children" { + format!("slice_1_{i}") + } else { + String::new() + }; + (v["id"].as_str().unwrap_or(&fallback) == target).then_some(i) + }) { + return vec![format!("{path}/{i}")]; + } + } + } + } + // Fields without a separable bounded object belong to its panel; + // the complete raw changes remain available there. + return vec![]; + } + if node.key.starts_with("series/") { + return state["series"] + .as_array() + .and_then(|v| v.iter().position(|v| v["series_id"] == target)) + .map(|i| vec![format!("/series/{i}")]) + .unwrap_or_default(); + } + let panel = panel_path(node.layer); + if node.kind == "panel" { + return vec![panel]; + } + if node.kind == "layer" { + if let renderer::LayerRef::Overlay(i) | renderer::LayerRef::SubplotOverlay(_, i) = + node.layer + { + return vec![format!("{panel}/overlays/{i}")]; + } + } + if target.ends_with("colorbar_title") { + return vec![format!("{panel}/colorbar/axis/title_option")]; + } + if target.ends_with("colorbar_tick_labels") { + return vec![format!("{panel}/colorbar/axis/label_style")]; + } + if target.ends_with("colorbar_axis") { + return vec![format!("{panel}/colorbar/axis")]; + } + decoration_path(node).into_iter().collect() + } + fn visit(state: &Value, nodes: &[navigation::Node], out: &mut Vec) { + for node in nodes { + out.push(json!({"id":node.key,"label":node.label,"kind":node.kind,"paths":paths(state,node)})); + visit(state, &node.children, out); + } + } + let settings: Settings = parse(settings)?; + let state = serde_json::to_value(&settings).map_err(runtime)?; + let mut out = vec![]; + visit(&state, &navigation::nodes(&settings), &mut out); + serde_json::to_string(&out).map_err(runtime) +} fn add_default( out: &mut Vec, state: &Value, @@ -918,40 +1052,7 @@ fn build(state: &Value, node: &navigation::Node, sources: &[Value]) -> Object { } } else { let config = state.pointer(&panel).unwrap(); - let path = if short == "chart_title" { - Some(format!("{panel}/chart_title")) - } else if short == "legend" { - Some(format!("{panel}/legend")) - } else if short.starts_with("colorbar") { - Some(format!("{panel}/colorbar")) - } else if short == "data_area" { - Some(format!("{panel}/chart_area")) - } else if short.starts_with("axis_") || short.starts_with("tick_labels_") { - let side = short.rsplit('_').next().unwrap(); - let axis = match side { - "bottom" => "bottom_x", - "top" => "top_x", - "left" => "left_y", - _ => "right_y", - }; - let base = match node.layer { - renderer::LayerRef::Overlay(i) | renderer::LayerRef::SubplotOverlay(_, i) - if side == "right" => - { - format!("{panel}/overlays/{i}/y_axis") - } - _ => format!("{panel}/{axis}"), - }; - Some(if short.starts_with("axis_title_") { - format!("{base}/title_option") - } else if short.starts_with("tick_labels_") { - format!("{base}/label_style") - } else { - base - }) - } else { - None - }; + let path = decoration_path(node); if short.starts_with("colorbar") { colorbar_fields(fields, state, &panel); let path = if short == "colorbar_title" { @@ -1054,8 +1155,10 @@ fn build(state: &Value, node: &navigation::Node, sources: &[Value]) -> Object { } } } + layout::fields(fields, state, node); out.fields.sort_by_key(|field| { let rank = match field.group.as_str() { + "Figure size" | "Panel geometry" | "Plot area" => 0, "Line" | "Vectors" | "Bars" | "Contours" | "Appearance" | "Figure grid" => 0, "Title" | "Tick labels" => 0, "Markers" => 1, @@ -1086,13 +1189,12 @@ fn build(state: &Value, node: &navigation::Node, sources: &[Value]) -> Object { } impl editor::Editor { - pub(super) fn catalog_properties( + pub(super) fn inspector_object( &mut self, - ui: &mut egui::Ui, - c: &mut FigureAccess<'_>, + ui: &egui::Ui, + c: &FigureAccess<'_>, target: &str, - primary: bool, - ) { + ) -> Option { let layer = c .data .document @@ -1100,12 +1202,45 @@ impl editor::Editor { .map_or(c.data.chart.config().active_layer(), |d| { d.chart.active_layer() }); - let Some(object) = selected( + let dragging = ui.input(|i| { + i.pointer.primary_down() + && i.pointer + .press_origin() + .is_some_and(|p| self.stage.contains(p)) + }); + if let Some((name, owner, preferred, revision, value)) = &self.property_cache { + if name == target + && *owner == layer + && preferred == &self.inspector_owner + && (dragging || *revision == c.data.revision) + { + return Some(value.clone()); + } + } + let value = selected( &c.snapshot(), target, layer, self.inspector_owner.as_deref(), - ) else { + )?; + self.property_builds += 1; + self.property_cache = Some(( + target.into(), + layer, + self.inspector_owner.clone(), + c.data.revision, + value.clone(), + )); + Some(value) + } + pub(super) fn catalog_properties( + &mut self, + ui: &mut egui::Ui, + c: &mut FigureAccess<'_>, + target: &str, + primary: bool, + ) { + let Some(object) = self.inspector_object(ui, c, target) else { return; }; let mut groups = Vec::::new(); @@ -1140,12 +1275,28 @@ impl editor::Editor { } let response = egui::CollapsingHeader::new(group) .id_salt((&object.id, group)) - .default_open(primary && index < 2) + .default_open((primary && index < 2 && !target.ends_with("data_area")) || matches!(group.as_str(), "Panel geometry" | "Plot area" | "Figure size")) .show(ui, |ui| { for field in fields { let mut value = field.value.clone(); let mut changed = false; let response = ui.push_id((&object.id,&field.id),|ui|match field.kind.as_str() { + "rectangle" => { + let mut numbers=value.as_array().unwrap().iter().filter_map(Value::as_f64).collect::>(); + if numbers.is_empty() { + if let Ok(frame)=c.document_frame() { + if let Some(r)=frame.decorations().entries().find(|(_,e)|e.id.element_id()=="data_area").and_then(|(id,_)|frame.decorations().bounds(id)) {numbers=vec![r.x as f64,r.y as f64,r.width as f64,r.height as f64];} + } + } + let mut response=ui.label(&field.label); + if numbers.len()==4 { + for (i,key) in ["x","y","width","height"].into_iter().enumerate() { + ui.horizontal(|ui| {ui.label(key);let r=ui.add(egui::DragValue::new(&mut numbers[i]).speed(1.));changed|=r.changed();self.controls.insert(format!("plot_{key}"),r.rect);response=r;}); + } + value=json!(numbers); + } + response + } "action" => { let r=ui.button(&field.label); changed=r.clicked(); @@ -1223,7 +1374,7 @@ impl editor::Editor { let focused=ui.memory(|m|m.has_focus(id)); let draft=self.property_text.entry(format!("{}/{}",object.id,field.id)).or_insert_with(||(initial.clone(),field.value.clone(),c.data.revision)); if !focused&&draft.1!=field.value {*draft=(initial,field.value.clone(),c.data.revision);} - let r = ui.add(egui::TextEdit::singleline(&mut draft.0).id(id)); + let r = ui.add_sized([ui.available_width(),ui.spacing().interact_size.y],egui::TextEdit::singleline(&mut draft.0).id(id)); if r.gained_focus(){draft.1=field.value.clone();draft.2=c.data.revision;} if r.lost_focus() { let text=&draft.0; @@ -1380,6 +1531,9 @@ pub(crate) fn apply( if field.kind != "action" && value == field.value { return Ok(result); } + if layout::apply(&mut result, &object, field, &value)? { + return Ok(result); + } if field.group == "Shared axes" { links::apply(&mut result, object.layer, &field.id, &value)?; return Ok(result); diff --git a/src/property_catalog/layout.rs b/src/property_catalog/layout.rs new file mode 100644 index 0000000..825573e --- /dev/null +++ b/src/property_catalog/layout.rs @@ -0,0 +1,179 @@ +//! One accepted-state path for document, panel and plot geometry. +use super::*; + +pub(super) fn fields(out: &mut Vec, state: &Value, node: &navigation::Node) { + let bounded = ["radial", "categorical", "boxplot", "radar"] + .into_iter() + .find(|k| state[*k].is_object()); + if node.kind == "figure" { + let config: Config = serde_json::from_value(state["config"].clone()).unwrap(); + let area = config.document_area(); + for (key, value) in [("width", area.width), ("height", area.height)] { + field( + out, + json!(value), + &format!("/config/chart_area/{key}"), + "Figure size", + key, + if key == "width" { + "Width (logical px)" + } else { + "Height (logical px)" + }, + ); + } + } + if node.kind != "panel" { + return; + } + let path = bounded.map_or_else( + || panel_path(node.layer), + |kind| format!("/{kind}/decorations"), + ); + let value = if bounded.is_some() { + let a = &state.pointer(&path).unwrap()["data_area"]; + if a.is_object() { + json!([a["x"], a["y"], a["width"], a["height"]]) + } else { + json!([]) + } + } else { + let config: Config = serde_json::from_value(state.pointer(&path).unwrap().clone()).unwrap(); + match config.data_area() { + Ok(a) => json!([ + a.0.x as f64 - config.chart_area.0.x as f64, + a.0.y as f64 - config.chart_area.0.y as f64, + a.0.width, + a.0.height + ]), + Err(_) => json!([]), + } + }; + out.push(Field { + id: "Plot area/bounds".into(), + path: format!("{path}/data_area"), + group: "Plot area".into(), + label: "Position and size (logical px)".into(), + kind: "rectangle".into(), + value, + choices: vec![], + integer: false, + }); + if bounded.is_some() { + out.push(Field { + id: "Plot area/automatic".into(), + path: format!("{path}/data_area"), + group: "Plot area".into(), + label: "Use automatic plot layout".into(), + kind: "action".into(), + value: json!(true), + choices: vec![], + integer: false, + }); + } +} + +pub(super) fn apply( + result: &mut Value, + object: &Object, + field: &Field, + value: &Value, +) -> PyResult { + if field.group == "Figure size" { + let size = value + .as_u64() + .filter(|v| (100..=16384).contains(v)) + .ok_or_else(|| { + invalid("Figure dimensions must be integers from 100 to 16384 logical pixels") + })?; + let key = field.path.rsplit('/').next().unwrap(); + let dimension = if key == "width" { 0 } else { 1 }; + if result["identity"]["layout"].is_object() { + result["identity"]["layout"]["size"][dimension] = json!(size); + reflow_grid(result)?; + } else { + let old = field.value.as_f64().unwrap(); + for config in std::iter::once(&mut result["config"]) { + let scale = size as f64 / old; + for name in [if dimension == 0 { "x" } else { "y" }, key] { + let v = config["chart_area"][name].as_f64().unwrap(); + config["chart_area"][name] = json!((v * scale).round() as u32); + } + if let Some(panels) = config.get_mut("subplots").and_then(Value::as_array_mut) { + for panel in panels { + for name in [if dimension == 0 { "x" } else { "y" }, key] { + let v = panel["config"]["chart_area"][name].as_f64().unwrap(); + panel["config"]["chart_area"][name] = json!((v * scale).round() as u32); + } + } + } + } + } + return Ok(true); + } + if field.id == "Plot area/automatic" { + let (parent, key) = field.path.rsplit_once('/').unwrap(); + result + .pointer_mut(parent) + .ok_or_else(|| invalid("plot layout is unavailable"))?[key] = Value::Null; + return Ok(true); + } + if field.id != "Plot area/bounds" { + return Ok(false); + } + let a = value + .as_array() + .filter(|a| a.len() == 4) + .ok_or_else(|| invalid("Plot bounds require x, y, width and height"))?; + let a = a + .iter() + .map(|v| { + v.as_f64() + .filter(|v| v.is_finite()) + .ok_or_else(|| invalid("Plot bounds must be finite")) + }) + .collect::>>()?; + if a[2] < 1. || a[3] < 1. { + return Err(invalid("Plot dimensions must be positive")); + } + if field.path.starts_with("/config") { + let path = panel_path(object.layer); + let mut config: Config = + serde_json::from_value(result.pointer(&path).unwrap().clone()).map_err(invalid)?; + let current = config.data_area().map_err(|e| invalid(format!("{e:?}")))?.0; + let ca = config.chart_area.0; + let changes = [ + (a[0] + ca.x as f64 - current.x as f64, "left_y"), + ( + current.x as f64 + current.width as f64 - ca.x as f64 - a[0] - a[2], + "right_y", + ), + (a[1] + ca.y as f64 - current.y as f64, "top_x"), + ( + current.y as f64 + current.height as f64 - ca.y as f64 - a[1] - a[3], + "bottom_x", + ), + ]; + for (delta, key) in changes { + let axis = match key { + "left_y" => &mut config.left_y, + "right_y" => &mut config.right_y, + "top_x" => &mut config.top_x, + _ => &mut config.bottom_x, + }; + axis.out_margin += delta as f32; + if key == "right_y" { + for overlay in &mut config.overlays { + overlay.y_axis.out_margin += delta as f32; + } + } + } + config.validate().map_err(|e| invalid(format!("{e:?}")))?; + *result.pointer_mut(&path).unwrap() = serde_json::to_value(config).unwrap(); + } else { + let (parent, key) = field.path.rsplit_once('/').unwrap(); + result.pointer_mut(parent).unwrap()[key] = + json!({"x":a[0],"y":a[1],"width":a[2],"height":a[3]}); + } + Ok(true) +} diff --git a/src/radial_editor.rs b/src/radial_editor.rs index b2e78c5..c910a09 100644 --- a/src/radial_editor.rs +++ b/src/radial_editor.rs @@ -169,7 +169,7 @@ impl Editor { let code = c.settings_json().and_then(|settings| { Python::attach(|py| { py.import("figgyplot._codegen")? - .getattr("settings_to_code")? + .getattr("editor_settings_to_code")? .call1(( settings, serde_json::to_string(&c.data.default_config) @@ -229,11 +229,30 @@ impl Editor { }); if self.inspector_open && !overlay { egui::Panel::right("radial_properties") - .exact_size(INSPECTOR_WIDTH) + .exact_size(self.inspector_width) .show(root, |ui| { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); + let edge = egui::Rect::from_min_size( + ui.max_rect().min - egui::vec2(4., 0.), + egui::vec2(7., ui.available_height()), + ); + let grip = ui + .interact( + edge, + egui::Id::new("bounded_inspector_resize"), + egui::Sense::drag(), + ) + .on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + self.controls.insert("inspector_resize".into(), grip.rect); + if grip.dragged() { + self.inspector_width = (self.inspector_width + - ui.input(|i| i.pointer.delta().x)) + .clamp(280., 640.); + } self.bounded_navigator(ui, c); let form = egui::ScrollArea::vertical() .id_salt("bounded_properties") + .auto_shrink([false, false]) .show(ui, |ui| self.radial_properties(ui, c)); self.controls .insert("properties_viewport".into(), form.inner_rect); @@ -249,9 +268,12 @@ impl Editor { egui::Window::new("Properties") .id(egui::Id::new("radial_overlay")) .anchor(egui::Align2::RIGHT_TOP, [-16., 70.]) - .default_width(INSPECTOR_WIDTH) - .resizable(false) + .default_width(self.inspector_width) + .min_width(280.) + .max_width(640.) + .resizable(true) .show(root.ctx(), |ui| { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); self.bounded_navigator(ui, c); let form = egui::ScrollArea::vertical() .id_salt("bounded_properties") @@ -280,6 +302,10 @@ impl Editor { .map(|i| format!("slice_{}_{i}", selected["group"].as_u64().unwrap_or(0))) }) }; + self.navigator.review_enabled = self + .review + .as_ref() + .is_some_and(|v| v.lock().unwrap().is_object()); let nodes = navigation::nodes(&c.snapshot()); if let Some(node) = self.navigator.show( ui, @@ -342,6 +368,7 @@ impl Editor { self.error = error.to_string(); } } + self.take_tree_review_target(); } fn bounded_properties(&mut self, ui: &mut egui::Ui, c: &mut FigureAccess<'_>) { @@ -516,14 +543,17 @@ impl Editor { self.error = error.to_string(); } } - ui.small("Wheel over plot: zoom values · Fit: automatic range"); + ui.small(if view["axis"] == "radius" { + "Set the radial range explicitly; wheel zoom is disabled." + } else { + "Wheel over plot: zoom values · Fit: automatic range" + }); ui.separator(); } false } fn radial_properties(&mut self, ui: &mut egui::Ui, c: &mut FigureAccess<'_>) { - self.basic_radial_properties(ui, c); let chart = &c.data.document.as_ref().unwrap().chart; let target = if self.figure_properties { "figure".into() @@ -546,6 +576,19 @@ impl Editor { .unwrap_or_else(|| "data_area".into()) }) }; + let explicit_plot = chart + .decorations() + .selected + .as_ref() + .is_some_and(|v| v.element_id() == "data_area"); + if target == "figure" || target == "data_area" && (self.edit_area || explicit_plot) { + self.catalog_properties(ui, c, &target, true); + if target == "data_area" { + self.bounded_context_properties(ui, c); + } + return; + } + self.basic_radial_properties(ui, c); self.catalog_properties(ui, c, &target, false); } fn basic_radial_properties(&mut self, ui: &mut egui::Ui, c: &mut FigureAccess<'_>) { diff --git a/src/review_images.rs b/src/review_images.rs new file mode 100644 index 0000000..6c23ea5 --- /dev/null +++ b/src/review_images.rs @@ -0,0 +1,280 @@ +//! Review-only image input and previews; never part of chart Settings. +use base64::{Engine, engine::general_purpose::STANDARD}; +use image::{ImageDecoder, ImageFormat, ImageReader}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{collections::HashMap, io::Cursor, path::Path}; + +const MAX_BYTES: usize = 20 * 1024 * 1024; +fn png(data: &[u8]) -> Result<(Vec, u32, u32), String> { + if data.len() > MAX_BYTES { + return Err("Review images must be at most 20 MiB".into()); + } + let mut reader = ImageReader::new(Cursor::new(data)) + .with_guessed_format() + .map_err(|e| e.to_string())?; + if !matches!( + reader.format(), + Some(ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::WebP) + ) { + return Err("Choose a PNG, JPEG or WebP image".into()); + } + let mut limits = image::Limits::default(); + limits.max_image_width = Some(8192); + limits.max_image_height = Some(8192); + limits.max_alloc = Some(128 * 1024 * 1024); + reader.limits(limits); + let mut decoder = reader + .into_decoder() + .map_err(|e| format!("Could not decode image: {e}"))?; + if decoder.total_bytes() > 128 * 1024 * 1024 { + return Err("Decoded review image exceeds 128 MiB; use a smaller image".into()); + } + let orientation = decoder + .orientation() + .map_err(|e| format!("Could not read image orientation: {e}"))?; + let mut image = image::DynamicImage::from_decoder(decoder) + .map_err(|e| format!("Could not decode image: {e}"))?; + image.apply_orientation(orientation); + let (width, height) = (image.width(), image.height()); + let mut output = Cursor::new(Vec::new()); + image + .write_to(&mut output, ImageFormat::Png) + .map_err(|e| e.to_string())?; + let bytes = output.into_inner(); + if bytes.len() > MAX_BYTES { + return Err("Decoded review image exceeds 20 MiB; use a smaller image".into()); + } + Ok((bytes, width, height)) +} +pub(crate) fn attachment(data: &[u8], name: &str) -> Result { + let (bytes, width, height) = png(data)?; + let sha = format!("{:x}", Sha256::digest(&bytes)); + Ok( + json!({"id":sha,"name":name,"mime_type":"image/png","bytes":bytes.len(),"width":width,"height":height,"data":STANDARD.encode(bytes)}), + ) +} +#[pyo3::pyfunction] +pub(crate) fn review_image_json(data: &[u8], name: &str) -> pyo3::PyResult { + attachment(data, name) + .map(|v| v.to_string()) + .map_err(crate::invalid) +} +pub(crate) fn clipboard() -> Result { + let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; + let image = clipboard + .get_image() + .map_err(|_| "Clipboard does not contain an image".to_owned())?; + if image.width > 8192 || image.height > 8192 || image.bytes.len() > 128 * 1024 * 1024 { + return Err("Clipboard image is too large; use an image up to 8192 pixels per side".into()); + } + let rgba = image::RgbaImage::from_raw( + image.width as u32, + image.height as u32, + image.bytes.into_owned(), + ) + .ok_or("Invalid clipboard image")?; + let mut bytes = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(rgba) + .write_to(&mut bytes, ImageFormat::Png) + .map_err(|e| e.to_string())?; + attachment(bytes.get_ref(), "Clipboard image.png") +} +fn bytes(item: &Value, cache: &str) -> Result, String> { + if let Some(data) = item["data"].as_str() { + STANDARD.decode(data).map_err(|e| e.to_string()) + } else { + let file = item["file"].as_str().ok_or("Missing image reference")?; + let sha = item["sha256"].as_str().ok_or("Missing image digest")?; + if sha.len() != 64 + || !sha.chars().all(|c| c.is_ascii_hexdigit()) + || file != format!("attachments/{sha}.png") + { + return Err("Invalid review image reference".into()); + } + std::fs::read(Path::new(cache).join(file)).map_err(|e| e.to_string()) + } +} +pub(crate) fn append(items: &mut Vec, item: Value) { + if !items.iter().any(|v| v["id"] == item["id"]) { + items.push(item); + } +} +#[derive(Default)] +pub(crate) struct ImageUi { + textures: HashMap, + preview: Option, + pub error: String, +} +impl ImageUi { + pub fn retain(&mut self, ids: &std::collections::HashSet) { + let preview = self + .preview + .as_ref() + .and_then(|v| v["id"].as_str()) + .map(str::to_owned); + self.textures + .retain(|id, _| ids.contains(id) || preview.as_ref() == Some(id)); + } + pub fn dismiss_preview(&mut self) -> bool { + self.preview.take().is_some() + } + fn texture( + &mut self, + ctx: &egui::Context, + item: &Value, + cache: &str, + ) -> Result { + let id = item["id"].as_str().ok_or("Missing image ID")?; + if let Some(texture) = self.textures.get(id) { + return Ok(texture.clone()); + } + let data = bytes(item, cache)?; + let image = image::load_from_memory_with_format(&data, ImageFormat::Png) + .map_err(|e| e.to_string())? + .to_rgba8(); + let color = egui::ColorImage::from_rgba_unmultiplied( + [image.width() as usize, image.height() as usize], + image.as_raw(), + ); + let texture = ctx.load_texture( + format!("review-image-{id}"), + color, + egui::TextureOptions::LINEAR, + ); + self.textures.insert(id.into(), texture.clone()); + Ok(texture) + } + pub fn editor( + &mut self, + ui: &mut egui::Ui, + items: &mut Vec, + cache: &str, + prefix: &str, + controls: &mut HashMap, + ) { + ui.horizontal(|ui| { + let attach = ui.button("Attach image…"); + controls.insert(format!("{prefix}_attach"), attach.rect); + if attach.clicked() { + if let Some(files) = rfd::FileDialog::new() + .add_filter("Images", &["png", "jpg", "jpeg", "webp"]) + .pick_files() + { + for path in files { + let result = std::fs::metadata(&path) + .map_err(|e| e.to_string()) + .and_then(|m| { + if m.len() > MAX_BYTES as u64 { + Err("Review images must be at most 20 MiB".into()) + } else { + std::fs::read(&path).map_err(|e| e.to_string()) + } + }) + .and_then(|data| { + attachment( + &data, + &path.file_name().unwrap_or_default().to_string_lossy(), + ) + }); + match result { + Ok(item) => { + append(items, item); + self.error.clear(); + } + Err(e) => self.error = e, + } + } + } + } + let paste = ui.button("Paste image"); + controls.insert(format!("{prefix}_paste"), paste.rect); + if paste.clicked() { + match clipboard() { + Ok(item) => { + append(items, item); + self.error.clear(); + } + Err(e) => self.error = e, + } + } + if items.is_empty() { + ui.weak("PNG · JPEG · WebP · Ctrl/Cmd+V in comments"); + } + }); + if !items.is_empty() { + let mut remove = None; + egui::ScrollArea::horizontal() + .id_salt(format!("{prefix}-images")) + .max_height(80.) + .show(ui, |ui| { + ui.horizontal(|ui| { + for (index, item) in items.iter().enumerate() { + ui.group(|ui| { + ui.horizontal(|ui| { + match self.texture(ui.ctx(), item, cache) { + Ok(texture) => { + let response = ui.add( + egui::Image::new(&texture) + .max_size(egui::vec2(72., 52.)) + .sense(egui::Sense::click()), + ); + controls.insert( + format!( + "{prefix}_image:{}", + item["id"].as_str().unwrap_or("") + ), + response.rect, + ); + if response.clicked() { + self.preview = Some(item.clone()); + } + } + Err(e) => { + ui.label("Image unavailable"); + self.error = e; + } + } + let button = + ui.small_button("×").on_hover_text("Remove attachment"); + controls.insert( + format!( + "{prefix}_image_remove:{}", + item["id"].as_str().unwrap_or("") + ), + button.rect, + ); + if button.clicked() { + remove = Some(index); + } + }); + }); + } + }); + }); + if let Some(index) = remove { + items.remove(index); + } + } + if !self.error.is_empty() { + ui.colored_label(egui::Color32::from_rgb(170, 45, 45), &self.error); + } + } + pub fn preview(&mut self, ctx: &egui::Context, cache: &str) { + let Some(item) = self.preview.clone() else { + return; + }; + let mut open = true; + egui::Window::new(item["name"].as_str().unwrap_or("Review image")) + .id(egui::Id::new("review_image_preview")) + .open(&mut open) + .show(ctx, |ui| { + if let Ok(texture) = self.texture(ctx, &item, cache) { + ui.add(egui::Image::new(&texture).max_size(egui::vec2(700., 500.))); + } + }); + if !open { + self.preview = None; + } + } +} diff --git a/src/ui_fonts.rs b/src/ui_fonts.rs index 6910584..00301c4 100644 --- a/src/ui_fonts.rs +++ b/src/ui_fonts.rs @@ -47,3 +47,41 @@ pub(crate) fn install(ctx: &egui::Context) { }); ctx.set_fonts(fonts.clone()); } + +pub(crate) fn resolve(family: &str) -> Result<(Vec, u32), String> { + let mut db = fontdb::Database::new(); + db.load_system_fonts(); + let id = db + .query(&fontdb::Query { + families: &[fontdb::Family::Name(family)], + ..Default::default() + }) + .ok_or_else(|| format!("Review font {family:?} is not installed"))?; + db.with_face_data(id, |data, index| (data.to_vec(), index)) + .ok_or_else(|| "Review font data is unavailable".into()) +} + +pub(crate) fn install_review(ctx: &egui::Context, family: &str) -> Result<(), String> { + let (bytes, index) = resolve(family)?; + let mut fonts = egui::FontDefinitions::default(); + let mut data = egui::FontData::from_owned(bytes); + data.index = index; + fonts + .font_data + .insert("figgy-review".into(), std::sync::Arc::new(data)); + for names in fonts.families.values_mut() { + names.push("figgy-review".into()); + } + let mut names = vec!["figgy-review".into()]; + names.extend( + fonts.families[&egui::FontFamily::Proportional] + .iter() + .filter(|n| *n != "figgy-review") + .cloned(), + ); + fonts + .families + .insert(egui::FontFamily::Name("figgy-review".into()), names); + ctx.set_fonts(fonts); + Ok(()) +} diff --git a/tests/jupyter_harness.py b/tests/jupyter_harness.py index e34fa6e..c4acfdb 100644 --- a/tests/jupyter_harness.py +++ b/tests/jupyter_harness.py @@ -26,7 +26,7 @@ }; window.__figgyTrace=(stage,details={})=>{ record('host',{stage,...details}); - if(/^(request|prepare|ready_frame|display_wait|export|response|surface)\./.test(stage)){ + if(/^(request|prepare|ready_frame|display_wait|export|response|surface|edit|state|property)\./.test(stage)){ trace.hostStages.push({t:performance.now(),stage,...details}); if(trace.hostStages.length>100)trace.hostStages.shift(); } @@ -277,6 +277,14 @@ def phase(stage): page.evaluate('window.jupyterapp.restored') page.wait_for_function('!!window.jupyterapp.shell.currentWidget?.sessionContext') page.evaluate("""async()=>{const app=window.jupyterapp;await app.restored;const panel=app.shell.currentWidget;await panel.context.ready;await panel.sessionContext.ready;await app.commands.execute('notebook:run-all-cells');}""") + initial_errors=page.evaluate("""() => { + const cells=window.jupyterapp.shell.currentWidget.content.widgets; + return cells.flatMap(cell=>(cell.model.outputs?.toJSON()??[]) + .filter(output=>output.output_type==='error') + .map(output=>({ename:output.ename,evalue:output.evalue,traceback:output.traceback}))); + }""") + kernel_executions.append({'phase':'initial-cells','outcome':'error' if initial_errors else 'ok','errors':initial_errors}) + assert not initial_errors, 'Initial notebook execution failed: '+json.dumps(initial_errors) # Ready includes async pipeline compilation and GPU completion. # Cold software-GPU startup has its own budget; gesture checks # keep Playwright's normal timeout and strict result assertions. diff --git a/tests/native_input.py b/tests/native_input.py index f5eee77..2082bb0 100644 --- a/tests/native_input.py +++ b/tests/native_input.py @@ -1,4 +1,13 @@ """Send complete X11 clicks before waiting for a rendered frame.""" +import subprocess + + +def set_clipboard(text): + # xclip forks a selection owner that must survive until the next paste. + # Its stdout/stderr must not retain pytest's CI log pipe after Python exits. + subprocess.run(['xclip','-selection','clipboard','-t','UTF8_STRING'], + input=text,text=True,check=True,timeout=10, + stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) def click(emit, *, double=False): diff --git a/tests/test_api_composition.py b/tests/test_api_composition.py new file mode 100644 index 0000000..a95fc0f --- /dev/null +++ b/tests/test_api_composition.py @@ -0,0 +1,121 @@ +"""Public API combinations preserve explicit edits and consistent document geometry.""" +import copy +import threading +from concurrent.futures import ThreadPoolExecutor + +import numpy as np +import pytest +import figgyplot as fg + + +def chart(fig,kind): + return fig.get_settings()[kind] + + +@pytest.mark.parametrize('backend',['native','webgpu']) +@pytest.mark.parametrize('family',['categorical','boxplot','radar','radial']) +def test_partial_nested_style_override_changes_only_requested_leaves(backend,family): + token=fg.styles.preset('dashboard.operations') + token.appearance['bounded']['radial']['style']={'outline':{'color':'#e6edf5','width':2}} + def build(fig,override): + a=fig.axes[0] + if family=='categorical':a.bar(['A','B'],[1,2],**({'style':{'corner_radius':0}} if override else {})) + elif family=='boxplot':a.boxplot([1,2,3,5],**({'style':{'median_width':3}} if override else {})) + elif family=='radar':a.radar(['A','B','C'],[1,2,3],**({'style':{'line_width_px':3}} if override else {})) + else:a.pie([1,2],**({'outline':{'width':3}} if override else {})) + with fg.Figure(backend=backend,style=token) as baseline,fg.Figure(backend=backend,style=token) as changed: + build(baseline,False);build(changed,True) + expected=copy.deepcopy(chart(baseline,family)) + key={'categorical':'corner_radius','boxplot':'median_width','radar':'line_width_px'}.get(family) + if family=='radial':expected['style']['outline']['width']=3 + else:expected['style'][key]=0 if family=='categorical' else 3 + assert chart(changed,family)==expected + + +def add_mapped(ax,method,explicit=False): + kw={'cmap':'magma'} if explicit else {} + if method=='imshow':ax.imshow([[1,2],[3,4]],**kw) + elif method=='contour':ax.contour(np.arange(9).reshape(3,3)+1,**kw) + elif method=='contourf':ax.contourf(np.arange(9).reshape(3,3)+1,**kw) + elif method=='scatter':ax.scatter([0,1],[2,3],c=[2,7],**kw) + else:ax.quiver([0,1],[2,3],[.2,.3],[.3,.2],values=[2,7],**kw) + + +def appearance(bar): + bar=copy.deepcopy(bar) + for key in ('min','max','major_spacing'):bar['axis'].pop(key) + bar.pop('colormap') + return bar + + +@pytest.mark.parametrize('backend',['native','webgpu']) +@pytest.mark.parametrize('style',[None,'dashboard.operations']) +@pytest.mark.parametrize('method',['imshow','contour','contourf','scatter','quiver']) +def test_new_mapped_layer_preserves_existing_colorbar_edits(backend,style,method): + with fg.Figure(backend=backend,style=style,grid=[['left','right']]) as fig: + fig.subplots['left'].plot([0,1],[1,2],id='left-line') + ax=fig.subplots['right'].axes + ax.scatter([0,1],[1,2],c=[0,1],id='right-points') + state=fig.get_settings();bar=state['config']['subplots'][0]['config']['colorbar'] + bar['axis']['label_style']['font_size']=31;bar['gap_px']=37;bar['thickness_px']=29 + fig.apply_settings(state);before=fig.get_settings();old=before['config']['subplots'][0]['config']['colorbar'] + add_mapped(ax,method,explicit=True) + after=fig.get_settings();new=after['config']['subplots'][0]['config']['colorbar'] + assert appearance(new)==appearance(old) + assert new['colormap']=='Magma' + assert after['series'][:2]==before['series'] + # The other panel keeps its complete accepted appearance and domain. + for key in ('bottom_x','top_x','left_y','right_y','chart_title','grid','legend'): + assert after['config'][key]==before['config'][key] + + +def grid_rects(fig): + state=fig.get_settings() + return state['identity']['layout'],[state['config']['chart_area']]+[p['config']['chart_area'] for p in state['config'].get('subplots',[])] + + +def assert_two_columns(fig,gap,weights): + layout,rects=grid_rects(fig);width,height=layout['size'] + first=(width-gap)*weights[0]/sum(weights);second_x=round(first+gap) + assert layout['gap']==pytest.approx(gap) + assert layout['column_weights']==weights + assert rects==[{'x':0,'y':0,'width':round(first),'height':height}, + {'x':second_x,'y':0,'width':width-second_x,'height':height}] + + +@pytest.mark.parametrize('backend',['native','webgpu']) +@pytest.mark.parametrize('dpi',[120,144]) +def test_inherited_grid_defaults_and_explicit_layout_match_accepted_geometry(backend,dpi): + with fg.Figure(backend=backend,dpi=120) as source: + source.set_layout([['left','right']],gap=44,column_weights=[2,1]);token=source.extract_style() + with fg.Figure(backend=backend,dpi=dpi,style=token,grid=[['left','right']]) as fig: + assert_two_columns(fig,44*dpi/120,[2,1]) + fig.set_layout([['left','right']],gap=22,column_weights=[1,2]) + assert_two_columns(fig,22,[1,2]) + # Omitted options retain the accepted manual layout, not old defaults. + fig.set_layout([['left','right']]) + assert_two_columns(fig,22,[1,2]) + fig.place('left',slot='right') + layout,rects=grid_rects(fig) + assert rects[0]['x']>rects[1]['x'] and layout['gap']==22 + + +def test_disabling_style_while_a_constructor_waits_is_one_serialized_operation(): + with fg.Figure(backend='webgpu',style='dashboard.operations') as fig: + waiting=threading.Event();original=fig._lock + class ObservedLock: + def __enter__(self): + if threading.current_thread() is not threading.main_thread():waiting.set() + original.acquire() + return self + def __exit__(self,*args):original.release() + fig._lock=ObservedLock() + with ThreadPoolExecutor(1) as pool: + with original: + future=pool.submit(fig.axes[0].plot,[0,1],[1,2],color='black') + assert waiting.wait(5) + fig.set_style(None) + future.result(timeout=10) + assert fig.style_defaults is None + spec=fig.get_settings()['series'][0]['render_type']['Line']['line'] + assert spec['line_color']=={'r':0.,'g':0.,'b':0.,'a':1.} diff --git a/tests/test_blind_api_findings.py b/tests/test_blind_api_findings.py new file mode 100644 index 0000000..a4ecaac --- /dev/null +++ b/tests/test_blind_api_findings.py @@ -0,0 +1,229 @@ +"""Public contracts discovered by six independent users of the frozen wheel.""" +import copy + +import numpy as np +import pytest +import figgyplot as fg + + +def populate(ax, method, **kwargs): + if method in ('bar', 'barh'): + getattr(ax, method)(['A', 'B'], [2, 4], **kwargs) + return 'categorical' + if method == 'boxplot': + ax.boxplot([[1, 2, 3], [2, 3, 5]], labels=['A', 'B'], **kwargs) + return 'boxplot' + if method == 'radar': + ax.radar(['A', 'B', 'C'], [2, 4, 3], **kwargs) + return 'radar' + getattr(ax, method)([2, 4], labels=['A', 'B'], **kwargs) + return 'radial' + + +def wording(title): + return ''.join(s['text'] for s in title['text']['segments']) + + +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +@pytest.mark.parametrize('style', [None, 'paper']) +@pytest.mark.parametrize('method,horizontal', [('bar', False), ('barh', True), + ('boxplot', False), ('boxplot', True)]) +def test_axis_names_survive_first_bounded_chart(backend, style, method, horizontal): + with fg.Figure(backend=backend, style=style) as before, fg.Figure(backend=backend, style=style) as after: + charts = [] + for fig, early in [(before, True), (after, False)]: + ax = fig.axes[0] + if early: + ax.set_xlabel('Requested X'); ax.set_ylabel('Requested Y') + kind = populate(ax, method, **({'orientation': 'horizontal'} if method == 'boxplot' and horizontal else {})) + if not early: + ax.set_xlabel('Requested X'); ax.set_ylabel('Requested Y') + charts.append(fig.get_settings()[kind]) + assert charts[0]['category_title'] == ('Requested Y' if horizontal else 'Requested X') + assert charts[0]['value_title'] == ('Requested X' if horizontal else 'Requested Y') + # Compare the entire actual model, not a guessed subset of constructor defaults. + assert charts[0] == charts[1] + if backend == 'native': + np.testing.assert_array_equal(before.render_rgba(), after.render_rgba()) + + +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +def test_explicit_bounded_axis_names_including_empty_override_inherited_names(backend): + with fg.Figure(backend=backend, style='paper') as fig: + ax = fig.axes[0] + ax.set_xlabel('Earlier X'); ax.set_ylabel('Earlier Y') + ax.barh(['A'], [2], category_title='', value_title='Explicit X') + chart = fig.get_settings()['categorical'] + assert chart['category_title'] == '' + assert chart['value_title'] == 'Explicit X' + with fg.Figure(backend=backend, style='paper') as fig: + fig.axes[0].set_title('Earlier\nTitle') + fig.axes[0].bar(['A'], [2], title='Explicit\nHeading') + assert wording(fig.get_settings()['categorical']['chart_title']) == 'Explicit\nHeading' + with fg.Figure(backend=backend) as fig: + fig.axes[0].set_title('Earlier title') + fig.axes[0].bar(['A'], [2], title='') + assert wording(fig.get_settings()['categorical']['chart_title']) == '' + + +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +@pytest.mark.parametrize('method', ['bar', 'boxplot', 'radar', 'pie', 'donut']) +def test_multiline_titles_before_and_after_construction_preserve_wording_and_format(backend, method): + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + ax.set_title('First line\nSecond line', fontsize=18, color='purple', align='right') + kind = populate(ax, method) + chart = fig.get_settings()[kind] + assert wording(chart['chart_title']) == 'First line\nSecond line' + assert chart['chart_title']['text']['align'] == 'Right' + assert chart['chart_title']['text']['font_size'] == 30 + ax.set_title('Revised\nSecond line') + chart = fig.get_settings()[kind] + assert wording(chart['chart_title']) == 'Revised\nSecond line' + assert '\n' not in chart['title'] and len(chart['title']) <= 160 + # A size-only edit preserves character formatting and all unrelated chart fields. + state = fig.get_settings() + state[kind]['chart_title']['text']['segments'][0]['bold'] = True + fig.apply_settings(state) + expected = fig.get_settings() + expected[kind]['chart_title']['text']['font_size'] = 35 + fig.update('chart_title', fontsize=21) + assert fig.get_settings() == expected + if backend == 'native': + image = fig.render_rgba() + assert image[..., :3].var() > 1 + + +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +@pytest.mark.parametrize('method', ['bar', 'boxplot', 'radar', 'pie']) +def test_title_text_tokens_and_code_replay_preserve_rich_text(backend, method): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + kind = populate(source.axes[0], method) + populate(target.axes[0], method) + source.axes[0].set_title('Heading\nDetail') + state = source.get_settings() + state[kind]['chart_title']['text']['segments'][0]['italic'] = True + source.apply_settings(state) + token = fg.StyleToken.from_json(source.extract_style(text=True).to_json()) + before = target.get_settings() + target.apply_style(token) + assert target.get_settings()[kind]['title'] == before[kind]['title'] + target.apply_style(token, text={'titles'}) + assert target.get_settings()[kind]['chart_title']['text']['segments'] == state[kind]['chart_title']['text']['segments'] + # Previously serialized tokens with only a plain title remain usable. + legacy = fg.StyleToken.from_json(token.to_json()) + legacy.text['bounded'].pop('title_segments') + target.apply_style(legacy, text={'titles'}) + assert wording(target.get_settings()[kind]['chart_title']) == 'Heading\nDetail' + target.axes[0].set_title('Other') + exec(source.to_code(mode='exact'), {'fig': target}) + assert target.get_settings()[kind] == source.get_settings()[kind] + if backend == 'native': + np.testing.assert_array_equal(source.render_rgba(), target.render_rgba()) + + +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +def test_title_appearance_token_preserves_a_legacy_only_destination_title(backend): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + source.axes[0].bar(['A'], [2]); target.axes[0].bar(['A'], [7]) + source.axes[0].set_title('SOURCE WORDING', fontsize=20, color='purple') + legacy = target.get_settings() + legacy['categorical'].pop('chart_title', None) + legacy['categorical']['title'] = 'DESTINATION WORDING' + target.apply_settings(legacy) + target.apply_style(source.extract_style(text=True)) + chart = target.get_settings()['categorical'] + assert wording(chart['chart_title']) == 'DESTINATION WORDING' + assert chart['chart_title']['text']['font_size'] == pytest.approx(100/3) + assert chart['series'][0]['values'] == [7] + + +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +def test_rich_title_limits_still_reject_atomically_and_keep_full_valid_text(backend): + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0]; ax.bar(['A'], [2]) + valid = 'x' * 200 + '\n' + 'y' * 200 + ax.set_title(valid) + assert wording(fig.get_settings()['categorical']['chart_title']) == valid + for bad in ['x' * 513, '\n'.join(['line'] * 9), 'bad\x00title']: + state, revision = fig.get_settings(), fig.revision + with pytest.raises(ValueError): ax.set_title(bad) + assert fig.get_settings() == state and fig.revision == revision + + +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +def test_axis_inversion_is_scoped_and_preserves_other_settings(backend): + with fg.Figure(backend=backend, grid=[['first', 'second']]) as fig: + ax = fig.subplots['second']; ax.plot([1, 2], [2, 4]) + before = fig.get_settings() + ax.invert_yaxis() + expected = copy.deepcopy(before) + for side in ('left_y', 'right_y'): expected['config']['subplots'][0]['config'][side]['inverted'] = True + assert fig.get_settings() == expected + ax.invert_yaxis() + assert fig.get_settings() == before + ax.invert_xaxis() + expected = copy.deepcopy(before) + for side in ('bottom_x', 'top_x'): expected['config']['subplots'][0]['config'][side]['inverted'] = True + assert fig.get_settings() == expected + ax.invert_xaxis() + assert fig.get_settings() == before + twin = ax.twinx(); before = fig.get_settings(); twin.invert_yaxis() + expected = copy.deepcopy(before) + expected['config']['subplots'][0]['config']['overlays'][0]['y_axis']['inverted'] = True + assert fig.get_settings() == expected + twin.invert_yaxis() + assert fig.get_settings() == before + fig.share_axes('y', ['first', 'second']) + ax.invert_yaxis() + state = fig.get_settings() + assert state['config']['left_y']['inverted'] + assert state['config']['subplots'][0]['config']['left_y']['inverted'] + for kind in ('bounded', 'polar'): + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + if kind == 'bounded': ax.bar(['A'], [2]) + else: ax.set_polar() + state, revision = fig.get_settings(), fig.revision + for edit in (ax.invert_xaxis, ax.invert_yaxis): + with pytest.raises(ValueError): edit() + assert fig.get_settings() == state and fig.revision == revision + + +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +def test_legend_location_helpers_share_model_and_invalid_edits_are_atomic(backend): + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0]; ax.plot([1, 2], [2, 3], label='Signal') + ax.legend(loc='upper left') + assert fig.get_settings()['config']['legend']['corner'] == 'TopLeft' + fig.update('legend', corner='BottomRight') + assert fig.get_settings()['config']['legend']['corner'] == 'BottomRight' + before, rev = fig.get_settings(), fig.revision + with pytest.raises(ValueError): ax.legend(loc='best') + assert fig.get_settings() == before and fig.revision == rev + with pytest.raises(ValueError): fig.update('legend', corner='bogus') + assert fig.get_settings() == before and fig.revision == rev + twin = ax.twinx() + twin.plot([1, 2], [3, 4], label='Secondary', id='secondary') + before = fig.get_settings() + twin.legend(loc='lower left') + after = fig.get_settings() + assert after['config']['legend'] == before['config']['legend'] + overlay_legend = after['config']['overlays'][0]['legend'] + assert overlay_legend['corner'] == 'BottomLeft' + assert 'Secondary' in ''.join(s['text'] for s in overlay_legend['content']['segments']) + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0]; ax.bar(['A'], [2]) + before, revision = fig.get_settings(), fig.revision + with pytest.raises(ValueError, match='visibility only'): ax.legend(loc='upper left') + assert fig.get_settings() == before and fig.revision == revision + + +@pytest.mark.parametrize('method', ['bar', 'boxplot']) +def test_multiline_category_labels_have_actionable_errors_and_no_partial_chart(method): + with fg.Figure(backend='native') as fig: + before, revision = fig.get_settings(), fig.revision + with pytest.raises(ValueError, match='category labels must be single-line'): + if method == 'bar': fig.axes[0].bar(['A\nB'], [2]) + else: fig.axes[0].boxplot([[1, 2, 3]], labels=['A\nB']) + assert fig.get_settings() == before and fig.revision == revision diff --git a/tests/test_browser.py b/tests/test_browser.py index a9259ed..5947a91 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -19,7 +19,8 @@ from figgyplot.editor import EditorSession -def test_live_editor_select_drag_copy_and_png(tmp_path): +@pytest.mark.parametrize("delayed_ack", [False, True]) +def test_live_editor_select_drag_copy_and_png(tmp_path, delayed_ack): pytest.importorskip("playwright") from playwright.sync_api import sync_playwright import figgyplot as figgy @@ -38,6 +39,11 @@ def test_live_editor_select_drag_copy_and_png(tmp_path): def payload(): return json.dumps({"state": editor.snapshot(), "image": base64.b64encode(editor.image).decode()}).encode() + release_ack = threading.Event() + release_ack.set() + release_started = threading.Event() + if delayed_ack:release_ack.clear() + class Handler(BaseHTTPRequestHandler): def log_message(self, *args): pass def do_GET(self): @@ -64,7 +70,11 @@ def do_GET(self): self.send_response(200); self.send_header("Content-Type", mime); self.end_headers(); self.wfile.write(data) def do_POST(self): content = self.rfile.read(int(self.headers["Content-Length"])) - editor.dispatch(json.loads(content)) + message=json.loads(content) + editor.dispatch(message) + if delayed_ack and message['action']=='release': + release_started.set() + if not release_ack.wait(10):raise RuntimeError('release acknowledgement gate was not opened') data = payload() self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(data) @@ -95,11 +105,23 @@ def do_POST(self): page.wait_for_function("editorModel.get('state').selection === 'chart_title'") page.mouse.move(x + 15, y + 5, steps=3); page.mouse.up() page.wait_for_function("editorModel.get('state').settings.config.chart_title.offset_x > 0") + page.bring_to_front() + page.evaluate("""() => { + window.copyAttempts=[]; + const write=navigator.clipboard.writeText.bind(navigator.clipboard); + navigator.clipboard.writeText=text=>{window.copyAttempts.push(text);return write(text);}; + }""") + if delayed_ack:assert release_started.wait(5) page.locator("[data-copy]").click() + if delayed_ack: + assert page.evaluate('copyAttempts.length')==0, 'copied before the final edit acknowledgement' + release_ack.set() page.wait_for_function("document.querySelector('[data-status]').textContent === 'Python code copied'") copied = page.evaluate("navigator.clipboard.readText()") assert "ax.set_title(" in copied and "Browser edited title" in copied assert "json.loads" not in copied + assert copied == fig.to_code() + assert page.evaluate('copyAttempts') == [copied] with page.expect_download() as event: page.locator("[data-save]").click() download = event.value @@ -109,4 +131,5 @@ def do_POST(self): assert not errors browser.close() finally: + release_ack.set() server.shutdown(); server.server_close(); thread.join(timeout=5) diff --git a/tests/test_chart_coverage.py b/tests/test_chart_coverage.py index d834d44..17b553a 100644 --- a/tests/test_chart_coverage.py +++ b/tests/test_chart_coverage.py @@ -71,7 +71,7 @@ def test_bounded_replay_preserves_data_and_rejects_other_category_layout(kind): else: fig.axes[0].boxplot([[1+offset, 2+offset], [3+offset, 4+offset]]) original = copy.deepcopy(b.get_settings()[kind]['series'][0]['values']) a.configure_bounded(style={'outline': True}) - exec(a.to_code(), {'fig': b}) + exec(a.to_code(mode='exact'), {'fig': b}) assert b.get_settings()[kind]['series'][0]['values'] == original before, revision = b.get_settings(), b.revision bad = copy.deepcopy(before) diff --git a/tests/test_chart_styles.py b/tests/test_chart_styles.py index ab35f94..f488386 100644 --- a/tests/test_chart_styles.py +++ b/tests/test_chart_styles.py @@ -71,7 +71,7 @@ def test_native_draw_modes_change_pixels_and_replay_exactly(mode): source.set_facecolor("#0b0f17") styled = source.render_rgba() assert np.count_nonzero(styled != precise) > 1000 - code = source.to_code() + code = source.to_code(mode='exact') assert f"set_draw_style('{mode}'" in code exec(code, {"fig": replay}) assert source.get_settings() == replay.get_settings() @@ -86,7 +86,7 @@ def test_background_is_validated_replayed_and_reset(backend): source.axes[0].plot([0, 1], [0, 1]) replay.axes[0].plot([0, 1], [0, 1]) source.set_facecolor("#0b0f17") - exec(source.to_code(), {"fig": replay}) + exec(source.to_code(mode='exact'), {"fig": replay}) assert source.get_settings() == replay.get_settings() if backend == "native": np.testing.assert_array_equal(source.render_rgba(), replay.render_rgba()) diff --git a/tests/test_ci_runtime.py b/tests/test_ci_runtime.py new file mode 100644 index 0000000..0d97dd1 --- /dev/null +++ b/tests/test_ci_runtime.py @@ -0,0 +1,70 @@ +"""CI package setup must ignore image vendor feeds without ignoring APT errors.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + +SCRIPT=Path(__file__).resolve().parents[1]/'scripts/install_ci_runtime.sh' + + +def run_setup(tmp_path,fail=False): + log=tmp_path/'apt-calls.jsonl' + sudo=tmp_path/'sudo' + sudo.write_text('#!'+sys.executable+'\n'+''' +import json,os,pathlib,sys +args=sys.argv[1:] +source=next(v.split('=',1)[1] for v in args if v.startswith('Dir::Etc::sourcelist=')) +with open(os.environ['APT_CALLS'],'a') as f: + f.write(json.dumps(dict(args=args,sources=pathlib.Path(source).read_text()))+'\\n') +if os.environ.get('APT_FAIL')=='1':sys.exit(100) +''') + sudo.chmod(0o755) + env={**os.environ,'PATH':str(tmp_path)+os.pathsep+os.environ['PATH'],'APT_CALLS':str(log),'APT_FAIL':str(int(fail))} + result=subprocess.run(['bash',str(SCRIPT)],env=env,capture_output=True,text=True,timeout=10) + return result,[json.loads(line) for line in log.read_text().splitlines()] + + +def apt_plan(tmp_path,call,isolate): + if shutil.which('apt-get') is None:pytest.skip('APT URI planning requires Linux APT') + source=tmp_path/'runtime.sources';source.write_text(call['sources']) + vendor=tmp_path/'image-sources';vendor.mkdir(exist_ok=True) + # These reproduce two image-owned feeds. --print-uris never contacts them. + (vendor/'vendor.list').write_text('deb https://packages.microsoft.com/ubuntu/24.04/prod noble main\ndeb http://azure.archive.ubuntu.com/ubuntu noble main\n') + lists=tmp_path/'lists';lists.mkdir(exist_ok=True) + options=call['args'][1:-1] + options=[('Dir::Etc::sourcelist='+str(source)) if v.startswith('Dir::Etc::sourcelist=') else v for v in options] + if not isolate: + at=options.index('Dir::Etc::sourceparts=-');del options[at-1:at+1] + command=['apt-get','-o','Dir::State::lists='+str(lists),'-o','Dir::Etc::sourceparts='+str(vendor),*options,'--print-uris','update'] + result=subprocess.run(command,capture_output=True,text=True,timeout=10) + assert result.returncode==0,result.stderr + return result.stdout + + +def test_actual_apt_plan_uses_only_official_signed_ubuntu_sources(tmp_path): + result,calls=run_setup(tmp_path) + assert result.returncode==0,result.stderr + assert len(calls)==2 and calls[0]['args'][-1]=='update' + assert 'install' in calls[1]['args'] and 'fonts-noto-cjk' in calls[1]['args'] + assert calls[0]['sources']==calls[1]['sources'] + assert calls[0]['sources'].count('Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg')==2 + plan=apt_plan(tmp_path,calls[0],True) + for suite in ['noble','noble-updates','noble-backports','noble-security']:assert '/'+suite+'/InRelease' in plan + assert 'https://archive.ubuntu.com/ubuntu/' in plan and 'https://security.ubuntu.com/ubuntu/' in plan + assert 'azure.archive' not in plan and 'packages.microsoft' not in plan + + +def test_apt_plan_oracle_detects_vendor_sources_if_isolation_is_removed(tmp_path): + result,calls=run_setup(tmp_path);assert result.returncode==0,result.stderr + plan=apt_plan(tmp_path,calls[0],False) + assert 'azure.archive.ubuntu.com' in plan and 'packages.microsoft.com' in plan + + +def test_official_repository_failure_stops_before_package_installation(tmp_path): + result,calls=run_setup(tmp_path,True) + assert result.returncode==100 + assert len(calls)==1 and calls[0]['args'][-1]=='update' diff --git a/tests/test_codegen.py b/tests/test_codegen.py index bc49b6b..90be156 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -27,7 +27,7 @@ def test_readable_setters_replay_rich_layout_and_styles(variable, dpi): source.apply_settings(state) state = source.get_settings() target.axes[0].set_title("Unrelated", fontsize=40, color="orange") - code = source.to_code(variable) + code = source.to_code(variable, mode='exact') ast.parse(code) assert ".set_title(" in code and "fontsize=18.0" in code assert ".set_xlabel('Time (s)'" in code and ".set_ylabel('Amplitude'" in code @@ -46,7 +46,7 @@ def test_invalid_export_variable_and_configure_are_atomic(): before = figure.get_settings() for variable in ("fig.axes[0]", "class", 1): with pytest.raises(ValueError, match="identifier"): - figure.to_code(variable) + figure.to_code(variable, mode='exact') with pytest.raises(ValueError, match="unknown series"): figure.configure(config={"chart_title": {"visible": False}}, series={"missing": {}}) with pytest.raises(ValueError, match="source columns"): diff --git a/tests/test_compact_codegen.py b/tests/test_compact_codegen.py new file mode 100644 index 0000000..464cac8 --- /dev/null +++ b/tests/test_compact_codegen.py @@ -0,0 +1,112 @@ +"""Compact exports restore actual SSOT, including defaults and rich text.""" +import ast +import copy +import pprint + +import numpy as np +import pytest +import figgyplot as fg + + +def advanced_scene(fig, scene): + ax = fig.axes[0] + if scene == 'grid': + fig.set_layout([['first', 'second']]) + ax = fig.subplots['first'].axes + fig.subplots['second'].plot([0, 1], [3, 2], id='other', label='Other') + if scene == 'mapped': ax.scatter([0, 1], [1, 2], c=[1, 4], id='signal', label='Mapped') + else: ax.plot([0, 1], [1, 2], id='signal', label='Signal') + if scene == 'overlay': + right = ax.twinx(); right.plot([0, 1], [4, 8], id='pressure', label='Pressure') + right.legend(loc='lower right') + if scene == 'annotation': ax.text(.2, .3, 'Annotation\nwith details', coords='ratio', id='note') + ax.set_title('Comparison\nObserved sample', fontsize=18) + ax.set_xlabel('Time (s)'); ax.set_ylabel('Value'); ax.legend() + + +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +@pytest.mark.parametrize('scene', ['grid', 'mapped', 'overlay', 'annotation']) +def test_compact_document_replay_replaces_dirty_defaults_exactly(backend, scene): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + advanced_scene(source, scene); advanced_scene(target, scene) + source_state = source.get_settings() + dirty = target.get_settings() + configs = [dirty['config']] + [p['config'] for p in dirty['config'].get('subplots', [])] + for config in configs: + config['grid']['major_x_width'] = 7 + config['legend']['border_color']['a'] = .1 + config['left_y']['label_style']['font_size'] = 35 + for overlay in config.get('overlays', []): + overlay['y_axis']['line_width'] = 9 + target.apply_settings(dirty) + code = source.to_code(mode='exact') + assert len(code) < len(pprint.pformat(source_state, width=96, sort_dicts=False)) * .55 + expanded = source.to_code(compact=False) + assert len(code) < len(expanded) * .55 + assert 'reset=True' in code + assert 'json.loads' not in code and 'base64' not in code + ast.parse(code) + uploads = target.info.get('column_uploads') + revision = target.revision + exec(code, {'fig': target}) + assert target.get_settings() == source_state + assert target.info.get('column_uploads') == uploads + assert target.revision == revision + 1 + if backend == 'native': np.testing.assert_array_equal(source.render_rgba(), target.render_rgba()) + + +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +@pytest.mark.parametrize('family', ['categorical', 'boxplot', 'radar', 'radial']) +def test_bounded_compact_reset_preserves_destination_values_and_restores_defaults(backend, family): + def build(fig, offset): + ax = fig.axes[0] + if family == 'categorical': ax.bar(['A', 'B'], [1+offset, 3+offset]) + elif family == 'boxplot': ax.boxplot([[1+offset, 2+offset], [3+offset, 5+offset]]) + elif family == 'radar': ax.radar(['A', 'B', 'C'], [1+offset, 3+offset, 2+offset]) + else: ax.pie([1+offset, 3+offset], labels=['A', 'B']) + ax.set_title('A multiline\nrich heading') + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + build(source, 0); build(target, 10) + dirty = target.get_settings() + dirty[family]['font_size'] = 29 + dirty[family]['background'] = dict(r=.1, g=.1, b=.1, a=1.) + target.apply_settings(dirty) + before = copy.deepcopy(target.get_settings()[family]) + code = source.to_code(mode='exact') + assert len(code) < len(pprint.pformat(source.get_settings()[family], width=96, sort_dicts=False)) * .55 + assert len(code) < len(source.to_code(compact=False)) * .55 + exec(code, {'fig': target}) + expected = source.get_settings()[family] + data = 'slices' if family == 'radial' else 'series' + values = 'value' if family == 'radial' else 'values' + for item, original in zip(expected[data], before[data]): item[values] = original[values] + assert target.get_settings()[family] == expected + + +@pytest.mark.parametrize('variable', ['fig', 'text_segments', '_text_segments', '_color1']) +def test_styled_text_runs_and_unicode_replay_without_character_dictionary_dumps(variable): + with fg.Figure(backend='webgpu') as source, fg.Figure(backend='webgpu') as target: + for fig in [source, target]: advanced_scene(fig, 'annotation') + state = source.get_settings() + segments = fg.text_segments('Plain ') + fg.text_segments('bold 한글', bold=True) + fg.text_segments('\n10⁻³') + segments += fg.text_segments('한글 red', color={'r': .5, 'g': .25, 'b': .75, 'a': 1.}) * 2 + state['config']['chart_title']['text']['segments'] = segments + source.apply_settings(state) + code = source.to_code(variable, mode='exact') + assert "bold=True" in code and "'bold':" not in code + exec(code, {variable: target}) + assert target.get_settings() == source.get_settings() + + +def test_compact_resets_validate_atomically_and_do_not_mutate_input(): + with fg.Figure(backend='webgpu') as fig: + fig.axes[0].bar(['A', 'B'], [2, 3]) + before, revision = fig.get_settings(), fig.revision + with pytest.raises(ValueError): fig.configure_bounded(reset=True, font_size=-1) + assert fig.get_settings() == before and fig.revision == revision + patch = {'schema_version': 2, 'config': {'chart_area': before['config']['chart_area']}, + 'series': [], 'identity': copy.deepcopy(before['identity']), 'categorical': {'categories': []}} + original = copy.deepcopy(patch) + with pytest.raises(ValueError): fig.apply_settings(patch, reset=True) + assert patch == original + assert fig.get_settings() == before and fig.revision == revision diff --git a/tests/test_desktop_editor.py b/tests/test_desktop_editor.py index 9a9924d..6f9e014 100644 --- a/tests/test_desktop_editor.py +++ b/tests/test_desktop_editor.py @@ -130,7 +130,8 @@ def screenshot(name): ImageGrab.grab(xdisplay=os.environ['DISPLAY']).save(tmp_pa invalid=copy.deepcopy(baseline);invalid['config']['bottom_x']['min']=invalid['config']['bottom_x']['max'] def paste_settings(value): click_rect(info()['settings_window']['controls']['settings_json'],settings_id) - subprocess.run(['xclip','-selection','clipboard','-t','UTF8_STRING'],input=json.dumps(value),text=True,check=True,timeout=3) + from native_input import set_clipboard + set_clipboard(json.dumps(value)) key('ctrl+a');key('ctrl+v') click_rect(info()['settings_window']['controls']['apply_settings'],settings_id) paste_settings(invalid) diff --git a/tests/test_desktop_host.py b/tests/test_desktop_host.py index fbe69d7..1b2a5aa 100644 --- a/tests/test_desktop_host.py +++ b/tests/test_desktop_host.py @@ -8,7 +8,7 @@ import numpy as np import pytest from native_input import click as send_native_click -from figgyplot import subplots, release_gpu +from figgyplot import Figure, subplots, release_gpu from figgyplot.desktop import poll_events from object_navigation import ensure_native_control_visible @@ -239,9 +239,25 @@ def click_rect(rect, double=False): click_rect(info()["controls"]["colormap_Magma"]) bar = fig.get_settings()["config"]["colorbar"] assert bar["axis"]["min"] == -2 and bar["axis"]["max"] == 4 - assert bar["colormap"] == "Magma" and "Magma" in fig.to_code() + assert bar["colormap"] == "Magma" assert info()["column_uploads"] == 4 assert info()["rgba_readbacks"] == info()["png_encodes"] == 0 + # The UI transport above must not read images back. Explicitly verify + # exported behavior afterwards; public cmap names need not use the + # renderer enum's spelling in the generated source text. + state = fig.get_settings() + code = fig.to_code() + with Figure(backend="native") as restored: + restored.axes[0].imshow( + np.array([[-1.0, 0.0], [1.0, 2.0]], dtype=np.float32), + id="Temperature", + ) + uploads = restored.info["column_uploads"] + exec(code, {"fig": restored}) + assert restored.get_settings()["config"] == state["config"] + assert restored.get_settings()["series"] == state["series"] + assert restored.info["column_uploads"] == uploads + np.testing.assert_array_equal(fig.render_rgba(), restored.render_rgba()) finally: handle.close() pump() diff --git a/tests/test_editor_properties.py b/tests/test_editor_properties.py index cd803ac..7111272 100644 --- a/tests/test_editor_properties.py +++ b/tests/test_editor_properties.py @@ -38,7 +38,7 @@ def test_contextual_styles_have_real_commands_and_replay(backend,kind): assert fig.edit_history['undo_count']==count+1 fig.undo();assert fig.get_settings()==before fig.redo();assert fig.get_settings()==after - exec(fig.to_code(),{'fig':fig}) + exec(fig.to_code(mode='exact'),{'fig':fig}) assert fig.get_settings()==after @@ -443,7 +443,7 @@ def test_colorbar_title_command_preserves_character_style_and_replays(backend): assert rich['font_size']==text['font_size'] fig.undo();assert fig.get_settings()==before fig.redo();assert fig.get_settings()==after - exec(fig.to_code(),{'fig':fig});assert fig.get_settings()==after + exec(fig.to_code(mode='exact'),{'fig':fig});assert fig.get_settings()==after @pytest.mark.parametrize('backend',['native','webgpu']) diff --git a/tests/test_feature_visual_browser.py b/tests/test_feature_visual_browser.py index 80f833f..926ed96 100644 --- a/tests/test_feature_visual_browser.py +++ b/tests/test_feature_visual_browser.py @@ -42,3 +42,10 @@ def test_displayed_feature_has_expected_geometry_and_colors(tmp_path,feature): image=np.asarray(Image.open(io.BytesIO(png)).convert('RGBA')) assert_feature_image(image,feature) (tmp_path/'visual-contract.json').write_text(json.dumps(dict(feature=feature,geometry=geometry,alignment=alignment))) + +@pytest.mark.skipif(not os.environ.get('DISPLAY') or os.environ.get('FIGGY_TEST_JUPYTER')!='1', + reason='requires real Jupyter WebGPU') +def test_initial_kernel_failure_reports_the_error_before_gpu_readiness(tmp_path): + with pytest.raises(AssertionError,match='Initial notebook execution failed:.*deliberate initialization probe'): + with notebook(tmp_path/'initial-error',"raise ValueError('deliberate initialization probe')"): + pytest.fail('failed notebook was treated as a ready chart') diff --git a/tests/test_figure.py b/tests/test_figure.py index bebd97f..e86f576 100644 --- a/tests/test_figure.py +++ b/tests/test_figure.py @@ -39,7 +39,7 @@ def test_exported_python_replays_layout_and_style(plot): artist.set_linewidth(3) state = fig.get_settings() image = fig.render_rgba().copy() - code = fig.to_code("restored") + code = fig.to_code("restored", mode='exact') with figgy.Figure(figsize=(6, 4), dpi=100, pool_bytes=8 * 1024**2) as restored: restored.axes[0].plot(x, np.sin(x), id="signal", label="Signal") exec(code, {"restored": restored}) diff --git a/tests/test_harness_diagnostics.py b/tests/test_harness_diagnostics.py index 3f34628..d88fb30 100644 --- a/tests/test_harness_diagnostics.py +++ b/tests/test_harness_diagnostics.py @@ -61,6 +61,10 @@ class Buffer { assert.equal(figgyBrowserTrace.events.length,400);assert.ok(figgyBrowserTrace.dropped>0); assert.equal(figgyBrowserTrace.events.at(-1).index,449); assert.equal(figgyBrowserTrace.hostStages.at(-1).requestId,'request-1'); +__figgyTrace('edit.post',{sequence:2,revision:7}); +for(let i=0;i<450;i++)__figgyTrace('queue.finished',{index:i}); +assert.equal(figgyBrowserTrace.hostStages.at(-1).stage,'edit.post'); +assert.equal(figgyBrowserTrace.hostStages.at(-1).revision,7); for(let i=0;i<120;i++)__figgyTrace('prepare.completed',{requestId:i}); assert.equal(figgyBrowserTrace.hostStages.length,100);assert.equal(figgyBrowserTrace.hostStages.at(-1).requestId,119); assert.ok(figgyBrowserTrace.events.every(e=>Number.isFinite(e.t))); @@ -209,3 +213,36 @@ def test_capture_requires_current_surface_completion_and_propagates_display_erro state.frame_error='device lost';assert.throws(settled,/device lost/);state.frame_error=null; state.disposed=true;assert.throws(settled,/disposed/); """) + + +def test_property_draft_rebase_preserves_unrelated_values_and_rejects_conflicts(): + from pathlib import Path + source=(Path(__file__).resolve().parents[1]/'python'/'figgyplot'/'webgpu.js').read_text()+'\nexport {propertyDraft,rebasePropertyDraft};' + run_node("const source="+json.dumps(source)+";\n"+r""" +import assert from 'node:assert/strict'; +const {propertyDraft,rebasePropertyDraft}=await import('data:text/javascript;base64,'+Buffer.from(source).toString('base64')); +const base={identity:{figure:'figure-a',panels:[{id:'main',overlays:[]}]},config:{digits:7,grid:false},series:[{id:'a',width:1},{id:'b',width:1}],values:[1e-200,1e200]}; +const requested=structuredClone(base);requested.config.digits=2; +const original=structuredClone(base),draft=propertyDraft(base,requested); +const accepted=structuredClone(base);accepted.config.grid=true;accepted.values=[2e-200,2e200]; +const saved=structuredClone(accepted),result=rebasePropertyDraft(draft,accepted); +assert.equal(result.config.digits,2);assert.equal(result.config.grid,true); +assert.deepEqual(result.values,[2e-200,2e200]); +assert.deepEqual(base,original);assert.deepEqual(accepted,saved); +const conflict=structuredClone(accepted);conflict.config.digits=4; +assert.equal(rebasePropertyDraft(draft,conflict),null); +const replaced=structuredClone(accepted);replaced.identity.figure='figure-b'; +assert.equal(rebasePropertyDraft(draft,replaced),null); +const moved=structuredClone(accepted);moved.identity.panels[0].id='other'; +assert.equal(rebasePropertyDraft(draft,moved),null); +const already=structuredClone(accepted);already.config.digits=2; +assert.deepEqual(rebasePropertyDraft(draft,already),already); +const arrayEdit=structuredClone(base);arrayEdit.series[0].width=2; +const reordered=structuredClone(base);reordered.series.reverse(); +assert.equal(rebasePropertyDraft(propertyDraft(base,arrayEdit),reordered),null); +const deleted=structuredClone(base);delete deleted.config.digits; +const deletion=rebasePropertyDraft(propertyDraft(base,deleted),accepted); +assert.equal(Object.hasOwn(deletion.config,'digits'),false);assert.equal(deletion.config.grid,true); +const inserted=structuredClone(base);inserted.config.newStyle={alpha:0.25}; +assert.deepEqual(rebasePropertyDraft(propertyDraft(base,inserted),accepted).config.newStyle,{alpha:0.25}); +""") diff --git a/tests/test_interaction_parity.py b/tests/test_interaction_parity.py index afd2953..458bbf7 100644 --- a/tests/test_interaction_parity.py +++ b/tests/test_interaction_parity.py @@ -1,6 +1,8 @@ from object_navigation import ObjectNavigator """Same input scenarios must work on native surfaces and real notebook WebGPU.""" import json +import base64 +import subprocess import os import shutil import time @@ -8,6 +10,7 @@ import pytest import figgyplot as figgy from test_radial_desktop import Inputs +from native_input import set_clipboard from jupyter_harness import notebook, button_point_without_frames, click_button from browser_timing import screenshot_editor @@ -44,6 +47,103 @@ def test_legacy_settings_and_heatmap_legend_preserve_shared_host_model(backend): NEEDS_UI = not os.environ.get('DISPLAY') or not all(shutil.which(p) for p in ['xdotool','xclip','wmctrl']) or os.environ.get('FIGGY_TEST_JUPYTER') != '1' +@pytest.mark.skipif(NEEDS_UI, reason='requires actual native/Jupyter copy controls') +def test_compact_python_copy_in_both_hosts_replays_the_complete_document(tmp_path): + scene = ''' +fig.set_layout([['first','second']]) +fig.subplots['first'].plot([0,1],[1,2],id='signal',label='Signal') +fig.subplots['first'].set_title('Comparison\\nTwo lines') +fig.subplots['first'].text(.2,.3,'한글 note',coords='ratio',id='note') +fig.subplots['second'].imshow([[1,2],[3,4]],id='field') +''' + with figgy.Figure(backend='native') as fig: + exec(scene, {'fig':fig}) + handle = fig.show(block=False) + try: + inputs = Inputs(handle) + inputs.click('copy') + code = subprocess.check_output(['xclip','-selection','clipboard','-o'],text=True,timeout=3) + assert code == fig.to_code() and 'batch_update' in code + assert len(code) < len(fig.to_code(compact=False)) * .55 + with figgy.Figure(backend='native') as other: + exec(scene, {'fig':other}) + exec(code, {'fig':other}) + assert other.get_settings()['config'] == fig.get_settings()['config'] + assert other.get_settings()['series'] == fig.get_settings()['series'] + np.testing.assert_array_equal(other.render_rgba(), fig.render_rgba()) + (tmp_path/'native-copy.py').write_text(code) + finally: + handle.close(); Inputs.pump() + source = (f'import figgyplot as fg\nassert fg.__file__ == {figgy.__file__!r}\n' + "fig=fg.Figure(backend='webgpu')\n"+scene+'\ndisplay(fig.widget())') + with notebook(tmp_path/'browser',source) as (page,execute): + click_button(page,'[data-copy]') + page.evaluate(ROOT+'.flush()') + execute('await fig.sync()') + code=page.locator('textarea[data-code]').input_value() + assert code and 'batch_update' in code + (tmp_path/'browser-copy.py').write_text(code) + execute(f"code={code!r}\nassert code==fig.to_code()\nother=fg.Figure(backend='webgpu')\n"+ + f"exec({scene!r},{{'fig':other}})\nexec(code,{{'fig':other}})\nassert other.get_settings()['config']==fig.get_settings()['config']\nassert other.get_settings()['series']==fig.get_settings()['series']\nother.close()") + + +def assert_blind_trial_title_pixels(image): + assert image.shape == (500,700,4) + rgb = image[..., :3].astype(int) + ink = (rgb[:,:,0] > 130) & (rgb[:,:,1] < 90) & (rgb[:,:,2] < 90) + rows = np.flatnonzero(ink.sum(axis=1) >= 2) + bands = np.split(rows, np.flatnonzero(np.diff(rows) > 1) + 1) + assert len(bands) == 2 and all(len(b) >= 10 for b in bands), [b.tolist() for b in bands] + assert ink.sum() > 150 + return dict(title_bands=[b.tolist() for b in bands], colored_pixels=int(ink.sum())) + + +def test_blind_trial_title_oracle_rejects_black_and_missing_second_line(): + with pytest.raises(AssertionError): + assert_blind_trial_title_pixels(np.zeros((500,700,4), dtype=np.uint8)) + with figgy.Figure(backend='native', figsize=(7,5), dpi=100) as fig: + fig.axes[0].bar(['A','B'],[2,4]) + fig.axes[0].set_title('FIRST',fontsize=18,color='#b42020') + with pytest.raises(AssertionError): + assert_blind_trial_title_pixels(fig.render_rgba()) + + +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +@pytest.mark.skipif(NEEDS_UI, reason='requires native/Jupyter GPU environment') +def test_blind_trial_titles_render_two_real_lines_in_both_hosts(tmp_path, backend): + """Ready is insufficient: both title lines must be present on the image.""" + from browser_timing import DISPLAY_SETTLED + from PIL import Image + import io + scene = ''' +ax=fig.axes[0] +ax.set_xlabel('Requested X') +ax.set_ylabel('Requested Y') +ax.set_title('FIRST\\nSECOND',fontsize=18,color='#b42020') +ax.bar(['A','B'],[2,4]) +''' + if backend == 'native': + with figgy.Figure(backend=backend, figsize=(7,5), dpi=100) as fig: + exec(scene, {'fig': fig}) + image = fig.render_rgba() + fig.savefig(tmp_path/'native.png') + state = fig.get_settings() + else: + source = (f"import figgyplot as fg\nassert fg.__file__ == {figgy.__file__!r}\n" + "fig=fg.Figure(backend='webgpu',figsize=(7,5),dpi=100)\n" + scene + "\ndisplay(fig.widget())") + with notebook(tmp_path, source) as (page, execute): + page.wait_for_function(DISPLAY_SETTLED, polling=100, timeout=180000) + canvas = page.locator('.figgy-editor canvas') + canvas.evaluate('''c=>{const r=c.getBoundingClientRect();c.closest('.figgy-editor').style.transform=`translate(${Math.ceil(r.x)-r.x}px,${Math.ceil(r.y)-r.y}px)`;}''') + image = np.asarray(Image.open(io.BytesIO(canvas.screenshot(path=str(tmp_path/'browser.png')))).convert('RGBA')) + state = page.evaluate(ROOT + '.snapshot()') + chart = state['categorical'] + assert chart['category_title'] == 'Requested X' and chart['value_title'] == 'Requested Y' + assert ''.join(s['text'] for s in chart['chart_title']['text']['segments']) == 'FIRST\nSECOND' + result = assert_blind_trial_title_pixels(image) + (tmp_path/'contract.json').write_text(json.dumps(dict(backend=backend,**result,settings=state),indent=2)) + + @pytest.mark.parametrize('backend',['native','webgpu']) @pytest.mark.parametrize('panel',['main','child']) def test_marker_size_changes_keep_legend_symbols_owned_by_labelled_series(backend,panel): @@ -129,6 +229,10 @@ def browser_point(page, logical): return r['x']+logical[0]*r['width']/960, r['y']+logical[1]*r['height']/600 +def browser_surface_point(page, point): + return page.evaluate("""p=>{const c=document.querySelector('.figgy-editor canvas'),r=c.getBoundingClientRect();return [r.x+p[0]*r.width/c.width,r.y+p[1]*r.height/c.height];}""", point) + + def check_bounded_menus_during_preparation(page, execute, kind): """Hold actual GPU completion so toolbar clicks overlap an async WASM borrow.""" menu_points = [] @@ -1468,10 +1572,15 @@ def test_bounded_value_range_wheel_and_fit_work_in_both_hosts(tmp_path, family): rect=inputs.info()['decorations']['data_area'];point=native_point(inputs.info(),(rect[0]+rect[2]/2,rect[1]+rect[3]/2)) inputs.xdo('mousemove','--window',inputs.wid,*map(round,point));inputs.pump();inputs.xdo('click',4);inputs.pump() after=fig.get_settings()[kind] - assert after['value_range'] is not None + assert after['value_range'] is None if kind=='radar' else after['value_range'] is not None assert after['series']==before['series'] panel_before=inputs.info()['chart_panel'] + before_inspector=inputs.info() inputs.click('properties');assert 'range_min' in inputs.info()['controls'] + if inputs.info()['chart_panel']!=panel_before: + from PIL import ImageGrab + (tmp_path/'inspector-layout.json').write_text(json.dumps(dict(before=before_inspector,after=inputs.info()),indent=2)) + ImageGrab.grab(xdisplay=os.environ['DISPLAY']).save(tmp_path/'inspector-layout.png') assert inputs.info()['chart_panel']==panel_before inputs.click('fit');assert fig.get_settings()[kind]['value_range'] is None assert fig.info['rgba_readbacks']==fig.info['png_encodes']==0 @@ -1481,7 +1590,8 @@ def test_bounded_value_range_wheel_and_fit_work_in_both_hosts(tmp_path, family): original=page.evaluate(ROOT+'.snapshot()')[kind] area=page.evaluate('async()=> (await '+ROOT+'.decorations()).find(v=>v.id==="data_area").rect') page.mouse.move(*browser_point(page,(area[0]+area[2]/2,area[1]+area[3]/2)));page.mouse.wheel(0,-40) - page.wait_for_function(ROOT+f'.snapshot().{kind}.value_range !== null') + if kind!='radar':page.wait_for_function(ROOT+f'.snapshot().{kind}.value_range !== null') + else:assert page.evaluate(ROOT+f'.snapshot().{kind}.value_range') is None page.evaluate(ROOT+'.flush()') state=page.evaluate(ROOT+'.snapshot()')[kind] assert state['series']==original['series'] @@ -2802,8 +2912,7 @@ def test_native_review_long_comments_keep_approval_controls_reachable(tmp_path,f session.comment(fig.id,'Preserve the original data',target={'series_id':'signal'}) inputs.click('review_comments') import subprocess - subprocess.run(['xclip','-selection','clipboard'],input=comments,text=True, - stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,check=True) + set_clipboard(comments) inputs.key('ctrl+v');inputs.pump();session.poll() info=inputs.info();width,height=info['surface_size'] for key in ['review_comments','review_changes','review_accept']: @@ -2869,3 +2978,538 @@ def builder(title, extra=False): page.wait_for_function("[...document.querySelectorAll('.figgy-editor')].every(r=>{const d=r.figgy.diagnostics();return !d.dirty&&!d.frame_pending&&!d.busy&&!d.queued;})",polling=100,timeout=180000) for index in range(2):page.locator('.figgy-editor').nth(index).screenshot(path=str(tmp_path/f'complete-next-round-{index}.png')) execute('watcher.cancel(); session.cancel()') + + +@pytest.mark.skipif(NEEDS_UI, reason='requires native and real Jupyter input') +def test_layout_selects_plot_on_first_click_and_exposes_real_geometry(tmp_path): + from PIL import ImageGrab + with figgy.Figure(backend='native',grid=[['left','right']]) as fig: + fig.subplots['left'].plot([0,1],[2,3],id='one') + fig.subplots['right'].plot([0,1],[3,2],id='two') + handle=fig.show(block=False) + try: + inputs=Inputs(handle);inputs.click('layout_mode') + inputs.click_rect([*native_point(inputs.info(),(240,300)),0,0]) + assert inputs.info()['selected']=='data_area' + assert inputs.info()['properties_open'] + assert all('plot_'+key in inputs.info()['controls'] for key in ['x','y','width','height']) + before=fig.get_settings();inputs.click('plot_width');inputs.key('ctrl+a');inputs.xdo('type','--clearmodifiers','220');inputs.key('Return') + assert not inputs.info()['error'] + owner=next(v for v in fig.editor_properties() if v['target']=='data_area') + bounds=next(v['value'] for v in owner['fields'] if v['id']=='Plot area/bounds') + assert bounds[2]==220 + assert fig.get_settings()['config']['chart_area']==before['config']['chart_area'] + # Make the inspector wider using its actual separator. + old=inputs.info()['controls']['inspector_resize'] + drag_native(inputs,(old[0]+3,old[1]+old[3]/2),(-100,0)) + assert inputs.info()['controls']['plot_width'][0] < inputs.info()['surface_size'][0]-150 + ImageGrab.grab(xdisplay=os.environ['DISPLAY']).save(tmp_path/'native-layout-controls.png') + assert fig.info['column_uploads']==4 + finally:handle.close();Inputs.pump() + source="import figgyplot as fg\nfig=fg.Figure(backend='webgpu',grid=[['left','right']])\nfig.subplots['left'].plot([0,1],[2,3],id='one')\nfig.subplots['right'].plot([0,1],[3,2],id='two')\ndisplay(fig.widget())" + with notebook(tmp_path/'browser-layout',source) as (page,execute): + check_display_frame_ready(page) + click_button(page,'[data-layout]') + page.mouse.click(*browser_point(page,(240,300))) + control=page.locator('[data-layout-component=width]');control.wait_for(state='visible') + control.fill('220');control.press('Tab');page.evaluate(ROOT+'.flush()') + catalog=json.loads(execute('import json;print(json.dumps(fig.editor_properties()))')) + owner=next(o for o in catalog if o['target']=='data_area') + assert next(f['value'] for f in owner['fields'] if f['id']=='Plot area/bounds')[2]==220 + screenshot_editor(page,tmp_path/'browser-layout-controls.png') + assert not page.locator('.figgy-error').inner_text() + + +@pytest.mark.skipif(NEEDS_UI, reason='requires native and real Jupyter input') +def test_polar_wheel_does_not_modify_angular_or_radial_ranges(tmp_path): + with figgy.Figure(backend='native') as fig: + ax=fig.axes[0];ax.set_polar();ax.plot([0,90,180,270],[1,2,3,2]) + handle=fig.show(block=False) + try: + inputs=Inputs(handle);rect=inputs.info()['decorations']['data_area'] + point=(rect[0]+rect[2]/2,rect[1]+rect[3]/2) + inputs.xdo('mousemove','--window',inputs.wid,*map(round,point));inputs.pump() + before=fig.get_settings() + for modifier in [None,'ctrl','shift']: + if modifier:inputs.xdo('keydown',modifier) + inputs.xdo('click',4);inputs.pump() + if modifier:inputs.xdo('keyup',modifier) + assert fig.get_settings()['config']==before['config'] + assert fig.info['column_uploads']==2 + finally:handle.close();Inputs.pump() + source="import figgyplot as fg\nfig,ax=fg.subplots(backend='webgpu')\nax.set_polar();ax.plot([0,90,180,270],[1,2,3,2])\ndisplay(fig.widget())" + with notebook(tmp_path/'polar-wheel',source) as (page,execute): + check_display_frame_ready(page) + before=page.evaluate(ROOT+'.snapshot()') + area=page.evaluate('async()=> (await '+ROOT+'.decorations()).data_area.rect') + page.mouse.move(*browser_surface_point(page,(area[0]+area[2]/2,area[1]+area[3]/2))) + for modifier in [None,'Control','Shift']: + if modifier:page.keyboard.down(modifier) + page.mouse.wheel(0,-40) + if modifier:page.keyboard.up(modifier) + page.evaluate(ROOT+'.flush()') + after=page.evaluate(ROOT+'.snapshot()') + assert after['config']==before['config'] + + +@pytest.mark.skipif(NEEDS_UI, reason='requires native and real Jupyter input') +def test_element_review_comments_use_selected_font_and_preserve_ssot(tmp_path): + from figgyplot.review import Session + from figgyplot._native import font_family_available + from PIL import ImageGrab + family='Noto Sans CJK KR' if font_family_available('Noto Sans CJK KR') else 'DejaVu Sans' + text='이 주석을 오른쪽으로 이동해 주세요' if family=='Noto Sans CJK KR' else 'Move this annotation to the right' + with Session(tmp_path/'native-cache',backend='native',review_font=family,review_font_size=17) as session: + fig=session.figure(id='plot');fig.axes[0].plot([0,1],[2,3],id='one') + fig.axes[0].set_title('Review title') + fig.axes[0].text(.3,.3,'Review this element',coords='ratio',id='note') + session.present();handle=session._handles['plot'] + inputs=Inputs(handle) + before=fig.get_settings() + bounds=json.loads(fig._native.bounds_json('annotation:note'))['rect'] + point=native_point(inputs.info(),(bounds[0]+bounds[2]/2,bounds[1]+bounds[3]/2)) + inputs.input_frame('mousemove','--window',inputs.wid,*map(round,point));inputs.input_frame('click',3) + target=next(v['id'] for v in fig.editor_properties() if v['target']=='annotation:note') + inputs.click('review_target:'+target);inputs.click('review_target_text') + import subprocess + set_clipboard(text) + inputs.key('ctrl+v');inputs.pump() + ImageGrab.grab(xdisplay=os.environ['DISPLAY']).save(tmp_path/'native-element-comment.png') + inputs.click('review_target_save');session.poll() + comments=session._review_state('plot')['target_comments'] + assert len(comments)==1 and comments[0]['target']==target and comments[0]['text']==text + comment_id=comments[0]['id'] + inputs.click('review_target_list');inputs.click('review_target_edit:'+comment_id) + inputs.click('review_target_text');inputs.key('ctrl+a') + set_clipboard(text+'!') + inputs.key('ctrl+v');inputs.click('review_target_save');session.poll();inputs.key('Escape') + assert session._review_state('plot')['target_comments'][0]['id']==comment_id + # A decoration uses its own stable target, separately from annotations. + bounds=inputs.info()['decorations']['chart_title'] + inputs.input_frame('mousemove','--window',inputs.wid,round(bounds[0]+bounds[2]/2),round(bounds[1]+bounds[3]/2));inputs.input_frame('click',3) + title=next(v['id'] for v in fig.editor_properties() if v['target']=='chart_title') + inputs.click('review_target:'+title);inputs.click('review_target_text') + inputs.xdo('type','--clearmodifiers','Clarify the title');inputs.click('review_target_save');session.poll() + comments=session._review_state('plot')['target_comments'];assert len(comments)==2 + title_comment=next(c for c in comments if c['target']==title) + inputs.click('review_target_list');inputs.click('review_target_delete:'+title_comment['id']);session.poll();inputs.key('Escape') + comments=session._review_state('plot')['target_comments'] + assert len(comments)==1 and comments[0]['id']==comment_id and comments[0]['text']==text+'!' + assert fig.get_settings()==before + inputs.click('review_changes');session.poll() + assert session.events() + assert session.feedback(session.events()[0]['work_id'])['round']['documents']['plot']['comments'][0]['text']==text+'!' + work=session.events()[0]['work_id'] + summary=session.feedback_summary(work) + obj,=summary['figures'][0]['objects'] + assert obj['target']==target and obj['change_count']==0 and obj['comments'][0]['id']==comment_id + detail=session.feedback_details(work,'plot',target=target) + assert not detail['changes'] and detail['objects'][0]['snapshots']['after'] + source="import figgyplot as fg\nfrom figgyplot.review import Session\nsession=Session(backend='webgpu',review_font="+repr(family)+",review_font_size=17)\nfig=session.figure(id='plot');fig.axes[0].plot([0,1],[2,3],id='one')\nfig.axes[0].text(.3,.3,'Review this element',coords='ratio',id='note')\ndisplay(session.widget(fig))" + with notebook(tmp_path/'browser-comments',source) as (page,execute): + check_display_frame_ready(page) + page.wait_for_function("document.querySelector('.figgy-editor').style.getPropertyValue('--figgy-review-font') !== ''") + before=page.evaluate(ROOT+'.snapshot()') + bounds=page.evaluate(ROOT+'.annotations()')[0]['rect'] + page.mouse.click(*browser_surface_point(page,(bounds[0]+bounds[2]/2,bounds[1]+bounds[3]/2)),button='right') + menu=page.locator('[data-review-target]');menu.wait_for(state='visible');target=menu.get_attribute('data-review-target');menu.click() + page.locator('[data-review-target-text]').fill(text) + assert page.locator('[data-review-target-text]').evaluate('e=>getComputedStyle(e).fontSize')=='17px' + screenshot_editor(page,tmp_path/'browser-element-comment.png') + page.locator('[data-review-target-save]').click() + assert json.loads(execute('import json;print(json.dumps(session._review_state("plot")["target_comments"]))'))[0]['target']==target + click_button(page,'[data-review-target-list]');page.get_by_role('button',name='Edit',exact=True).click() + page.locator('[data-review-target-text]').fill(text+'!');page.locator('[data-review-target-save]').click() + # A series can be addressed directly from the object tree, even in an overlay. + click_button(page,'[data-properties]') + row=page.locator('[role=treeitem][data-object="one"]');row.wait_for(state='visible');row.click(button='right') + menu=page.locator('[data-review-target]');series_target=menu.get_attribute('data-review-target');menu.click() + assert series_target!=target + page.locator('[data-review-target-text]').fill('Clarify this series');page.locator('[data-review-target-save]').click() + click_button(page,'[data-review-target-list]') + page.locator('dialog section').filter(has_text='Clarify this series').get_by_role('button',name='Delete',exact=True).click() + page.get_by_role('button',name='Close',exact=True).last.click() + comments=json.loads(execute('import json;print(json.dumps(session._review_state("plot")["target_comments"]))')) + assert len(comments)==1 and comments[0]['target']==target and comments[0]['text']==text+'!' + assert page.evaluate(ROOT+'.snapshot()')==before + assert not page.locator('[data-review-status]').inner_text().startswith('Review font could not') + assert page.evaluate("document.fonts.status")=='loaded' + assert not page.locator('.figgy-error').inner_text() + + +@pytest.mark.skipif(NEEDS_UI, reason='requires native and real Jupyter input') +def test_review_connect_and_finish_buttons_use_a_terminal_lifecycle_signal(tmp_path,monkeypatch): + from figgyplot.review import Session + with Session(tmp_path/'native-connect',backend='native') as session: + fig=session.figure(id='plot');fig.axes[0].plot([0,1],[2,3],id='signal') + session.start();before=fig.get_settings();session._set_comment_text('plot','저장 후 종료') + poll=session.poll;clicked=False + def automate_review(): + nonlocal clicked + poll() + if not clicked: + inputs=Inputs(session._handles['plot']) + inputs.click('review_finish');clicked=True + poll() + monkeypatch.setattr(session,'poll',automate_review) + event=session.connect(timeout=10) + assert clicked and event['intent']=='finish' + assert session.snapshot()['rounds'][-1]['documents']['plot']['approval']['settings']==before + assert session.finish(event['work_id'])==1 + assert session.status['state']=='completed' + assert not session._handles + source="import json\nfrom figgyplot.review import Session\nsession=Session(backend='webgpu')\nfig=session.figure(id='plot');fig.axes[0].plot([0,1],[2,3],id='signal')\ndisplay(session.widget(fig))" + with notebook(tmp_path/'browser-finish',source,allow_closed=True) as (page,execute): + check_display_frame_ready(page) + before=page.evaluate(ROOT+'.snapshot()') + page.locator('[data-review-comments]').fill('저장 후 종료') + click_button(page,'[data-review-finish]') + page.wait_for_function("!document.querySelector('.figgy-editor')") + event=json.loads(execute('print(json.dumps(session.events()[0]))')) + assert event['intent']=='finish' + approved=json.loads(execute('print(json.dumps(session.snapshot()["rounds"][-1]["documents"]["plot"]["approval"]["settings"]))')) + assert approved==before + assert execute('print(session.finish(session.events()[0]["work_id"]))').strip()=='1' + assert json.loads(execute('print(json.dumps(session.status))'))['state']=='completed' + + +@pytest.mark.skipif(NEEDS_UI, reason='requires native and real Jupyter input') +@pytest.mark.parametrize('annotation',['arrow','text']) +def test_annotation_drag_reuses_inspector_descriptions_until_release(tmp_path, monkeypatch, annotation): + from time import perf_counter + measurement={} + monkeypatch.setenv('FIGGY_PROFILE_HOST','1') + def assert_only_arrow_coordinates_changed(host,before,after): + (tmp_path/f'{host}-drag-ssot.json').write_text(json.dumps(dict(before=before,after=after),indent=2)) + old=json.loads(json.dumps(before));new=json.loads(json.dumps(after)) + kind='Arrow' if annotation=='arrow' else 'Text' + a=old['config']['annotations']['items'][0]['kind'][kind] + b=new['config']['annotations']['items'][0]['kind'][kind] + keys=('from','to') if annotation=='arrow' else ('anchor',) + for key in keys: + assert a[key]!=b[key] + for axis in ('x','y'): + assert b[key]['Ratio'][axis]>a[key]['Ratio'][axis] + b[key]=a[key] + assert new==old + with figgy.Figure(backend='native') as fig: + fig.axes[0].plot([0,1],[2,3],id='one') + if annotation=='arrow':fig.axes[0].arrow(.2,.2,.3,.2,coords='ratio',id='arrow') + else:fig.axes[0].text(.2,.2,'Move this text',coords='ratio',id='arrow') + fig.axes[0].text(.7,.7,'Keep this annotation',coords='ratio',id='fixed') + handle=fig.show(block=False) + try: + inputs=Inputs(handle);inputs.click('properties') + rect=json.loads(fig._native.bounds_json('annotation:arrow'))['rect'] + start=native_point(inputs.info(),(rect[0]+rect[2]/2,rect[1]+rect[3]/2)) + inputs.click_rect([*start,0,0]);before=inputs.info()['property_catalog_builds'];old=fig.get_settings();uploads=fig.info['column_uploads'] + if annotation=='text':time.sleep(.5) # This gesture is a drag, not double-click text editing. + inputs.input_frame('mousemove','--window',inputs.wid,*map(round,start));inputs.input_frame('mousedown',1) + work_before=fig.info['host_work'] + begin=perf_counter() + for step in range(1,7):inputs.input_frame('mousemove','--window',inputs.wid,round(start[0]+step*3),round(start[1]+step*2)) + during=inputs.info()['property_catalog_builds'];inputs.input_frame('mouseup',1) + work_after=fig.info['host_work'] + for stage in ('annotation_view','native_axis_refresh','native_styles'): + assert work_after.get(stage,{}).get('count',0)==work_before.get(stage,{}).get('count',0), (stage,work_before,work_after) + assert work_after['native_annotation_refresh']['count']>work_before.get('native_annotation_refresh',{}).get('count',0) + measurement['native']=dict(moves=6,inspector_rebuilds_during=during-before,elapsed=perf_counter()-begin,last_input_to_submit_ms=inputs.info()['last_input_to_submit_ms']) + assert during-before<=1 + assert_only_arrow_coordinates_changed('native',old,fig.get_settings()) + assert fig.info['column_uploads']==uploads + finally:handle.close();Inputs.pump() + item="ax.arrow(.2,.2,.3,.2,coords='ratio',id='arrow')" if annotation=='arrow' else "ax.text(.2,.2,'Move this text',coords='ratio',id='arrow')" + source="import figgyplot as fg\nfig,ax=fg.subplots(backend='webgpu')\nax.plot([0,1],[2,3],id='one');"+item+"\nax.text(.7,.7,'Keep this annotation',coords='ratio',id='fixed')\ndisplay(fig.widget())" + with notebook(tmp_path/'browser-drag',source) as (page,execute): + check_display_frame_ready(page) + click_button(page,'[data-properties]') + rect=page.evaluate(ROOT+'.annotations()')[0]['rect'] + start=browser_surface_point(page,(rect[0]+rect[2]/2,rect[1]+rect[3]/2)) + page.mouse.click(*start);page.evaluate(ROOT+'.flush()') + if annotation=='text':time.sleep(.5) + before=page.evaluate(ROOT+'.stats.inspector_rebuilds') + old=page.evaluate(ROOT+'.snapshot()');uploads=page.evaluate(ROOT+'.stats.column_uploads') + begin=perf_counter();page.mouse.move(*start);page.mouse.down() + page.mouse.move(start[0]+24,start[1]+16,steps=8) + during=page.evaluate(ROOT+'.stats.inspector_rebuilds') + page.mouse.up();page.evaluate(ROOT+'.flush()') + measurement['browser']=dict(moves=8,inspector_rebuilds_during=during-before,elapsed=perf_counter()-begin) + assert during-before<=1 + assert_only_arrow_coordinates_changed('browser',old,page.evaluate(ROOT+'.snapshot()')) + assert page.evaluate(ROOT+'.stats.column_uploads')==uploads + assert not page.locator('.figgy-error').inner_text() + (tmp_path/'wrapper-drag-measurement.json').write_text(json.dumps(measurement,indent=2)) + + +@pytest.mark.skipif(NEEDS_UI, reason='requires actual native review and clipboard input') +@pytest.mark.parametrize('kind',['line','radial']) +def test_native_review_images_paste_preview_remove_and_targeted_submission(tmp_path,kind): + from PIL import Image, ImageGrab + from figgyplot.review import Session + from figgyplot._review_diff import persistent + from figgyplot._native import font_family_available + image=tmp_path/'reference.png';Image.new('RGB',(96,64),'#E69F00').save(image) + family='Noto Sans CJK KR' if font_family_available('Noto Sans CJK KR') else 'DejaVu Sans' + with Session(tmp_path/'cache',backend='native',review_font=family) as session: + fig=session.figure(id='plot');ax=fig.axes[0] + if kind=='radial':ax.donut([3,2,1],labels=['A','B','C']) + else:ax.plot([0,1],[2,3],id='signal') + ax.set_title('Image review') + session.present();inputs=Inputs(session._handles['plot']);before=fig.get_settings() + def clipboard_image(): + subprocess.run(['xclip','-selection','clipboard','-t','image/png','-i',str(image)],check=True, + stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=10) + clipboard_image();inputs.click('review_comments');inputs.key('ctrl+v');session.poll() + ref,=session._review_state('plot')['attachments'] + assert 'data' not in inputs.info()['review']['attachments'][0] + inputs.click('review_image:'+ref['id']);inputs.key('Escape') + inputs.click('review_image_remove:'+ref['id']);session.poll() + assert not session._review_state('plot')['attachments'] + inputs.click('review_paste');session.poll() + assert len(session._review_state('plot')['attachments'])==1 + bounds=inputs.info()['decorations']['chart_title'] + if kind=='radial': + # Bounded-host decoration stats are document pixels; Cartesian + # decorations are already in surface coordinates. + panel=inputs.info()['chart_panel'];area=before['config']['chart_area'] + bounds=[panel[0]+bounds[0]*panel[2]/area['width'],panel[1]+bounds[1]*panel[3]/area['height'],bounds[2]*panel[2]/area['width'],bounds[3]*panel[3]/area['height']] + inputs.input_frame('mousemove','--window',inputs.wid,round(bounds[0]+bounds[2]/2),round(bounds[1]+bounds[3]/2));inputs.input_frame('click',3) + target='bounded/chart_title' if kind=='radial' else 'panel/main/chart_title' + inputs.click('review_target:'+target);inputs.click('review_target_text');inputs.key('ctrl+v') + assert any(k.startswith('review_target_image:') for k in inputs.info()['controls']) + inputs.click('review_target_save');session.poll() + target_comment,=session._review_state('plot')['target_comments'];assert not target_comment['text'] + assert len(target_comment['attachments'])==1 + inputs.click('review_target_list');inputs.click('review_target_edit:'+target_comment['id']) + inputs.click('review_target_image_remove:'+ref['id']);inputs.click('review_target_paste') + inputs.click('review_target_save');session.poll();inputs.key('Escape') + set_clipboard('Keep the palette');inputs.click('review_comments');inputs.key('ctrl+v');session.poll() + assert session._review_state('plot')['comments']=='Keep the palette' + assert persistent(fig.get_settings())==persistent(before) + ImageGrab.grab(xdisplay=os.environ['DISPLAY']).save(tmp_path/'review-images.png') + inputs.click('review_changes');session.poll() + small=session.feedback_summary(session.events()[0]['work_id']) + figure,=small['figures'];obj,=figure['objects'] + assert obj['target']==target and obj['change_count']==0 + assert figure['comments'][0]['attachments'][0]['path']==obj['comments'][0]['attachments'][0]['path'] + assert os.path.isfile(obj['comments'][0]['attachments'][0]['path']) + Inputs.pump() + + +@pytest.mark.skipif(NEEDS_UI, reason='requires real notebook WebGPU and clipboard input') +def test_browser_review_images_upload_clipboard_preview_and_targeted_submission(tmp_path): + from PIL import Image + orange=tmp_path/'reference.png';green=tmp_path/'clipboard.png' + Image.new('RGB',(96,64),'#E69F00').save(orange);Image.new('RGB',(96,64),'#009E73').save(green) + cache=tmp_path/'cache' + source=("from figgyplot.review import Session\n" + f"session=Session({str(cache)!r},backend='webgpu')\n" + "fig=session.figure(id='plot');ax=fig.axes[0]\n" + "ax.plot([0,1],[2,3],id='signal');ax.text(.3,.3,'Review me',coords='ratio',id='note')\n" + "display(session.widget(fig))") + with notebook(tmp_path/'browser-images',source,allow_closed=True) as (page,execute): + check_display_frame_ready(page);before=page.evaluate(ROOT+'.snapshot()') + general=page.locator('.figgy-review') + general.locator('input[type=file]').set_input_files(orange) + general.locator('[data-review-image]').wait_for() + page.evaluate("""async encoded=>{const bytes=Uint8Array.from(atob(encoded),c=>c.charCodeAt(0));await navigator.clipboard.write([new ClipboardItem({'image/png':new Blob([bytes],{type:'image/png'})})]);}""",base64.b64encode(green.read_bytes()).decode()) + general.locator('[data-review-comments]').click();page.keyboard.press('Control+V') + page.wait_for_function("document.querySelectorAll('.figgy-review [data-review-image]').length===2") + general.locator('[data-review-image]').first.click() + page.wait_for_function("document.querySelector('.figgy-review-image-preview img')?.naturalWidth===96") + page.locator('.figgy-review-image-preview button').click() + general.locator('[data-review-image-remove]').first.click() + assert general.locator('[data-review-image]').count()==1 + general.locator('[data-review-comments]').fill('Keep the palette') + # Editing the review field scrolls a tall notebook output to its bottom. + # Bring the chart into view before deriving a viewport pointer position. + page.locator('.figgy-editor canvas').scroll_into_view_if_needed() + bounds=page.evaluate(ROOT+'.annotations()')[0]['rect'] + page.mouse.click(*browser_surface_point(page,(bounds[0]+bounds[2]/2,bounds[1]+bounds[3]/2)),button='right') + page.locator('[data-review-target]').click() + dialog=page.locator('.figgy-review-dialog') + assert page.locator('.lm-Menu:visible').count()==0 + dialog.locator('input[type=file]').set_input_files(orange) + dialog.locator('[data-review-target-image]').wait_for() + dialog.locator('[data-review-target-save]').click() + assert page.evaluate(ROOT+'.snapshot()')==before + screenshot_editor(page,tmp_path/'browser-review-images.png') + general.locator('[data-review-changes]').click() + output=execute("import time,json\nfor _ in range(100):\n if session.events():break\n time.sleep(.01)\nprint(json.dumps(session.feedback_summary(session.events()[0]['work_id'])))") + summary=json.loads(output.strip());figure,=summary['figures'];obj,=figure['objects'] + assert obj['target']=='panel/main/annotation/note' and obj['change_count']==0 + assert not obj['comments'][0]['text'] + a=figure['comments'][0]['attachments'][0];b=obj['comments'][0]['attachments'][0] + assert a['sha256']!=b['sha256'] and os.path.isfile(a['path']) and os.path.isfile(b['path']) + + +@pytest.mark.skipif(NEEDS_UI, reason='requires native and real Jupyter input') +def test_style_api_composition_renders_consistent_grid_and_colorbar_in_both_hosts(tmp_path): + from PIL import Image + source = '''import figgyplot as fg +fg.use_style('dashboard.operations') +template=fg.Figure(backend=BACKEND) +template.set_layout([['left','right']],gap=44,column_weights=[2,1]) +style=template.extract_style();template.close() +fig=fg.Figure(backend=BACKEND,style=style,grid=[['left','right']]) +fg.use_style(None) +fig.subplots['left'].plot([0,1],[1,2],color='#64c6ef',id='left-line') +right=fig.subplots['right'].axes +right.scatter([0,1],[1,2],c=[0,1],id='right-points') +state=fig.get_settings() +bar=state['config']['subplots'][0]['config']['colorbar'] +bar['gap_px']=17;bar['axis']['label_style']['font_size']=16 +fig.apply_settings(state) +right.imshow([[1,2],[3,4]],cmap='magma',id='field') +fig.subplots['left'].set_title('Measured') +right.set_title('Mapped values') +''' + def check(state): + layout=state['identity']['layout'];width,height=layout['size'] + first=round((width-44)*2/3);start=round((width-44)*2/3+44) + assert layout['gap']==44 and layout['column_weights']==[2,1] + assert state['config']['chart_area']==dict(x=0,y=0,width=first,height=height) + panel=state['config']['subplots'][0]['config'] + assert panel['chart_area']==dict(x=start,y=0,width=width-start,height=height) + assert panel['colorbar']['gap_px']==17 + assert panel['colorbar']['axis']['label_style']['font_size']==16 + assert panel['colorbar']['colormap']=='Magma' + assert [s['series_id'] for s in state['series']]==['left-line','right-points','field'] + def check_pixels(image): + rgb=np.asarray(image.convert('RGB')).astype(int) + boundary=round(rgb.shape[1]*.65) + left=rgb[:,:boundary];right=rgb[:,boundary:] + # These are authored scene colors, independent of the SSOT serializer + # and host Ready flag. A blank/black presented canvas must fail. + cyan=np.all(np.abs(left-[100,198,239])<=5,axis=2) + yellow=(right[:,:,0]>220)&(right[:,:,1]>150)&(right[:,:,2]<220)&(right[:,:,0]-right[:,:,2]>25) + assert cyan.sum()>50,'The displayed left-panel signal is missing' + assert yellow.sum()>100,'The displayed right-panel scalar field is missing' + namespace={};exec(source.replace('BACKEND',repr('native')),namespace) + fig=namespace['fig'] + try: + check(fig.get_settings());fig.savefig(tmp_path/'native-style-composition.png') + with Image.open(tmp_path/'native-style-composition.png') as image:check_pixels(image) + handle=fig.show(block=False) + try: + inputs=Inputs(handle);inputs.pump() + assert inputs.info()['chart_panel'][2]>0 + assert fig.info['column_uploads']==9 + finally:handle.close();Inputs.pump() + finally:fig.close() + with notebook(tmp_path/'browser-style-composition',source.replace('BACKEND',repr('webgpu'))+'\ndisplay(fig.widget())') as (page,execute): + check_display_frame_ready(page) + check(json.loads(execute('import json;print(json.dumps(fig.get_settings()))'))) + assert json.loads(execute("print(json.dumps(fig.info['columns']))"))==9 + screenshot_editor(page,tmp_path/'browser-style-composition.png') + canvas=page.locator('.figgy-editor canvas') + canvas.screenshot(path=str(tmp_path/'browser-style-canvas.png')) + with Image.open(tmp_path/'browser-style-canvas.png') as image:check_pixels(image) + assert not page.locator('.figgy-error').inner_text() + + +@pytest.mark.skipif(NEEDS_UI, reason='requires native and real Jupyter wheel input') +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +@pytest.mark.parametrize('mode', ['api', 'exact']) +@pytest.mark.parametrize('fixed', [False, True]) +def test_export_replay_preserves_future_wheel_tick_behavior(tmp_path, backend, mode, fixed): + """Equal pre-gesture SSOT must retain the user's auto/fixed tick behavior.""" + setup = f''' +import figgyplot as fg +from figgyplot.ticker import MultipleLocator +fig = fg.Figure(backend={backend!r}) +ax = fig.axes[0] +ax.plot([0, 1000], [1, 4], id='signal') +ax.set_xlim(0, 1000) +ax.set_ylim(0, 5) +fig.apply_style('report') +if {fixed!r}: ax.xaxis.set_major_locator(MultipleLocator(200)) +before = fig.get_settings() +exec(fig.to_code(mode={mode!r}), {{'fig': fig}}) +assert fig.get_settings() == before +''' + def check(before, after): + a, b = before['config']['bottom_x'], after['config']['bottom_x'] + assert b['max']-b['min'] < (a['max']-a['min'])/2 + if fixed: + assert b['major_spacing'] == a['major_spacing'] + else: + assert b['major_spacing'] < a['major_spacing'] + assert after['identity'].get('tick_modes') == before['identity'].get('tick_modes') + assert after['series'] == before['series'] + (tmp_path/'wheel-before-after.json').write_text(json.dumps(dict(before=before, after=after), indent=2)) + if backend == 'native': + scope = {}; exec(setup, scope); fig = scope['fig'] + with fig: + handle = fig.show(block=False) + try: + inputs = Inputs(handle); before = fig.get_settings(); uploads = fig.info['column_uploads'] + x, y, w, h = inputs.info()['decorations']['data_area'] + inputs.xdo('mousemove', '--window', inputs.wid, round(x+w/2), round(y+h/2)); inputs.pump() + inputs.xdo('keydown', 'ctrl') + try: + inputs.xdo('click', '--repeat', 8, '--delay', 20, 4) + finally: + inputs.xdo('keyup', 'ctrl') + inputs.pump() + check(before, fig.get_settings()) + assert fig.info['column_uploads'] == uploads + assert not inputs.info()['error'] + finally: + handle.close(); Inputs.pump() + else: + with notebook(tmp_path/'notebook', setup+'\ndisplay(fig.widget())') as (page, execute): + before = page.evaluate(ROOT+'.snapshot()') + uploads = page.evaluate(ROOT+'.stats.column_uploads') + upload_bytes = page.evaluate(ROOT+'.stats.binary_upload_bytes') + rect = page.evaluate(ROOT+'.decorations()')['data_area']['rect'] + x, y = browser_point(page, (rect[0]+rect[2]/2, rect[1]+rect[3]/2)) + page.mouse.move(x, y); page.keyboard.down('Control') + try: + page.mouse.wheel(0, -640) + finally: + page.keyboard.up('Control') + page.wait_for_function( + f"() => {{ const a={ROOT}.snapshot().config.bottom_x; return a.max-a.min < {(before['config']['bottom_x']['max']-before['config']['bottom_x']['min'])/2!r}; }}" + ) + page.evaluate(ROOT+'.flush()') + after = page.evaluate(ROOT+'.snapshot()') + check(before, after) + execute('await fig.sync()') + assert page.evaluate(ROOT+'.stats.column_uploads') == uploads + assert page.evaluate(ROOT+'.stats.binary_upload_bytes') == upload_bytes + assert not page.locator('.figgy-error').inner_text() + + +@pytest.mark.skipif(NEEDS_UI, reason='requires actual native and notebook label geometry') +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +def test_colorbar_requested_label_is_visible_and_separate_in_both_hosts(tmp_path, backend): + setup = f''' +import figgyplot as fg +fig = fg.Figure(backend={backend!r}) +points = fig.axes[0].scatter([1,2],[3,4],c=[10,20]) +bar = fig.colorbar(points, label='Temperature (K)') +bar.ax.yaxis.label.set_fontsize(18) +bar.ax.tick_params(labelsize=12) +''' + def check(title, ticks): + assert title[2] > 0 and title[3] > 0 + assert ticks[2] > 0 and ticks[3] > 0 + assert title[0] > ticks[0]+ticks[2] + if backend == 'native': + scope = {}; exec(setup, scope); fig = scope['fig'] + with fig: + handle = fig.show(block=False) + try: + inputs = Inputs(handle); decorations = inputs.info()['decorations'] + assert scope['bar'].ax.yaxis.label.get_visible() + check(decorations['colorbar_title'], decorations['colorbar_tick_labels']) + assert not inputs.info()['error'] + finally: + handle.close(); Inputs.pump() + else: + with notebook(tmp_path/'colorbar', setup+'\ndisplay(fig.widget())') as (page, execute): + decorations = page.evaluate(ROOT+'.decorations()') + assert page.evaluate(ROOT+'.snapshot().config.colorbar.axis.title_option.visible') + check(decorations['colorbar_title']['rect'], decorations['colorbar_tick_labels']['rect']) + assert not page.locator('.figgy-error').inner_text() diff --git a/tests/test_issue_fixes.py b/tests/test_issue_fixes.py index 551e330..3e74a5e 100644 --- a/tests/test_issue_fixes.py +++ b/tests/test_issue_fixes.py @@ -47,7 +47,7 @@ def test_bar_series_recolour_and_code_export(plot): fig.update("h", color="#ff0000") bar = fig.get_settings()["series"][0]["render_type"]["Histogram"]["bar"] assert bar["fill_color"] == _color("#ff0000") - assert "fig.update('h', color=" in fig.to_code() + assert "fig.artist('h').set_facecolor(" in fig.to_code() def test_sketch_rejects_series_it_cannot_draw(plot): @@ -160,7 +160,7 @@ def test_title_alignment_is_editable_and_exported(plot): ax.plot(np.arange(3.0), np.arange(3.0)) fig.update("chart_title", text="A wide first line\nab", align="center") assert fig.get_settings()["config"]["chart_title"]["text"]["align"] == "Center" - assert "align='center'" in fig.to_code() + assert "multialignment='center'" in fig.to_code() with pytest.raises(ValueError, match="align"): fig.update("chart_title", align="middle") @@ -240,7 +240,7 @@ def test_to_code_replays_an_injected_title_margin(plot): target, target_ax = figgy.subplots(figsize=(6, 4), dpi=100) try: target_ax.plot(np.arange(10.0), np.arange(10.0), id="a") - exec(fig.to_code(variable="fig"), {"fig": target}) + exec(fig.to_code(variable="fig", mode="exact"), {"fig": target}) assert target.get_settings() == expected finally: target.close() diff --git a/tests/test_layout_review_extensions.py b/tests/test_layout_review_extensions.py new file mode 100644 index 0000000..437d6b4 --- /dev/null +++ b/tests/test_layout_review_extensions.py @@ -0,0 +1,142 @@ +"""Accepted geometry and review metadata retain their separate contracts.""" +from concurrent.futures import ThreadPoolExecutor, TimeoutError +import json +import struct +import subprocess +import sys + +import pytest +import figgyplot as fg +from figgyplot.review import Session +from test_editor_properties import edit, object_for + + +@pytest.mark.parametrize('backend',['native','webgpu']) +def test_figure_resize_reflows_grid_and_preserves_data_and_history(backend): + with fg.Figure(backend=backend,grid=[['left','right']]) as fig: + fig.subplots['left'].plot([0,1],[2,3],id='one') + fig.subplots['right'].plot([0,1],[4,5],id='two') + before=fig.get_settings();uploads=fig.info['column_uploads' if backend=='native' else 'transfer_snapshot_bytes'] + edit(fig,'figure','Figure size/width',1200) + after=fig.get_settings() + assert after['identity']['layout']['size']==[1200,600] + assert after['config']['chart_area']['width']==594 + assert after['config']['subplots'][0]['config']['chart_area']['x']==606 + assert after['series']==before['series'] + assert after['config']['bottom_x']==before['config']['bottom_x'] + assert fig.info['column_uploads' if backend=='native' else 'transfer_snapshot_bytes']==uploads + fig.undo();assert fig.get_settings()==before + fig.redo();assert fig.get_settings()==after + + +@pytest.mark.parametrize('backend',['native','webgpu']) +def test_plot_rectangle_is_distinct_from_panel_geometry(backend): + with fg.Figure(backend=backend) as fig: + fig.axes[0].plot([0,1],[2,3],id='one') + before=fig.get_settings() + edit(fig,'data_area','Plot area/bounds',[100,100,600,350]) + after=fig.get_settings() + assert after['config']['chart_area']==before['config']['chart_area'] + for key in ('min','max','scale'): + assert after['config']['bottom_x'][key]==before['config']['bottom_x'][key] + assert after['config']['left_y'][key]==before['config']['left_y'][key] + assert object_for(fig,'data_area')['fields'][next(i for i,f in enumerate(object_for(fig,'data_area')['fields']) if f['id']=='Plot area/bounds')]['value']==[100,100,600,350] + with pytest.raises(ValueError):edit(fig,'data_area','Plot area/bounds',[100,100,-1,350]) + assert fig.get_settings()==after + fig.undo();assert fig.get_settings()==before + + +@pytest.mark.parametrize('backend',['native','webgpu']) +@pytest.mark.parametrize('family',['radar','boxplot','radial','categorical']) +def test_bounded_plot_geometry_can_return_to_automatic(backend,family): + with fg.Figure(backend=backend) as fig: + if family=='radar':fig.axes[0].radar(['A','B','C'],[2,3,4]) + elif family=='boxplot':fig.axes[0].boxplot([[1,2,3],[2,4,6]]) + elif family=='radial':fig.axes[0].donut([2,3,4]) + else:fig.axes[0].bar(['A','B','C'],[2,3,4]) + before=fig.get_settings() + edit(fig,'data_area','Plot area/bounds',[70,60,500,400]) + assert fig.get_settings()[family]['decorations']['data_area']==dict(x=70,y=60,width=500,height=400) + owner=json.loads(json.dumps(fig.get_settings()[family]));owner['decorations']=before[family]['decorations'] + assert owner==before[family] + edit(fig,'data_area','Plot area/automatic',True) + assert fig.get_settings()==before + edit(fig,'figure','Figure size/width',1200) + assert fig.get_settings()['config']['chart_area']['width']==1200 + assert fig.get_settings()[family]==before[family] + if backend=='native':assert fig.render_rgba().shape[1]==1200 + fig.undo();assert fig.get_settings()==before + + +def test_targeted_ui_comments_are_durable_and_separate_from_ssot(tmp_path): + with Session(tmp_path,backend='webgpu') as session: + fig=session.figure(id='plot');fig.axes[0].plot([0,1],[2,3],id='one') + fig.axes[0].text(.2,.2,'Document text',coords='ratio',id='note') + session.start();before=fig.get_settings() + target=next(v['id'] for v in fig.editor_properties() if v['target']=='annotation:note') + comments=[dict(id='comment-one',target=target,label='Document text',text='주석을 오른쪽으로 옮겨 주세요',channel='ui_target')] + session._set_target_comments('plot',comments) + session._set_comment_text('plot','전체 레이아웃도 넓혀 주세요') + assert fig.get_settings()==before + with Session.open(tmp_path) as reader:assert reader._review_state('plot')['target_comments']==comments + session.submit('plot','request_changes') + feedback=session.feedback(session.events()[0]['work_id'])['round']['documents']['plot'] + assert feedback['approval']['settings']==before and not feedback['approval']['changes'] + assert any(c['target']==target and c['text']==comments[0]['text'] for c in feedback['comments']) + + +def test_subscription_returns_only_after_the_complete_batch(tmp_path): + with Session(tmp_path,backend='webgpu') as session: + for fid in ['left','right']: + fig=session.figure(id=fid);fig.axes[0].plot([0,1],[2,3],id='one') + session.start() + with ThreadPoolExecutor(max_workers=1) as pool: + def wait(): + with Session.open(tmp_path) as reader:return reader.subscribe(timeout=3) + pending=pool.submit(wait) + session.submit('left','request_changes',comments='Make this wider') + with pytest.raises(TimeoutError):pending.result(timeout=.05) + session.submit('right','accept') + assert pending.result(timeout=3)==session.events()[0] + assert session.subscribe(after=session.events()[0]['work_id'],timeout=0) is None + + +def test_review_font_uses_the_selected_cjk_face_and_persists(tmp_path): + from figgyplot._native import font_family_available + from figgyplot._review_font import font_data + family='Noto Sans CJK KR' if font_family_available('Noto Sans CJK KR') else 'DejaVu Sans' + with Session(tmp_path,backend='webgpu',review_font=family,review_font_size=16) as session: + fig=session.figure(id='plot');fig.axes[0].plot([0,1],[2,3]) + before=fig.get_settings();widget=session.widget(fig) + assert widget.review['font_family']==family and widget.review['font_size']==16 + assert bytes(widget.review_font_data)==font_data(family) + assert fig.get_settings()==before + data=font_data(family) + assert data[:4]!=b'ttcf' and len(data)%4==0 + assert sum(struct.unpack('>'+str(len(data)//4)+'I',data))&0xffffffff==0xB1B0AFBA + with Session.open(tmp_path) as resumed:assert resumed.snapshot()['review_style']['font_family']==family + + +def test_subscription_cli_delivers_the_approval_signal(tmp_path): + with Session(tmp_path,backend='webgpu') as session: + fig=session.figure(id='plot');fig.axes[0].plot([0,1],[2,3]) + session.start() + process=subprocess.Popen([sys.executable,'-m','figgyplot.review','subscribe',str(tmp_path),'--timeout','10'],stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True) + try: + with pytest.raises(subprocess.TimeoutExpired):process.communicate(timeout=.2) + session.submit('plot','request_changes',comments='Make the figure wider') + stdout,stderr=process.communicate(timeout=10) + assert process.returncode==0,stderr + assert json.loads(stdout)==session.events()[0] + finally: + if process.poll() is None:process.kill();process.communicate() + + +def test_resume_rejects_an_unavailable_review_font_before_opening_windows(tmp_path,monkeypatch): + with Session(tmp_path,backend='native',review_font='DejaVu Sans') as session: + fig=session.figure(id='plot');fig.axes[0].plot([0,1],[2,3]);session.start() + def missing(family):raise ValueError('Review font is not installed') + monkeypatch.setattr('figgyplot._review_font.font_data',missing) + with Session.open(tmp_path) as resumed: + with pytest.raises(ValueError,match='font is not installed'):resumed.present() + assert not resumed._handles diff --git a/tests/test_matplotlib_api.py b/tests/test_matplotlib_api.py new file mode 100644 index 0000000..07631c0 --- /dev/null +++ b/tests/test_matplotlib_api.py @@ -0,0 +1,779 @@ +"""Public object/argument contracts, independently compared with Matplotlib.""" + +import copy + +import numpy as np +import pytest +import figgyplot as fg + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_title_axis_and_spine_objects_expose_public_style_methods(backend): + with fg.Figure(backend=backend, dpi=100) as fig: + ax = fig.axes[0] + ax.plot([0, 1], [1, 2], id="signal") + ax.title.set_text("Public title") + ax.title.set_fontsize(19) + ax.title.set_color("red") + ax.title.set_fontweight("bold") + ax.xaxis.set_label_text("Time", fontsize=11) + ax.yaxis.set_label_text("Value", color="purple") + ax.spines["top"].set_visible(False) + assert ax.get_title() == "Public title" + assert ax.get_xlabel() == "Time" and ax.get_ylabel() == "Value" + assert ax.title.get_fontsize() == pytest.approx(19) + assert ax.title.get_color() == (1.0, 0.0, 0.0, 1.0) + assert not ax.spines["top"].get_visible() + assert all( + s["bold"] + for s in fig.get_settings()["config"]["chart_title"]["text"]["segments"] + ) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_tick_params_points_direction_labels_and_visibility(backend): + with fg.Figure(backend=backend, dpi=144) as fig: + ax = fig.axes[0] + ax.plot([0, 1], [1, 2]) + ax.tick_params( + axis="x", + which="major", + direction="out", + length=7, + labelsize=11, + labelcolor="red", + top=False, + labeltop=False, + ) + state = fig.get_settings()["config"] + assert state["bottom_x"]["major_tick_length"] == 14 + assert state["bottom_x"]["label_style"]["font_size"] == 22 + assert state["bottom_x"]["tick"] == "Outside" + assert state["top_x"]["tick"] == "None" + assert not state["top_x"]["label_style"]["label_visible"] + assert state["left_y"]["label_style"]["font_size"] != 22 + before, revision = fig.get_settings(), fig.revision + with pytest.raises(NotImplementedError): + ax.tick_params(pad=10) + assert fig.get_settings() == before and fig.revision == revision + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_resize_in_inches_preserves_data_and_panel_identity(backend): + with fg.Figure( + backend=backend, dpi=100, figsize=(8, 5), grid=[["one", "two"]] + ) as fig: + fig.subplots["one"].plot([0, 1], [2, 3], id="signal") + ids = copy.deepcopy(fig.get_settings()["identity"]["panels"]) + uploads = fig.info.get("column_uploads") + fig.set_size_inches(12, 6) + np.testing.assert_array_equal(fig.get_size_inches(), [12, 6]) + assert fig.get_settings()["identity"]["panels"] == ids + assert fig.info.get("column_uploads") == uploads + assert fig.subplots["two"].axes.id == "two" + + +def test_supported_calls_match_matplotlib_public_object_properties(): + from matplotlib.figure import Figure + + reference = Figure(figsize=(8, 5), dpi=100) + ra = reference.subplots() + with fg.Figure(figsize=(8, 5), dpi=100, backend="webgpu") as candidate: + ca = candidate.axes[0] + for ax in [ra, ca]: + ax.plot([0, 1], [1, 2]) + ax.title.set_text("Title") + ax.title.set_fontsize(18) + ax.xaxis.set_label_text("Time") + ax.yaxis.set_label_text("Value") + ax.spines["top"].set_visible(False) + ax.tick_params( + axis="x", direction="out", length=7, labelsize=11, labelcolor="red" + ) + assert ca.get_title() == ra.get_title() + assert ca.get_xlabel() == ra.get_xlabel() + assert ca.get_ylabel() == ra.get_ylabel() + assert ca.title.get_fontsize() == ra.title.get_fontsize() + assert ca.spines["top"].get_visible() == ra.spines["top"].get_visible() + candidate.set_size_inches(12, 6) + reference.set_size_inches(12, 6) + np.testing.assert_array_equal( + candidate.get_size_inches(), reference.get_size_inches() + ) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_public_style_batch_commits_once_and_rolls_back_failed_edits(backend): + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + ax.plot([0, 1], [1, 2]) + revision = fig.revision + with fig.batch_update(): + ax.title.set_text("Batched") + ax.xaxis.set_label_text("Time") + ax.tick_params(length=8) + assert fig.revision == revision + assert fig.revision == revision + 1 + before = fig.get_settings() + revision = fig.revision + with pytest.raises(ValueError): + with fig.batch_update(): + ax.title.set_text("Rolled back") + ax.spines["top"].set(visible=False, unsupported=True) + assert fig.get_settings() == before and fig.revision == revision + with pytest.raises(RuntimeError): + with fig.batch_update(): + ax.plot([0, 1], [4, 5]) + assert fig.get_settings() == before and fig.revision == revision + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_standard_plot_aliases_and_returned_text_match_matplotlib(backend): + from matplotlib.figure import Figure + + reference = Figure(dpi=120).subplots() + with fg.Figure(backend=backend, dpi=120) as fig: + actual = fig.axes[0] + for ax in (reference, actual): + (line,) = ax.plot( + [0, 1], + [2, 4], + lw=2.5, + ls="--", + ms=7, + c="C2", + marker="s", + alpha=0.4, + gid="signal", + ) + title = ax.set_title("Title", fontsize=18, fontweight="bold") + title.set_color("magenta") + xlabel = ax.set_xlabel("Time", fontsize=12) + xlabel.set_text("Time (s)") + left = reference.get_lines()[0] + right = actual.get_lines()[0] + assert right.get_gid() == left.get_gid() == "signal" + assert right.get_linewidth() == pytest.approx(left.get_linewidth(), rel=1e-6) + assert right.get_markersize() == pytest.approx(left.get_markersize(), rel=1e-6) + assert right.get_marker() == left.get_marker() + assert right.get_linestyle() == left.get_linestyle() + from matplotlib.colors import to_rgba + + np.testing.assert_allclose( + right.get_color(), to_rgba(left.get_color(), left.get_alpha()) + ) + assert actual.get_xlabel() == reference.get_xlabel() + assert actual.title.get_fontsize() == reference.title.get_fontsize() + np.testing.assert_array_equal( + actual.title.get_color(), to_rgba(reference.title.get_color()) + ) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_scatter_size_arrays_are_point_areas_like_matplotlib(backend): + from matplotlib.figure import Figure + + sizes = np.array([0.0, 4.0, 16.0, 64.0]) + ref = Figure().subplots().scatter([0, 1, 2, 3], [1, 2, 3, 4], s=sizes) + with fg.Figure(backend=backend, dpi=144) as fig: + artist = fig.axes[0].scatter([0, 1, 2, 3], [1, 2, 3, 4], s=sizes) + mapping = artist._body(fig.get_settings())["scatter"]["point_size_map"] + t = np.clip( + (sizes - mapping["domain_min"]) + / (mapping["domain_max"] - mapping["domain_min"]), + 0, + 1, + ) + radii = np.sqrt( + mapping["min_px"] ** 2 + + (mapping["max_px"] ** 2 - mapping["min_px"] ** 2) * t + ) + # Read Matplotlib's actual public sizes, then derive the renderer radii. + np.testing.assert_allclose( + radii, np.sqrt(ref.get_sizes()) * fig.dpi / 144, rtol=1e-6 + ) + before, revision = fig.get_settings(), fig.revision + with pytest.raises(ValueError): + fig.axes[0].scatter([0, 1], [1, 2], s=[-1, 4]) + assert fig.get_settings() == before and fig.revision == revision + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_text_coordinates_and_annotation_handles_use_public_arguments(backend): + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + ax.plot([0, 1], [1, 2]) + note = ax.text( + 0.2, + 0.8, + "Corner", + transform=ax.transAxes, + fontweight="bold", + fontfamily="DejaVu Sans", + ) + np.testing.assert_allclose(note.get_position(), (0.2, 0.8)) + anchor = next(iter(note._find(fig.get_settings())[2]["kind"].values()))[ + "anchor" + ] + assert anchor["Ratio"] == pytest.approx(dict(x=0.2, y=0.2)) + note.set_position((0.3, 0.7)) + note.set_text("Moved") + note.set_fontsize(13) + assert note.get_text() == "Moved" and note.get_fontsize() == pytest.approx(13) + linked = ax.annotate( + "Peak", + xy=(1, 2), + xytext=(0.4, 0.8), + textcoords="axes fraction", + arrowprops={"arrowstyle": "->", "color": "red"}, + ) + linked.set_position((0.6, 0.7)) + state = fig.get_settings() + arrow = next(iter(linked.arrow_patch._find(state)[2]["kind"].values())) + assert arrow["to"]["Data"] == dict(x=1.0, y=2.0) + assert arrow["from"]["Ratio"] == pytest.approx(dict(x=0.6, y=0.3)) + before, revision = fig.get_settings(), fig.revision + with pytest.raises(NotImplementedError): + ax.annotate("bad", xy=(1, 2), arrowprops={"connectionstyle": "arc3"}) + assert fig.get_settings() == before and fig.revision == revision + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_regular_locator_grid_legend_position_and_limits_match_public_contract(backend): + from matplotlib.figure import Figure + from matplotlib.ticker import MultipleLocator + + ref = Figure(dpi=100).subplots() + with fg.Figure(backend=backend, dpi=100) as fig: + ax = fig.axes[0] + for candidate in (ref, ax): + candidate.plot([0, 1], [2, 4], label="Data") + candidate.set_xlim(2, -2) + candidate.set_ylim((1, 5)) + candidate.xaxis.set_major_locator(MultipleLocator(0.5)) + candidate.grid( + True, axis="y", which="minor", color="red", linewidth=2, linestyle=":" + ) + candidate.legend(loc="lower left", fontsize=12, framealpha=0.5) + candidate.set_position((0.2, 0.25, 0.6, 0.5)) + assert ax.get_xlim() == ref.get_xlim() and ax.get_ylim() == ref.get_ylim() + assert ax.xaxis_inverted() == ref.xaxis_inverted() + np.testing.assert_allclose( + ax.get_position().bounds, ref.get_position().bounds, atol=1e-6 + ) + np.testing.assert_allclose( + ax.get_legend().get_frame().get_facecolor(), + ref.get_legend().get_frame().get_facecolor(), + ) + state = fig.get_settings()["config"] + assert state["bottom_x"]["major_spacing"] == pytest.approx(0.5) + assert state["grid"]["show_minor_y"] + assert state["grid"]["minor_y_width"] == pytest.approx(2 * 100 / 72) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_colorbar_public_handle_keeps_mappable_and_scale_connected(backend): + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + points = ax.scatter([1, 2], [3, 4], c=[10, 20]) + bar = fig.colorbar(points, ax=ax, label="Temperature") + points.set_cmap("magma") + points.set_clim((5, 25)) + bar.ax.tick_params(labelsize=11) + assert points.get_cmap() == "Magma" and points.get_clim() == (5, 25) + assert bar.ax.yaxis.label.get_text() == "Temperature" + assert bar.ax.yaxis.label.get_visible() + assert fig.get_settings()["config"]["colorbar"]["axis"]["label_style"][ + "font_size" + ] == pytest.approx(11 * fig.dpi / 72) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +@pytest.mark.parametrize("variable", ["fig", "_ax", "text_segments", "MultipleLocator"]) +def test_default_export_uses_public_calls_and_replays_styles_and_pixels( + backend, variable +): + with fg.Figure(backend=backend, dpi=120) as source, fg.Figure( + backend=backend, dpi=120 + ) as target: + for fig in (source, target): + ax = fig.axes[0] + ax.plot([0, 1, 2], [2, 1, 4], marker="o", id="signal", label="Signal") + ax = source.axes[0] + ax.set_title("Public API", fontsize=18, fontweight="bold") + ax.set_xlabel("Time (s)") + ax.set_ylabel("Value") + ax.set_xlim(-0.5, 2.5) + ax.set_ylim(0, 5) + ax.tick_params(axis="x", direction="out", labelsize=10) + ax.spines["top"].set_visible(False) + ax.grid(False, axis="x") + ax.legend(loc="lower left", fontsize=12, framealpha=0.5) + target.axes[0].set_title("Stale", fontsize=35, color="red") + target.axes[0].tick_params(axis="y", labelsize=20) + target.set_facecolor("black") + source_state = source.get_settings() + target_id = target.id + uploads = target.info.get("column_uploads") + revision = target.revision + code = source.to_code(variable) + assert ".tick_params(" in code and ".spines[" in code + assert ".set_title(" in code and ".set_xlabel(" in code + assert ".configure(" not in code and ".apply_settings(" not in code + assert "render_type" not in code and "point_size" not in code + exec(code, {variable: target}) + accepted = target.get_settings() + assert accepted["config"] == source_state["config"] + assert accepted["series"] == source_state["series"] + assert accepted['identity'].get('tick_modes') == source_state['identity'].get('tick_modes') + assert target.id == target_id + assert source.get_settings() == source_state + assert target.info.get("column_uploads") == uploads + assert target.revision == revision + 1 + if backend == "native": + np.testing.assert_array_equal(target.render_rgba(), source.render_rgba()) + # Exact mode still has the full identity/metadata oracle. + exec(source.to_code(variable, mode="exact"), {variable: target}) + assert target.get_settings() == source_state + + +def test_batch_nested_savepoint_and_retirement_are_based_on_final_state(): + with fg.Figure(backend="webgpu") as fig: + ax = fig.axes[0] + ax.plot([0, 1], [2, 3]) + ax.text(0.2, 0.3, "Keep", id="note") + original = fig.get_settings() + revision = fig.revision + with fig.batch_update(): + fig.clear_annotations() + ax.text(0.4, 0.5, "Moved", id="note") + try: + with fig.batch_update(): + ax.set_title("Must roll back") + raise ValueError("cancel") + except ValueError: + pass + accepted = fig.get_settings() + assert accepted["config"]["chart_title"] == original["config"]["chart_title"] + assert "annotation/note" not in accepted["identity"]["retired"] + assert fig.revision == revision + 1 + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_common_histogram_and_error_styles_export_without_renderer_fields(backend): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + for fig in (source, target): + ax = fig.axes[0] + ax.stairs([2, 4, 3], [0, 1, 2, 3], id="histogram") + ax.errorbar([0.5, 1.5, 2.5], [2, 4, 3], yerr=[0.2, 0.3, 0.4], id="errors") + code = source.to_code() + assert ".configure(" not in code and "render_type" not in code + source.artist("histogram").set_edgecolor("red") + source.artist("errors").set_errorbar_style( + color="purple", width=2, capsize=4, capthick=1 + ) + exec(source.to_code(), {"fig": target}) + assert target.get_settings()["series"] == source.get_settings()["series"] + if backend == "native": + np.testing.assert_array_equal(target.render_rgba(), source.render_rgba()) + + +def test_cpu_export_canonicalization_matches_the_accepted_f32_serialization(): + import json + from figgyplot._native import canonical_style_state_json + + with fg.Figure(backend="webgpu") as fig: + fig.axes[0].plot([0, 1], [2, 3], lw=2.5) + state = fig.get_settings() + assert json.loads(canonical_style_state_json(json.dumps(state))) == state + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +@pytest.mark.parametrize("family", ["categorical", "boxplot", "radar", "radial"]) +def test_common_bounded_appearance_uses_named_controls_and_preserves_values( + backend, family +): + def build(fig, offset): + ax = fig.axes[0] + if family == "categorical": + ax.bar(["A", "B"], [2 + offset, 4 + offset]) + elif family == "boxplot": + ax.boxplot( + [ + [1 + offset, 2 + offset, 3 + offset], + [2 + offset, 4 + offset, 5 + offset], + ] + ) + elif family == "radar": + ax.radar(["A", "B", "C"], [2 + offset, 4 + offset, 3 + offset]) + else: + ax.donut([2 + offset, 4 + offset]) + ax.set_title("Chart") + + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + build(source, 0) + build(target, 0) + source.axes[0].chart.set_label_style(fontsize=12) + source.axes[0].title.set_fontweight("bold") + target.axes[0].chart.set_label_style(fontsize=18, color="red") + before = source.get_settings() + code = source.to_code() + assert ".configure_" not in code and ".apply_settings(" not in code + exec(code, {"fig": target}) + assert target.get_settings()[family] == before[family] + if backend == "native": + np.testing.assert_array_equal(target.render_rgba(), source.render_rgba()) + # Appearance export does not overwrite a second dataset. + with fg.Figure(backend=backend) as other: + build(other, 10) + data_key, value_key = ( + ("slices", "value") if family == "radial" else ("series", "values") + ) + values = [s[value_key] for s in other.get_settings()[family][data_key]] + exec(code, {"fig": other}) + assert [ + s[value_key] for s in other.get_settings()[family][data_key] + ] == values + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_export_preserves_an_editor_accepted_unresolved_font(backend): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + for fig in (source, target): + fig.axes[0].plot([0, 1], [2, 3], id="signal", label="Signal") + fig.axes[0].set_title("Title") + fig.axes[0].legend() + # The renderer/editor preserves a typed name while resolving fallback; + # the strict Python font setter must not make the resulting code fail. + source.configure( + config={ + "chart_title": {"text": {"font": "Unresolved trial family"}}, + "legend": {"content": {"font": "Unresolved trial family"}}, + } + ) + exec(source.to_code(), {"fig": target}) + assert target.get_settings()["config"] == source.get_settings()["config"] + if backend == "native": + np.testing.assert_array_equal(target.render_rgba(), source.render_rgba()) + + +@pytest.mark.parametrize( + "c,alpha", + [ + ("steelblue", None), + ("red", 0.5), + (["red", "blue"], None), + ([(1, 0, 0, 1), (0, 0, 1, 0.5)], None), + ], +) +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_scatter_c_accepts_explicit_matplotlib_color_forms(backend, c, alpha): + from matplotlib.figure import Figure + + reference = Figure().subplots().scatter([0, 1], [1, 2], c=c, alpha=alpha) + with fg.Figure(backend=backend) as fig: + artist = fig.axes[0].scatter([0, 1], [1, 2], c=c, alpha=alpha, s=100) + state = fig.get_settings() + body = artist._body(state)["scatter"] + assert not state["config"].get("colorbar") + overrides = body.get("point_style_overrides", []) + colors = ( + [tuple(item["point_color"][k] for k in "rgba") for item in overrides] + if overrides + else [tuple(body["point_color"][k] for k in "rgba")] + ) + np.testing.assert_allclose( + colors, reference.get_facecolors(), rtol=1e-6, atol=1e-7 + ) + reference.set_alpha(0.25) + artist.set_alpha(0.25) + updated = artist._body(fig.get_settings())["scatter"] + updated_colors = [ + tuple(item["point_color"][k] for k in "rgba") + for item in updated.get("point_style_overrides", []) + ] or [tuple(updated["point_color"][k] for k in "rgba")] + np.testing.assert_allclose( + updated_colors, reference.get_facecolors(), rtol=1e-6, atol=1e-7 + ) + if backend == "native": + pixels = fig.render_rgba() + assert np.count_nonzero(np.any(pixels[:, :, :3] < 220, axis=2)) > 500 + before, revision, uploads = ( + fig.get_settings(), + fig.revision, + fig.info.get("column_uploads"), + ) + with pytest.raises(NotImplementedError): + fig.axes[0].scatter([0, 1], [1, 2], c=[1, 2], alpha=0.5) + assert fig.get_settings() == before and fig.revision == revision + assert fig.info.get("column_uploads") == uploads + mapped = fig.axes[0].scatter([0, 1], [1, 2], c=[1, 2]) + before = fig.get_settings() + with pytest.raises(NotImplementedError): + mapped.set_alpha(0.5) + assert fig.get_settings() == before + + +def test_css_named_cycle_grayscale_and_short_colors_match_matplotlib(): + from matplotlib.colors import CSS4_COLORS, to_rgba + from figgyplot.figure import _color + + for value in [ + *CSS4_COLORS, + "g", + "c", + "m", + "y", + "C0", + "C12", + "0.5", + ".25", + "SteelBlue", + ]: + np.testing.assert_array_equal( + tuple(_color(value)[k] for k in "rgba"), to_rgba(value) + ) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_markers_can_be_added_to_a_resident_line_through_artist_set(backend): + from matplotlib.figure import Figure + + (reference,) = Figure().subplots().plot([0, 1, 2], [1, 3, 2]) + with fg.Figure(backend=backend) as fig: + (line,) = fig.axes[0].plot([0, 1, 2], [1, 3, 2], id="signal") + assert line.get_marker() == reference.get_marker() + assert fig.artist("signal") is line + assert fig.axes[0].lines[0] is line + before = fig.get_settings() + uploads = fig.info.get("column_uploads") + revision = fig.revision + reference.set(marker="s", markersize=7) + line.set(marker="s", ms=7) + assert line.get_marker() == reference.get_marker() + assert line.get_markersize() == pytest.approx( + reference.get_markersize(), rel=1e-6 + ) + assert ( + fig.info.get("column_uploads") == uploads and fig.revision == revision + 1 + ) + assert fig.get_settings()["config"] == before["config"] + assert [ + s[k] for s in fig.get_settings()["series"] for k in ("x_column", "y_column") + ] == [s[k] for s in before["series"] for k in ("x_column", "y_column")] + if backend == "native": + assert fig.render_rgba().shape == (600, 960, 4) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_secondary_locator_preserves_primary_automatic_tick_policy(backend): + from matplotlib.figure import Figure + from matplotlib.ticker import MultipleLocator + + reference = Figure().subplots() + reference.set_xlim(0, 2) + primary = reference.xaxis.get_major_locator() + reference.secondary_xaxis("top").xaxis.set_major_locator(MultipleLocator(0.25)) + assert reference.xaxis.get_major_locator() is primary + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + ax.plot([0, 1, 2], [1, 3, 2]) + ax.set_xlim(0, 2) + before = fig.get_settings() + ax.secondary_xaxis("top").xaxis.set_major_locator(MultipleLocator(0.25)) + after = fig.get_settings() + assert after["config"]["bottom_x"] == before["config"]["bottom_x"] + assert ( + after["identity"]["tick_modes"]["main"]["bottom_x"] + == before["identity"]["tick_modes"]["main"]["bottom_x"] + == "Auto" + ) + assert after["identity"]["tick_modes"]["main"]["top_x"] == "Fixed" + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_primary_locator_and_formatter_preserve_an_independent_boundary(backend): + from figgyplot.ticker import MultipleLocator, SignificantFormatter + + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + ax.plot([0, 1, 2], [1, 3, 2]) + ax.set_ylim(0, 4) + ax.configure( + right_y={"scale": "Logarithmic", "min": 1, "max": 100, "major_spacing": 1} + ) + before = fig.get_settings() + ax.yaxis.set_major_locator(MultipleLocator(2)) + ax.yaxis.set_major_formatter(SignificantFormatter(4, style="scientific")) + after = fig.get_settings() + assert after["config"]["right_y"] == before["config"]["right_y"] + assert after["config"]["left_y"]["major_spacing"] == 2 + assert after["config"]["left_y"]["label_style"]["format"] == "Scientific" + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_colorbar_locator_preserves_plot_axes_tick_intent_and_norm(backend): + from matplotlib.ticker import MultipleLocator + + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + points = ax.scatter([0, 1], [1, 2], c=[10, 20]) + ax.set_ylim(0, 3) + bar = fig.colorbar(points, ax=ax) + before = fig.get_settings() + bar.ax.yaxis.set_major_locator(MultipleLocator(0.5)) + after = fig.get_settings() + for side in ("bottom_x", "top_x", "left_y", "right_y"): + assert after["config"][side] == before["config"][side] + assert after["identity"] == before["identity"] + assert after["config"]["colorbar"]["axis"]["major_spacing"] == 0.5 + assert points.get_clim() == (10, 20) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +@pytest.mark.parametrize("cmap", ["viridis", "magma", "turbo", "gray", "RdBu"]) +def test_every_builtin_colormap_exports_and_constructs_widgets(backend, cmap): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + for fig in (source, target): + fig.axes[0].scatter( + [0, 1, 2], [1, 3, 2], c=[0, 1, 2], cmap=cmap, id="mapped" + ) + source.artist("mapped").set_cmap(source.artist("mapped").get_cmap()) + code = source.to_code() + exec(code, {"fig": target}) + assert target.get_settings()["config"] == source.get_settings()["config"] + assert target.get_settings()["series"] == source.get_settings()["series"] + if backend == "webgpu": + widget = source.widget() + assert widget.state.get("code_error", "") == "" + assert widget.state.get("code_mode", "api") == "api" + else: + np.testing.assert_array_equal(target.render_rgba(), source.render_rgba()) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +@pytest.mark.parametrize("notation", ["Degrees", "Radians", {"Timestamp": {}}]) +def test_angle_and_timestamp_notation_remains_in_explicit_advanced_export( + backend, notation +): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + for fig in (source, target): + fig.axes[0].plot([0, 1, 2], [1, 3, 2], id="signal") + source.axes[0].configure(bottom_x={"label_style": {"format": notation}}) + exec(source.to_code(), {"fig": target}) + assert target.get_settings()["config"] == source.get_settings()["config"] + if backend == "native": + np.testing.assert_array_equal(target.render_rgba(), source.render_rgba()) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_editor_export_failure_keeps_chart_and_exact_recovery_available( + backend, monkeypatch +): + from figgyplot import _api_codegen + from figgyplot.editor import EditorSession + + def unavailable(*args, **kwargs): + raise ValueError( + "simulated code export failure\nraise RuntimeError('not executable code')" + ) + + with fg.Figure(backend=backend) as fig, fg.Figure(backend=backend) as target: + for item in (fig, target): + item.axes[0].plot([0, 1], [1, 2], id="signal") + before = fig.get_settings() + monkeypatch.setattr(_api_codegen, "settings_to_api_code", unavailable) + payload = ( + fig.widget().state if backend == "webgpu" else EditorSession(fig).snapshot() + ) + assert payload["settings"] == before + assert ( + payload["code_mode"] == "exact" + and "simulated code export failure" in payload["code_error"] + ) + exec(payload["code"], {"fig": target}) + assert target.get_settings() == before + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_auto_and_log_locators_preserve_ranges_and_explicit_formatting(backend): + from figgyplot.ticker import AutoLocator, LogLocator, SignificantFormatter + + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + ax.plot([0, 1, 2], [1, 3, 2]) + ax.set_yscale("log") + ax.set_ylim(1, 100) + ax.yaxis.set_major_locator(LogLocator(100)) + state = fig.get_settings() + assert state["config"]["left_y"]["major_spacing"] == 2 + assert state["identity"]["tick_modes"]["main"]["left_y"] == "Fixed" + assert ax.yaxis.get_major_locator().base == 100 + ax.yaxis.set_major_formatter(SignificantFormatter(4, style="scientific")) + label = fig.get_settings()["config"]["left_y"]["label_style"] + ax.yaxis.set_major_locator(AutoLocator()) + assert ax.get_ylim() == (1, 100) + assert fig.get_settings()["config"]["left_y"]["label_style"] == label + assert fig.get_settings()["identity"]["tick_modes"]["main"]["left_y"] == "Auto" + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +@pytest.mark.parametrize("primary_log", [False, True]) +def test_independent_boundary_scales_export_without_reinterpreting_locators( + backend, primary_log +): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + for fig in (source, target): + fig.axes[0].plot([0, 1, 2], [1, 3, 2], id="signal") + ax = source.axes[0] + ax.set_ylim(1, 100) + if primary_log: + ax.set_yscale("log") + ax.configure( + right_y={ + "scale": "Linear" if primary_log else "Logarithmic", + "min": 1, + "max": 100, + "major_spacing": 10 if primary_log else 1, + } + ) + code = source.to_code() + exec(code, {"fig": target}) + assert target.get_settings()["config"] == source.get_settings()["config"] + assert target.get_settings()["series"] == source.get_settings()["series"] + if backend == "native": + np.testing.assert_array_equal(target.render_rgba(), source.render_rgba()) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_default_export_omits_proven_native_no_op_style_calls(backend): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + for fig in (source, target): + fig.axes[0].plot([0, 1, 2], [1, 3, 2], id="signal") + source.axes[0].set(title="Measured", xlabel="Time", ylabel="Value") + source.artist("signal").set(color="C2", lw=2) + code = source.to_code() + assert ".tick_params(" not in code + assert ".grid(" not in code + assert ".configure(" not in code + exec(code, {"fig": target}) + assert target.get_settings()["config"] == source.get_settings()["config"] + assert target.get_settings()["series"] == source.get_settings()["series"] + if backend == "native": + np.testing.assert_array_equal(target.render_rgba(), source.render_rgba()) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_export_replays_added_markers_on_the_original_resident_line(backend): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + for fig in (source, target): + fig.axes[0].plot([0, 1, 2], [1, 3, 2], id="signal") + source.artist("signal").set(marker="s", ms=6, color="C2", lw=2) + uploads = target.info.get("column_uploads") + exec(source.to_code(), {"fig": target}) + assert target.get_settings()["config"] == source.get_settings()["config"] + assert target.get_settings()["series"] == source.get_settings()["series"] + assert target.artist("signal") is target.axes[0].lines[0] + assert target.info.get("column_uploads") == uploads + if backend == "native": + np.testing.assert_array_equal(target.render_rgba(), source.render_rgba()) diff --git a/tests/test_native_input.py b/tests/test_native_input.py new file mode 100644 index 0000000..5994e16 --- /dev/null +++ b/tests/test_native_input.py @@ -0,0 +1,36 @@ +"""The X11 clipboard owner must outlive its writer without retaining CI logs.""" +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + +from native_input import set_clipboard + + +@pytest.mark.skipif(not os.environ.get('DISPLAY') or not shutil.which('xclip'),reason='requires an X11 clipboard') +def test_clipboard_owner_preserves_unicode_without_retaining_writer_output(): + path=str(Path(__file__).parent) + text='한글 리뷰 · μm · 10⁻⁶' + inherited="import subprocess;subprocess.run(['xclip','-selection','clipboard','-t','UTF8_STRING'],input='negative control',text=True,check=True);print('writer returned',flush=True)" + writer=subprocess.Popen([sys.executable,'-c',inherited],stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True) + try: + # The interpreter really exits successfully; an inherited pipe is the + # only thing keeping communicate()/tee open. This is not a speed test. + assert writer.wait(timeout=10)==0 + with pytest.raises(subprocess.TimeoutExpired):writer.communicate(timeout=.2) + finally: + set_clipboard('Release the negative-control selection owner') + stdout,stderr=writer.communicate(timeout=10) + writer.stdout.close();writer.stderr.close() + assert 'writer returned' in stdout + assert not stderr + code="import sys;sys.path.insert(0,sys.argv[1]);from native_input import set_clipboard;set_clipboard(sys.argv[2]);print('writer returned',flush=True)" + result=subprocess.run([sys.executable,'-c',code,path,text],capture_output=True,text=True,timeout=10) + assert result.returncode==0,result.stderr + assert result.stdout.strip()=='writer returned' + # Redirecting the owner's descriptors must not kill the clipboard owner. + accepted=subprocess.check_output(['xclip','-selection','clipboard','-o','-t','UTF8_STRING'],text=True,timeout=10) + assert accepted==text diff --git a/tests/test_numeric_formatting.py b/tests/test_numeric_formatting.py index f6668ec..542c388 100644 --- a/tests/test_numeric_formatting.py +++ b/tests/test_numeric_formatting.py @@ -93,3 +93,61 @@ def click_control(key): execute("copy=fig.to_code(); other,bx=figgy.subplots(backend='webgpu'); bx.imshow(np.array([[0.,.3],[.6,.91475]]),id='field'); exec(copy,{'fig':other}); assert other.get_settings()['config']==fig.get_settings()['config']; other.close()") path=tmp_path/'browser-formatted.png' execute(f'await fig.savefig_async({str(path)!r})') + + +@pytest.mark.skipif(NEEDS_UI, reason='requires real native and Jupyter UI') +@pytest.mark.parametrize('draft_kind',['property','snapshot','late_property','conflict_property']) +def test_numeric_draft_survives_external_update_before_selection_ack(tmp_path,draft_kind): + source="import copy\nimport numpy as np\nimport figgyplot as figgy\nfig,ax=figgy.subplots(backend='webgpu')\n"+SETUP+"w=fig.widget(); display(w)" + with notebook(tmp_path/'pending-number',source) as (page,execute): + page.locator('[data-properties]').click() + execute("""saved_send=w.send +held_acks=[] +def hold_ack(content,*args,**kwargs): + if content.get('action')=='ack': held_acks.append((copy.deepcopy(content),args,kwargs)) + else: saved_send(content,*args,**kwargs) +w.send=hold_ack +""") + if draft_kind in ('late_property','conflict_property'):execute("saved_send_state=w.send_state\nw.send_state=lambda *args,**kwargs:None") + ObjectNavigator(page).choose('tick_labels_left') + page.wait_for_function(ROOT+'.diagnostics().edit_inflight !== null') + for _ in range(20): + if int(execute('print(len(held_acks))').strip()): break + else:pytest.fail('selection acknowledgement was not held') + digits=page.locator('[data-property=significant_digits]') + if draft_kind!='snapshot':digits.fill('2') + update="fig.configure(config={'grid':{'show_minor_x':True}})" + if draft_kind=='conflict_property':update+=";fig.configure(config={'left_y':{'label_style':{'significant_digits':4}}})" + accepted=int(execute(update+";print(fig.revision)").strip()) + if draft_kind not in ('late_property','conflict_property'):page.wait_for_function('(revision)=>'+ROOT+'.diagnostics().accepted_revision>=revision',arg=accepted) + if draft_kind!='snapshot': + assert digits.input_value()=='2' + digits.press('Tab') + else: + before_axis=page.evaluate(ROOT+".snapshot().config.left_y") + area=page.evaluate('async()=>(await '+ROOT+'.decorations()).data_area.rect') + canvas=page.locator('.figgy-editor canvas');box=canvas.bounding_box() + size=canvas.evaluate('(c)=>[c.width,c.height]') + page.mouse.move(box['x']+(area[0]+area[2]/2)*box['width']/size[0],box['y']+(area[1]+area[3]/2)*box['height']/size[1]) + page.mouse.wheel(0,-120) + page.wait_for_function(ROOT+'.diagnostics().edit_pending') + execute("w.send=saved_send\nfor content,args,kwargs in held_acks: saved_send(content,*args,**kwargs)") + page.evaluate(ROOT+'.flush()') + result=page.evaluate("({state:"+ROOT+".snapshot(),diagnostics:"+ROOT+".diagnostics(),events:figgyBrowserTrace.events.filter(e=>/^(edit|state|property)\\./.test(e.stage??''))})") + (tmp_path/'numeric-edit-events.json').write_text(json.dumps(result,indent=2)) + expected=7 if draft_kind=='snapshot' else 4 if draft_kind=='conflict_property' else 2 + assert style(result['state'],'left_y')['significant_digits']==expected + assert result['state']['config']['grid']['show_minor_x'] + if draft_kind=='snapshot': + assert any(event.get('error','').startswith('stale edit:') for event in result['events']) + for limit in ('min','max'): + assert result['state']['config']['left_y'][limit]==before_axis[limit] + assert int(execute("print(fig.get_settings()['config']['left_y']['label_style']['significant_digits'])").strip())==expected + + if draft_kind in ('snapshot','conflict_property'): + assert 'stale edit:' in page.locator('.figgy-error').inner_text() + digits.fill('3');digits.press('Tab');page.evaluate(ROOT+'.flush()') + assert style(page.evaluate(ROOT+'.snapshot()'),'left_y')['significant_digits']==3 + assert page.evaluate(ROOT+'.snapshot().config.grid.show_minor_x') + + if draft_kind in ('late_property','conflict_property'):execute('w.send_state=saved_send_state; w.send_state()') diff --git a/tests/test_public_appearance.py b/tests/test_public_appearance.py new file mode 100644 index 0000000..9c6fc2a --- /dev/null +++ b/tests/test_public_appearance.py @@ -0,0 +1,816 @@ +"""Public appearance controls hide renderer containers and preserve resident data.""" + +import copy +import numpy as np +import pytest +import figgyplot as fg +from figgyplot.ticker import SignificantFormatter, TimestampFormatter + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_independent_axis_format_and_scale_do_not_edit_another_boundary(backend): + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + ax.plot([1, 2, 3], [2, 3, 4]) + original = copy.deepcopy(fig.get_settings()["config"]["bottom_x"]) + top = ax.secondary_xaxis("top").xaxis + top.set_view_interval(1, 100) + top.set_scale("log") + top.set_major_formatter(SignificantFormatter(5, "degrees")) + current = fig.get_settings()["config"] + assert current["bottom_x"] == original + assert current["top_x"]["scale"] == "Logarithmic" + assert current["top_x"]["label_style"]["format"] == "Degrees" + assert top.get_scale() == "log" + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_timestamp_formatter_preserves_values_domain_and_complete_format(backend): + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + ax.plot([1, 2, 3], [2, 3, 4], id="signal") + before = fig.get_settings() + uploads = fig.info.get("column_uploads") + ax.xaxis.set_major_formatter( + TimestampFormatter( + "%Y-%m-%d %%Q %H:%M:%S.%f", + unit="ms", + timezone=540, + fractional_digits=3, + calendar=False, + precision=6, + ) + ) + after = fig.get_settings() + assert after["series"] == before["series"] + for field in ("min", "max", "major_spacing", "scale"): + assert ( + after["config"]["bottom_x"][field] + == before["config"]["bottom_x"][field] + ) + fmt = after["config"]["bottom_x"]["label_style"]["format"]["Timestamp"] + assert fmt == dict( + unit="Milliseconds", + timezone={"FixedOffsetMinutes": 540}, + label={"Pattern": "%Y-%m-%d %%Q %H:%M:%S.%f"}, + fractional={"Fixed": 3}, + tick_policy="NumericSpacing", + ) + assert fig.info.get("column_uploads") == uploads + with pytest.raises(ValueError): + TimestampFormatter("%q") + with pytest.raises(TypeError): + TimestampFormatter(calendar="false") + + +@pytest.mark.parametrize("kind", ["categorical", "boxplot", "radial", "radar"]) +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_bounded_style_roundtrip_retains_categories_values_and_ids(kind, backend): + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + if kind == "categorical": + ax.bar(["a", "b"], [2, 3]) + elif kind == "boxplot": + ax.boxplot([[1, 2, 4], [2, 4, 6]]) + elif kind == "radial": + ax.pie([2, 3]) + else: + ax.radar(["a", "b", "c"], [2, 3, 4]) + before = fig.get_settings() + original = ax.chart.get_style() + ax.chart.set_style(**original) + assert fig.get_settings()[kind]["style"] == before[kind]["style"] + if kind == "radar": + ax.chart.set_style(linewidth=3, fill_alpha=0.5) + elif kind == "radial": + ax.chart.set_style(roughness=0.7, gloss=0.2) + else: + ax.chart.set_style(linewidth=2, edgecolor="red") + after = fig.get_settings() + for field in ("categories", "series", "slices"): + if field in before[kind]: + assert after[kind][field] == before[kind][field] + assert after["identity"] == before["identity"] + checkpoint = fig.get_settings() + with pytest.raises(ValueError): + ax.chart.set_style(unknown_renderer_key=4) + assert fig.get_settings() == checkpoint + + +@pytest.mark.parametrize("side", ["right", "left", "top", "bottom"]) +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_colorbar_placement_and_stroke_are_independent_of_plot_axes(backend, side): + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + points = ax.scatter([1, 2], [3, 4], c=[0, 1], id="mapped") + before = fig.get_settings() + bar = ax.get_colorbar() + bar.set_layout(side=side) + bar.set_outline(color="red", linewidth=2) + bar.ax.yaxis.line.set(visible=False, color="blue") + after = fig.get_settings() + for field in ("bottom_x", "top_x", "left_y", "right_y"): + assert after["config"][field] == before["config"][field] + assert after["identity"] == before["identity"] + assert after["series"] == before["series"] + assert after["config"]["colorbar"]["side"] == side.title() + assert bar.get_layout()["side"] == side + assert points.get_clim() == (0, 1) + bar.set_cmap("magma") + assert points.get_cmap() == "Magma" + bar.set_clim(2, 5) + assert points.get_clim() == (2, 5) + checkpoint = fig.get_settings() + with pytest.raises(ValueError): + bar.set_layout(side="inside") + assert fig.get_settings() == checkpoint + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_marker_palettes_and_sparse_edits_keep_other_points_and_resident_sources( + backend, +): + with fg.Figure(backend=backend) as fig: + points = fig.axes[0].scatter([1, 2, 3], [1, 2, 3], id="points") + groups = fig.data.add("groups", [0, 1, 0]) + before = fig.get_settings() + uploads = fig.info.get("column_uploads") + points.markers.palette.set( + [fg.MarkerStyle(color="red"), fg.MarkerStyle(marker="s", markersize=8)], + source=groups, + ) + points.markers[0].set(markersize=6) + points.markers[1].set(color="blue") + points.markers[1].set(markersize=10) + assert points.markers[0].get_override().markersize == pytest.approx(6) + assert points.markers[1].get_override().color == (0, 0, 1, 1) + points.markers[1].set(color=None) + assert points.markers[1].get_override().color is None + assert points.markers[1].get_override().markersize == pytest.approx(10) + points.markers.map_color(points.data.y, clim=(0, 4), cmap="gray") + points.markers.map_size(points.data.y, domain=(0, 4), markersizes=(4, 12)) + current = fig.get_settings()["series"][0] + assert current["x_column"] == before["series"][0]["x_column"] + assert current["y_column"] == before["series"][0]["y_column"] + assert ( + current["render_type"]["Scatter"]["scatter"]["point_size_map"]["domain_max"] + == 4 + ) + assert fig.info.get("column_uploads") == uploads + checkpoint = fig.get_settings() + with pytest.raises(ValueError): + fig.data.add("groups", [1, 0, 1]) + assert fig.get_settings() == checkpoint + + +def test_marker_value_mapping_and_point_override_affect_actual_pixels(): + with fg.Figure(backend="native", figsize=(9.6, 6), dpi=100) as fig: + ax = fig.axes[0] + points = ax.scatter([1, 2, 3], [1, 2, 3], s=100, id="points") + groups = fig.data.add("groups", [0, 1, 0]) + points.markers.palette.set( + [fg.MarkerStyle(color="red"), fg.MarkerStyle(color="green", marker="s")], + source=groups, + ) + points.markers.map_color(points.data.y, clim=(0, 4), cmap="gray") + points.markers[1].set(color="blue") + ax.set(xlim=(0, 4), ylim=(0, 4)) + image = fig.render_rgba() + uploads = fig.info["column_uploads"] + box = ax.get_position() + centers = [ + ( + round((box.x0 + value / 4 * box.width) * 960), + round((1 - box.y0 - value / 4 * box.height) * 600), + ) + for value in (1, 2, 3) + ] + for (x, y), expected in zip( + centers, [(64, 64, 64), (0, 0, 255), (191, 191, 191)] + ): + patch = image[y - 2 : y + 3, x - 2 : x + 3, :3].astype(int) + assert np.any(np.max(np.abs(patch - np.array(expected)), axis=2) <= 3) + points.set_cmap("magma") + changed = fig.render_rgba() + x, y = centers[1] + assert np.any( + np.all(changed[y - 2 : y + 3, x - 2 : x + 3, :3] == [0, 0, 255], axis=2) + ) + for x, y in (centers[0], centers[2]): + assert not np.array_equal( + image[y - 2 : y + 3, x - 2 : x + 3], + changed[y - 2 : y + 3, x - 2 : x + 3], + ) + assert fig.info["column_uploads"] == uploads + + +def assert_public_replay(source, target): + import ast + + code = source.to_code() + tree = ast.parse(code) + forbidden = {"configure", "configure_bounded", "configure_radial", "apply_settings"} + assert not [ + node.func.attr + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in forbidden + ], code + before = source.get_settings() + uploads = target.info.get("column_uploads") + exec(code, {"fig": target}) + after = target.get_settings() + from figgyplot._charts import family + + kind = family(before) + for key in [kind] if kind else ["config", "series"]: + assert after[key] == before[key], code + assert target.info.get("column_uploads") == uploads + if source.backend == "native": + np.testing.assert_array_equal(source.render_rgba(), target.render_rgba()) + exec(source.to_code(mode="exact"), {"fig": target}) + assert target.get_settings() == before + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +@pytest.mark.parametrize( + "feature", + [ + "mapped_points", + "vector_points", + "vector_grid", + "steps", + "baseline_fill", + "series_fill", + "polar", + "radar", + "annotations", + ], +) +def test_public_export_renderer_feature_families(backend, feature): + from test_renderer_018_features import populate + + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + populate(source, feature) + populate(target, feature) + assert_public_replay(source, target) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_all_annotation_kinds_rich_runs_and_mixed_coordinates_replay(backend): + with fg.Figure(backend=backend, dpi=100) as source, fg.Figure( + backend=backend, dpi=100 + ) as target: + for fig in (source, target): + ax = fig.axes[0] + ax.plot([0, 1, 2], [1, 2, 0], id="line") + ax = source.axes[0] + note = ax.text( + 0.1, + 0.3, + [ + fg.TextRun("한글 ", bold=True), + fg.TextRun("A", color="red", fontsize=16), + fg.TextRun("2", script="super"), + ], + coords="ratio", + id="note", + ) + note.set_bbox(facecolor="#ffffccaa", linewidth=2, pad=6) + note.set_multialignment("center") + line = ax.axhline(1, id="threshold") + line.set_style(color="red", linewidth=2, linestyle="--") + line.set_label("Threshold", fontsize=13, color="green") + line.set_text([fg.TextRun("Threshold "), fg.TextRun("x", italic=True)]) + band = ax.axvspan(0.5, 0.8, id="band") + band.set_style(color="#00ff0044") + band.set_bounds(0.4, 0.9) + arrow = ax.arrow(0, 0, 1, 1, id="arrow") + arrow.set_endpoints((0.4, 0.4), (1.5, 1), coords="ratio", endcoords="data") + arrow.set_label("Peak", color="blue", multialignment="right") + arrow.set_style(linestyle=":", linewidth=2, head_length=10, head_width=7) + arrow.set_visible(False) + assert_public_replay(source, target) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_contours_labels_palette_and_resident_matrix_replay(backend): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + for fig in (source, target): + fig.axes[0].contourf([[0, 1, 2], [2, 1, 0], [1, 2, 3]], id="field") + field = source.artist("field") + field.contours.set_levels([0.5, 1.5, 2.5]) + field.contours.set_style( + color="purple", linewidth=2, linestyle="--", colors=["red", "green", "blue"] + ) + field.contours.set_labels( + fontsize=10, + color="black", + formatter=SignificantFormatter(4, "scientific"), + positions=[fg.ContourLabel(1, 0.8, 0.7, tangent=(1, 2))], + spacing=70, + background="#ffffffe6", + padding=2, + ) + field.set_field_fill(mode="bands", shading="interpolated", alpha=0.6) + assert_public_replay(source, target) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +@pytest.mark.parametrize("kind", ["categorical", "boxplot", "radial", "radar"]) +def test_bounded_element_overrides_and_decorations_public_replay(backend, kind): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + for fig in (source, target): + ax = fig.axes[0] + if kind == "categorical": + ax.bar(["a", "b"], [2, 3]) + elif kind == "boxplot": + ax.boxplot([[1, 2, 4], [2, 4, 6]]) + elif kind == "radial": + ax.pie([2, 3]) + ax.chart.set_split(1, [1, 2], labels=["one", "two"]) + else: + ax.radar(["a", "b", "c"], [2, 3, 4]) + ax = source.axes[0] + chart = ax.chart + if kind == "radial": + chart.slice(0).set_style(material="paper", roughness=0.8) + chart.slice(0).set_value_labels( + position="inside", format="value_percent", color="purple" + ) + chart.slice(0, detail=True).set_style(material="matte", gloss=0.2) + chart.slice(1, detail=True).set_explode(0.1) + chart.set_split_inner_radius(0.5) + chart.decoration("slice_label", index=0).set_offset((2, 3)) + else: + state = source.get_settings()[kind] + sid = state["series"][0]["id"] + if kind == "radar": + chart.series(sid).set_style( + linewidth=2, facecolor="#ff000033", marker="s", markersize=6 + ) + chart.set_ring_spacing(1) + chart.decoration( + "spoke_label", category=state["categories"][0]["id"] + ).set_offset((2, 3)) + else: + chart.series(sid).set_style(linewidth=2, edgecolor="red", outline=True) + cell = chart.cell(state["categories"][0]["id"], sid) + cell.set_color("green") + cell.set_style(material="matte", linewidth=3, outline=True) + cell.set_value_labels( + position="median_and_count" if kind == "boxplot" else "outside" + ) + chart.decoration( + "value_label", category=state["categories"][0]["id"], series=sid + ).set_offset((2, 3)) + ax.set_title([fg.TextRun("Summary ", bold=True), fg.TextRun("x", script="sub")]) + chart.decoration("title").set_offset((3, 4)) + chart.set_plot_bounds( + (25, 50, 520, 250) if kind == "radial" else (90, 80, 300, 220) + ) + ax.text(0.4, 0.4, "Note", coords="ratio", id="note").set_bbox(pad=3) + assert_public_replay(source, target) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +@pytest.mark.parametrize( + "case", ["palettes", "clean_palettes", "errors", "bins", "vector"] +) +def test_public_recipe_overwrites_a_differently_styled_destination(backend, case): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + for fig in (source, target): + ax = fig.axes[0] + if case in ("palettes", "clean_palettes"): + artist = ax.scatter([0, 1, 2], [2, 1, 3], id="data") + source_col = fig.data.add("classes", [0, 1, 0]) + artist.markers.set_palette( + [fg.MarkerStyle(color="red"), fg.MarkerStyle(marker="s")], + source=source_col, + ) + artist.markers.set_overrides( + [ + (1, fg.MarkerStyle(color="blue")), + (1, fg.MarkerStyle(markersize=8)), + ] + ) + artist.markers.map_size(source_col, domain=(0, 1), markersizes=(4, 12)) + elif case == "errors": + artist = ax.errorbar( + [0, 1, 2], [1, 2, 3], yerr=[0.2, 0.1, 0.3], id="data" + ) + source_col = fig.data.add("classes", [0, 1, 0]) + artist.errors.set_palette( + [fg.ErrorBarStyle(color="red"), fg.ErrorBarStyle(linewidth=3)], + source=source_col, + ) + artist.errors[1].set(capsize=9, capthick=2) + elif case == "bins": + _, _, artist = ax.hist([0, 0, 1, 2, 3], id="data") + artist.bins[0].set( + facecolor="red", edgecolor="black", linewidth=2, width=0.7, gap=2 + ) + else: + artist = ax.quiver( + [0, 1, 2], + [1, 2, 3], + [1, 0, -1], + [0, 1, 0.5], + values=[1, 2, 3], + id="data", + ) + artist.vectors.set( + color="green", + linewidth=2, + pivot="middle", + scale=15, + units="pixels", + min_length=3, + head_width=8, + head_length=10, + ) + if case == "clean_palettes": + source.artist("data").markers.set_palette(None) + source.artist("data").markers.set_overrides(None) + source.artist("data").markers.map_size(None) + elif case == "palettes": + target.artist("data").markers[0].set(color="yellow", markersize=20) + elif case == "errors": + target.artist("data").errors.palette[0].set(color="black") + elif case == "bins": + target.artist("data").bins[1].set(facecolor="green") + else: + target.artist("data").vectors.set( + scale=0.5, units="data", pivot="tip", linewidth=3 + ) + source.artist("data").vectors.map_color(None) + assert_public_replay(source, target) + + +@pytest.mark.parametrize( + "variable", + [ + "MarkerStyle", + "ErrorBarStyle", + "BinStyle", + "ContourLabel", + "TextRun", + "TimestampFormatter", + ], +) +def test_new_export_identifiers_do_not_shadow_the_figure(variable): + with fg.Figure(backend="webgpu") as source, fg.Figure(backend="webgpu") as target: + for fig in (source, target): + artist = fig.axes[0].contour([[0, 1], [2, 3]], id="field") + artist.contours.set_labels(positions=[fg.ContourLabel(0, 0.2, 0.3)]) + fig.axes[0].set_title([fg.TextRun("a", bold=True), fg.TextRun("b")]) + fig.axes[0].xaxis.set_major_formatter(TimestampFormatter()) + exec(source.to_code(variable=variable), {variable: target}) + assert source.get_settings()["config"] == target.get_settings()["config"] + assert source.get_settings()["series"] == target.get_settings()["series"] + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_overlay_axis_and_custom_legend_replay_without_parent_edits(backend): + with fg.Figure(backend=backend, grid=[["left", "right"]]) as source, fg.Figure( + backend=backend, grid=[["left", "right"]] + ) as target: + for fig in (source, target): + ax = fig.subplots["right"].axes + ax.plot([0, 1, 2], [1, 2, 3], id="base") + twin = ax.twinx(id="pressure") + twin.plot([0, 1, 2], [10, 20, 30], label="Pressure", id="pressure_data") + twin.legend(False) + twin = source.subplots["right"].axes.get_overlay("pressure") + primary = copy.deepcopy( + source.subplots["right"].axes._config(source.get_settings())["left_y"] + ) + twin.yaxis.set_view_interval(5, 50) + twin.yaxis.set_scale("log") + twin.yaxis.set_tick_params( + direction="out", length=7, labelsize=8, right=True, labelright=True + ) + twin.yaxis.set_ticklabel_offset((2, 3)) + twin.yaxis.set_layout_margin(40) + twin.yaxis.line.set(color="purple", linewidth=2, linestyle="--") + twin.yaxis.set_major_formatter(SignificantFormatter(4, "power")) + twin.set_ylabel("Pressure (kPa)", fontsize=11, color="purple") + twin.legend_handle.set_offset((3, 4)) + twin.legend_handle.content.set( + text=[fg.TextRun("P", italic=True), fg.TextRun(" (kPa)")], + multialignment="right", + ) + assert ( + source.subplots["right"].axes._config(source.get_settings())["left_y"] + == primary + ) + assert_public_replay(source, target) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_renderer_unsupported_combinations_and_invalid_new_inputs_are_atomic(backend): + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + ax.boxplot([[1, 2, 3], [2, 3, 4]]) + before, revision = fig.get_settings(), fig.revision + for call in ( + lambda: ax.chart.set_value_labels(position="outside"), + lambda: ax.chart.set_style(material="paper"), + lambda: ax.chart.set_plot_bounds((0, 0, -2, 10)), + lambda: ax.chart.cell("missing", "missing").set_color("red"), + ): + with pytest.raises((ValueError, AttributeError)): + call() + assert fig.get_settings() == before and fig.revision == revision + with fg.Figure(backend=backend) as fig: + artist = fig.axes[0].contour([[0, 1], [2, 3]]) + before, revision = fig.get_settings(), fig.revision + for call in ( + lambda: artist.contours.set_labels(spacing=0), + lambda: artist.contours.set_style(linestyle="bad"), + lambda: artist.contours.set_labels(positions=[{"x": 1}]), + lambda: artist.set_grid("unregistered"), + ): + with pytest.raises((ValueError, TypeError)): + call() + assert fig.get_settings() == before and fig.revision == revision + + +@pytest.mark.parametrize( + "shape", + [ + "Circle", + "Square", + "Triangle", + "Diamond", + "Cross", + "CircleFilled", + "SquareFilled", + "TriangleFilled", + "DiamondFilled", + "TriangleDown", + "TriangleLeft", + "TriangleRight", + "Plus", + "Pentagon", + "Hexagon", + "Octagon", + "Star", + "TriangleDownFilled", + "TriangleLeftFilled", + "TriangleRightFilled", + "PlusFilled", + "CrossFilled", + "PentagonFilled", + "HexagonFilled", + "OctagonFilled", + "StarFilled", + ], +) +def test_all_native_marker_shapes_export_public_symbols_and_fillstyle(shape): + from figgyplot._markers import decode_marker + + marker, fillstyle = decode_marker(shape) + with fg.Figure(backend="native") as source, fg.Figure(backend="native") as target: + for fig in (source, target): + fig.axes[0].scatter([0, 1, 2], [2, 1, 3], id="data") + artist = source.artist("data") + artist.set_marker(marker) + if fillstyle is not None: + artist.set_fillstyle(fillstyle) + assert artist._body(source.get_settings())["scatter"]["point_shape"] == shape + artist.markers.set_palette([fg.MarkerStyle(marker=marker, fillstyle=fillstyle)]) + assert_public_replay(source, target) + code = source.to_code() + assert f"marker={shape!r}" not in code + + +def test_bounded_public_style_map_covers_every_native_style_leaf(): + from figgyplot._appearance import _BOUNDED + from figgyplot._native import default_bounded_json, default_radial_json + import json + + def leaves(node, prefix=""): + for key, value in node.items(): + path = prefix + key + if isinstance(value, dict) and set(value) != set("rgba"): + yield from leaves(value, path + "/") + else: + yield path + + for kind in ("categorical", "boxplot", "radar", "radial"): + defaults = json.loads( + default_radial_json() if kind == "radial" else default_bounded_json(kind) + ) + assert {path for path, _ in _BOUNDED[kind].values()} == set( + leaves(defaults["style"]) + ) + + +@pytest.mark.parametrize("case", ["contour_labels", "polar", "standalone_colorbar"]) +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_export_explicitly_removes_optional_appearance_absent_from_source( + case, backend +): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + for fig in (source, target): + if case == "contour_labels": + fig.axes[0].contour([[0, 1], [2, 3]], id="field") + else: + fig.axes[0].plot([0, 1], [1, 2], id="line") + if case == "contour_labels": + target.artist("field").contours.set_labels() + elif case == "polar": + target.axes[0].set_polar_options(unit="radians", clockwise=True) + else: + target.axes[0].add_colorbar() + assert_public_replay(source, target) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_line_fill_and_marker_ink_are_independent_in_public_export(backend): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + for fig in (source, target): + fig.axes[0].plot( + [0, 1, 2], + [1, 3, 2], + marker="o", + fill=0, + fill_color="#0000ff44", + color="red", + id="line", + ) + source.artist("line").markers.set(color="green", markersize=8) + target.artist("line").markers.set(color="purple", markersize=12) + assert_public_replay(source, target) + source.artist("line").set_fill(0, color=None) + assert ( + "fill_color" + not in source.artist("line")._body(source.get_settings())["line"] + ) + + +def test_public_replay_keeps_every_panel_visible_against_independent_ink_oracles(): + import io + from PIL import Image + + with fg.Figure(backend="native", grid=[["top"], ["bottom"]]) as fig: + for panel, color in (("top", "red"), ("bottom", "blue")): + ax = fig.subplots[panel].axes + ax.plot( + [0, 1], [0.25, 0.75], color=color, linewidth=3, id=panel + "_signal" + ) + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + initial = fig.get_settings() + exact = fig.to_code(mode="exact") + for _ in range(2): + image = fig.render_rgba() + for panel, channel in (("top", 0), ("bottom", 2)): + bounds = fig.subplots[panel].axes.get_panel_position().bounds + height, width = image.shape[:2] + left, bottom, w, h = bounds + area = image[ + round((1 - bottom - h) * height) : round((1 - bottom) * height), + round(left * width) : round((left + w) * width), + ] + other = [c for c in range(3) if c != channel] + ink = ( + (area[..., channel] > 200) + & (area[..., other[0]] < 100) + & (area[..., other[1]] < 100) + & (area[..., 3] > 200) + ) + assert np.count_nonzero(ink) > 50, (panel, fig.info["adapter"]) + np.testing.assert_array_equal( + image, np.asarray(Image.open(io.BytesIO(fig.to_png()))) + ) + exec(fig.to_code(), {"fig": fig}) + exec(exact, {"fig": fig}) + assert fig.get_settings() == initial + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +@pytest.mark.parametrize("kind", ["categorical", "boxplot", "radial", "radar"]) +def test_bounded_discovery_and_chained_edit_stay_on_the_selected_element(backend, kind): + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + if kind == "radial": + ax.pie([2, 3]) + assert ax.chart.get_slice_count() == 2 + element = ax.chart.slice(0) + elif kind == "radar": + ax.radar(["a", "b", "c"], [1, 2, 3]) + series = ax.chart.get_series() + assert len(series) == 1 + assert list(ax.chart.get_categories().values()) == ["a", "b", "c"] + element = ax.chart.series(next(iter(series))) + else: + ax.bar(["a", "b"], [2, 3]) if kind == "categorical" else ax.boxplot( + [[1, 2, 3], [2, 3, 4]] + ) + series, categories = ax.chart.get_series(), ax.chart.get_categories() + element = ax.chart.cell(next(iter(categories)), next(iter(series))) + shared = ax.chart.get_style() + assert element.get_style() is None + assert element.set_style(linewidth=2) is element + assert element.set_color("purple") is element + assert element.get_style()["linewidth"] == pytest.approx(2) + assert ax.chart.get_style() == shared + element.reset_style() + assert element.get_style() is None + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_sparse_fillstyle_can_restore_shared_shape_inheritance(backend): + with fg.Figure(backend=backend) as fig: + points = fig.axes[0].scatter([0, 1], [1, 2]) + points.markers[0].set(marker="s", fillstyle="none", color="red") + points.markers[0].set(fillstyle=None) + override = points.markers[0].get_override() + assert override.marker is None and override.fillstyle is None + assert override.color == (1, 0, 0, 1) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_export_keeps_explicit_editor_selection_on_a_fresh_destination(backend): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + for fig in (source, target): + fig.axes[0].imshow([[0, 1], [2, 3]], id="field") + state = source.get_settings() + state["config"]["picked_data"] = { + "refs": [ + { + "kind": "matrix_cell", + "series_id": "field", + "x_index": 1, + "y_index": 0, + } + ] + } + source.apply_settings(state) + expected = source.get_settings() + uploads = target.info.get("column_uploads") + exec(source.to_code(), {"fig": target}) + assert target.get_settings()["config"] == expected["config"] + assert target.get_settings()["series"] == expected["series"] + assert target.info.get("column_uploads") == uploads + if backend == "native": + np.testing.assert_array_equal(source.render_rgba(), target.render_rgba()) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +@pytest.mark.parametrize("scale", ["linear", "log"]) +def test_colorbar_replay_sets_scale_before_its_locator_on_a_different_destination( + backend, scale +): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + for fig in (source, target): + fig.axes[0].imshow([[1, 10], [100, 1000]], vmin=1, vmax=1000, id="field") + bar = source.axes[0].get_colorbar() + bar.ax.yaxis.set_scale(scale) + from figgyplot.ticker import LogLocator, MultipleLocator + + bar.ax.yaxis.set_major_locator( + LogLocator(10) if scale == "log" else MultipleLocator(100) + ) + target.axes[0].get_colorbar().ax.yaxis.set_scale( + "linear" if scale == "log" else "log" + ) + assert_public_replay(source, target) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_linear_view_replay_can_replace_a_log_view_with_nonpositive_limits(backend): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + for fig in (source, target): + fig.axes[0].plot([0, 1, 2], [1, 2, 3], id="line") + target.axes[0].set_xscale("log") + assert source.axes[0].get_xlim()[0] < 0 + assert_public_replay(source, target) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_wide_log_view_replays_explicit_spacing_independent_of_initial_scale(backend): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + for fig in (source, target): + fig.axes[0].plot([1, 1e8, 1e20], [1, 2, 3], id="line") + source.axes[0].set_xscale("log") + source.axes[0].set_xlim(1, 1e20) + from figgyplot.ticker import LogLocator + + source.axes[0].xaxis.set_major_locator(LogLocator(10000)) + assert_public_replay(source, target) + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_single_panel_placement_and_entry_alignment_use_public_replay(backend): + with fg.Figure(backend=backend, grid=[["only"]]) as source, fg.Figure( + backend=backend, grid=[["only"]] + ) as target: + for fig in (source, target): + fig.axes[0].plot([0, 1, 2], [1, 3, 2], label="Signal", id="line") + source.axes[0].set_panel_position((0, 0.2, 0.8, 0.8)) + source.artist("line").legend_label.set_multialignment("center") + assert_public_replay(source, target) diff --git a/tests/test_radial.py b/tests/test_radial.py index 1f6ea05..e64b5fc 100644 --- a/tests/test_radial.py +++ b/tests/test_radial.py @@ -84,20 +84,20 @@ def test_radial_code_replay_preserves_new_values_and_shared_device(): with make(inner_radius=0.5, material="pearl", tilt=55, depth=0.16) as source, make() as replay: source.configure_radial(slices={0: {"explode": 0.08}}) source.set_radial_style(texture_strength=0.8) - code = source.to_code() + code = source.to_code(mode='exact') assert "configure_radial" in code and "'value':" not in code exec(code, {"fig": replay}) assert source.get_settings()["radial"] == replay.get_settings()["radial"] np.testing.assert_array_equal(source.render_rgba(), replay.render_rgba()) with make(inner_radius=0.5, material="pearl") as source, make((10, 20, 30, 40)) as replay: - exec(source.to_code(), {"fig": replay}) + exec(source.to_code(mode='exact'), {"fig": replay}) assert [s["value"] for s in replay.get_settings()["radial"]["slices"]] == [10, 20, 30, 40] assert replay.get_settings()["radial"]["style"]["material"] == "Pearl" with figgy.Figure(backend="native") as source, figgy.Figure(backend="native") as replay: source.axes[0].pie([1, 1]) replay.axes[0].pie([0, 2]) assert source.select(600, 300)["index"] == 0 - exec(source.to_code(), {"fig": replay}) + exec(source.to_code(mode='exact'), {"fig": replay}) assert [s["value"] for s in replay.get_settings()["radial"]["slices"]] == [0, 2] assert replay.get_settings()["radial"]["interaction"]["selected"] is None @@ -123,5 +123,5 @@ def test_radial_style_replay_rejects_a_different_slice_count(): destination.axes[0].pie([1, 2, 3]) before = destination.get_settings() with pytest.raises(ValueError, match="same slice count"): - exec(source.to_code(), {"fig": destination}) + exec(source.to_code(mode='exact'), {"fig": destination}) assert destination.get_settings() == before diff --git a/tests/test_radial_desktop.py b/tests/test_radial_desktop.py index 3ddd10c..6f8a844 100644 --- a/tests/test_radial_desktop.py +++ b/tests/test_radial_desktop.py @@ -5,6 +5,7 @@ import subprocess import time +import numpy as np import pytest import figgyplot as figgy from figgyplot.desktop import poll_events @@ -138,6 +139,27 @@ def test_radial_native_edit_copy_selection_and_no_readback(tmp_path): assert selected != original and selected != 2 inputs.key("Escape") assert inputs.info()["selected"] is None + # Clearing a data selection restores the global form. Layout remains a + # separate mode rather than replacing unselected title/style controls. + (tmp_path/'cleared-selection.json').write_text(json.dumps(dict(window=inputs.info(),settings=fig.get_settings()),indent=2)) + assert "radial_title" in inputs.info()["controls"] + # Compare mode switches with the same pointer/hover state. Moving from + # a slice to the toolbar legitimately clears renderer hover animation. + rect=inputs.info()['controls']['layout_mode'] + inputs.input_frame('mousemove','--window',inputs.wid,round(rect[0]+rect[2]/2),round(rect[1]+rect[3]/2)) + deadline=time.monotonic()+3 + while fig.get_settings()['radial']['interaction']['hover_progress']!=0: + assert time.monotonic()= 245, 'Chart focus outline is painted over the opaque review popup' + visible_y = round(stage[1] + stage[3] - 12) + visible = np.asarray(image.convert('RGB'))[ + y0+visible_y-1:y0+visible_y+2, x0+edge-1:x0+edge+2] + assert np.any(np.all(visible == [38, 99, 173], axis=-1)), \ + 'The focus outline must remain visible outside the popup' + inputs.click('review_target_save') + draft = json.loads(handle.review_draft_json()) + assert draft['target_comments'][0]['target'] == 'panel/ch/chart_title' diff --git a/tests/test_review_store.py b/tests/test_review_store.py new file mode 100644 index 0000000..350542e --- /dev/null +++ b/tests/test_review_store.py @@ -0,0 +1,143 @@ +"""Storage tests also run on Windows without a renderer or a native wheel.""" +import errno +import importlib.util +from pathlib import Path +import multiprocessing +import os +import sys +from types import SimpleNamespace + +import pytest + + +def storage_module(): + path=Path(__file__).resolve().parents[1]/'python/figgyplot/_review_store.py' + spec=importlib.util.spec_from_file_location('review_storage_under_test',path) + module=importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def storage_writer(path,start,iterations): + store=storage_module().Store(path) + assert start.wait(10) + for _ in range(iterations): + with store.locked(): + state=store.read() + state['counter']+=1 + store.write(state) + + +def storage_reader(path,start): + store=storage_module().Store(path) + assert start.wait(10) + previous=-1 + for _ in range(80): + with store.locked(): + state=store.read() + assert state['generation']>=previous + previous=state['generation'] + + +def test_image_cleanup_retries_windows_preview_sharing_contention(tmp_path,monkeypatch): + module=storage_module();store=module.Store(tmp_path) + image=tmp_path/'attachments'/('a'*64+'.png');image.write_bytes(b'unreferenced image') + state=dict(protocol=1,generation=0,rounds=[ + dict(number=1,status='completed',documents={'plot':{'comments':[{'attachments':[{'file':'attachments/'+image.name}]}]}}), + dict(number=2,status='reviewing',documents={})]) + store.write(state) + original=Path.unlink;attempts=[] + def unlink(path,*args,**kwargs): + if path==image: + attempts.append(path) + if len(attempts)==1:raise PermissionError(errno.EACCES,'preview reader still closing') + return original(path,*args,**kwargs) + monkeypatch.setattr(module,'_WINDOWS',True) + monkeypatch.setattr(Path,'unlink',unlink) + store.collect(state) + assert len(attempts)==2 and not image.exists() + assert [r['number'] for r in store.read()['rounds']]==[2] + + +def test_process_subscribers_and_controllers_share_a_durable_head(tmp_path): + store=storage_module().Store(tmp_path) + with store.locked():store.write(dict(protocol=1,generation=0,counter=0,rounds=[])) + context=multiprocessing.get_context('spawn') + start=context.Event() + processes=[context.Process(target=storage_writer,args=(str(tmp_path),start,20)) for _ in range(3)] + processes.append(context.Process(target=storage_reader,args=(str(tmp_path),start))) + try: + for process in processes:process.start() + start.set() + for process in processes: + process.join(30) + assert process.exitcode==0 + with store.locked():state=store.read() + assert state['counter']==60 and state['generation']==61 + assert not list(tmp_path.glob('.writing-*')) + # The shared byte is never read/initialized before a lock is owned. + assert (tmp_path/'.lock').stat().st_size==0 + finally: + for process in processes: + if process.is_alive():process.terminate();process.join(5) + + +def test_nested_lock_keeps_the_outer_lock_and_releases_after_exception(tmp_path): + store=storage_module().Store(tmp_path) + with pytest.raises(ValueError,match='deliberate'): + with store.locked(): + with store.locked(): + store.write(dict(protocol=1,generation=0,rounds=[])) + raise ValueError('deliberate') + with store.locked():assert store.read()['generation']==1 + + +def test_windows_byte_lock_retries_without_touching_locked_bytes(tmp_path,monkeypatch): + module=storage_module();monkeypatch.setattr(module,'_WINDOWS',True) + calls=[] + def locking(fd,mode,length): + calls.append(mode) + assert os.fstat(fd).st_size==0 and length==1 + if mode==1 and calls.count(1)<3:raise PermissionError(errno.EACCES,'competing lock') + monkeypatch.setitem(sys.modules,'msvcrt',SimpleNamespace(LK_NBLCK=1,LK_UNLCK=2,locking=locking)) + store=module.Store(tmp_path) + with store.locked(): + with store.locked():pass + assert calls==[1,1,1,2] + + +def test_windows_replace_sharing_denials_keep_the_previous_complete_manifest(tmp_path,monkeypatch): + module=storage_module();path=tmp_path/'manifest.json' + path.write_bytes(b'previous') + monkeypatch.setattr(module,'_WINDOWS',True) + replace=module.os.replace;calls=[] + def sharing(source,target): + calls.append(1) + if len(calls)<4: + assert path.read_bytes()==b'previous' + exc=PermissionError(errno.EACCES,'sharing violation');exc.winerror=32 + raise exc + replace(source,target) + monkeypatch.setattr(module.os,'replace',sharing) + module.atomic_write(path,b'next complete state') + assert len(calls)==4 and path.read_bytes()==b'next complete state' + assert not list(tmp_path.glob('.writing-*')) + + +@pytest.mark.parametrize('windows,code',[(False,errno.EACCES),(True,errno.ENOSPC)]) +def test_nonsharing_errors_are_not_retried(monkeypatch,windows,code): + module=storage_module();monkeypatch.setattr(module,'_WINDOWS',windows) + def fail():raise OSError(code,'cannot write') + monkeypatch.setattr(module.time,'sleep',lambda seconds:pytest.fail('unrelated error was retried')) + with pytest.raises(OSError) as exc:module._windows_retry(fail) + assert exc.value.errno==code + + +def test_windows_lock_timeout_preserves_the_error(monkeypatch): + module=storage_module();monkeypatch.setattr(module,'_WINDOWS',True) + clock=iter([0,1,2,3]);monkeypatch.setattr(module.time,'monotonic',lambda:next(clock)) + monkeypatch.setattr(module.time,'sleep',lambda _:None) + error=PermissionError(errno.EACCES,'still locked') + def busy():raise error + with pytest.raises(PermissionError) as exc:module._windows_retry(busy,timeout=2) + assert exc.value is error diff --git a/tests/test_review_summary.py b/tests/test_review_summary.py new file mode 100644 index 0000000..3230d75 --- /dev/null +++ b/tests/test_review_summary.py @@ -0,0 +1,228 @@ +"""Readable feedback must account for every exact snapshot operation once.""" +import copy +import json +from pathlib import Path +import subprocess +import sys + +import numpy as np +import pytest +from jsonschema import Draft202012Validator + +from figgyplot.review import Session +from figgyplot._review_diff import preserved + + +def validate(value): + import figgyplot.review as review + schema = json.loads(Path(review.__file__).with_name('review-feedback-v1.schema.json').read_text()) + Draft202012Validator({'$ref':'#/$defs/'+value['view'], '$defs':schema['$defs']}).validate(value) + + +def all_ids(summary): + return [cid for fig in summary['figures'] for obj in fig['objects'] for prop in obj['properties'] for cid in prop['operation_ids']] + + +def check_complete(session, work): + full = session.feedback(work) + small = session.feedback_summary(work) + validate(small) + expected = [c for d in full['round']['documents'].values() for c in d['approval']['changes']] + scoped = [(fig['id'],cid) for fig in small['figures'] for obj in fig['objects'] for p in obj['properties'] for cid in p['operation_ids']] + expected_scoped = {(fid,c['id']) for fid,d in full['round']['documents'].items() for c in d['approval']['changes']} + assert len(scoped) == len(set(scoped)) == len(expected) + assert set(scoped) == expected_scoped + assert small['counts']['changes'] == len(expected) + for fig in small['figures']: + detail = session.feedback_details(work, fig['id']) + validate(detail) + assert detail['changes'] == full['round']['documents'][fig['id']]['approval']['changes'] + assert session.feedback(work) == full + return full, small + + +def test_summary_groups_character_edits_and_comments_with_exact_detail(tmp_path): + with Session(tmp_path, backend='webgpu') as session: + line = session.figure(id='line');line.axes[0].plot([0,1],[1,2],id='signal') + line.axes[0].set_title('Before') + ring = session.figure(id='donut');ring.axes[0].donut([3,2,1]) + session.start();line.axes[0].set_title('AFTER!') + session.comment('line','제목을 짧게',target='panel/main/chart_title') + session.submit('line','accept') + session.submit('donut','request_changes',comments='두 번째 조각의 색을 연하게') + work = session.events()[0]['work_id'] + before = session.snapshot() + full, small = check_complete(session,work) + fig = next(f for f in small['figures'] if f['id']=='line') + assert fig['disposition']=='accept' and fig['change_count']==6 + title, = fig['objects'] + assert title['target']=='panel/main/chart_title' and title['change_count']==6 + field, = title['properties'] + assert field['kind']=='rich_text' and field['text_changed'] and not field['formatting_changed'] + assert field['before']==dict(value='Before',truncated=False) + assert field['after']==dict(value='AFTER!',truncated=False) + assert title['comments'][0]['text']=='제목을 짧게' + detail = session.feedback_details(work,'line',target=title['target']) + validate(detail) + obj, = detail['objects'] + assert detail['changes']==full['round']['documents']['line']['approval']['changes'] + assert obj['snapshots']['after']['/config/chart_title']==line.get_settings()['config']['chart_title'] + one = session.feedback_details(work,'line',change_id=field['operation_ids'][0]) + assert len(one['objects'])==1 and len(one['changes'])==1 + assert one['selection']['change_id']==field['operation_ids'][0] + assert session.snapshot()==before + assert len(json.dumps(small,ensure_ascii=False)) < len(json.dumps(full,ensure_ascii=False))/3 + + +@pytest.mark.parametrize('change',['add','remove','move','revert','long_text','format']) +def test_summary_preserves_structural_and_rich_text_changes(tmp_path,change): + with Session(tmp_path,backend='webgpu') as session: + fig=session.figure(id='plot');ax=fig.axes[0];ax.plot([0,1],[1,2],id='signal') + ax.text(.2,.3,'Keep',coords='ratio',id='note') + ax.text(.6,.7,'Other',coords='ratio',id='other') + ax.set_title('x'*200+'A' if change=='long_text' else 'Before') + session.start() + cfg=copy.deepcopy(fig.get_settings()['config']) + if change=='add':ax.text(.4,.5,'Added',coords='ratio',id='added') + elif change=='remove': + cfg['annotations']['items'].pop(0);fig.configure(config={'annotations':cfg['annotations']}) + session.comment('plot','삭제한 주석에 대한 댓글',target='panel/main/annotation/note') + elif change=='move': + cfg['annotations']['items'].reverse();fig.configure(config={'annotations':cfg['annotations']}) + elif change=='revert':ax.set_title('Temporary');ax.set_title('Before') + elif change=='long_text':ax.set_title('x'*200+'B') + elif change=='format': + cfg['chart_title']['text']['segments'][0]['bold']=True + fig.configure(config={'chart_title':cfg['chart_title']}) + session.submit('plot','request_changes',comments='검토') + work=session.events()[0]['work_id'];full,small=check_complete(session,work) + objects=small['figures'][0]['objects'] + if change=='revert':assert not objects and small['counts']['changes']==0 + elif change in ('long_text','format'): + title=next(o for o in objects if o['target']=='panel/main/chart_title') + field,=title['properties'] + if change=='long_text': + assert field['before']['truncated'] and field['after']['truncated'] and field['text_changed'] + assert len(field['after']['value'])==160 + else:assert not field['text_changed'] and field['formatting_changed'] + else: + target='panel/main/annotation/'+('added' if change=='add' else 'other' if change=='move' else 'note') + detail=session.feedback_details(work,'plot',target=target) + assert detail['changes'] + if change=='remove':assert not detail['objects'][0]['snapshots']['after'] + if change=='move':assert any(c['op']=='move' for c in detail['changes']) + for c in full['round']['documents']['plot']['approval']['changes']: + assert preserved(c,fig.get_settings()) + + +def test_summary_uses_stable_panel_overlay_series_and_annotation_targets(tmp_path): + with Session(tmp_path,backend='webgpu') as session: + fig=session.figure(id='plot',grid=[['left','right']]) + ax=fig.subplots['right'];ax.set_title('Before') + ax.twinx(id='pressure').plot([0,1],[3,4],id='signal') + fig.axes[0].text(.2,.3,'Note',coords='ratio',id='note') + session.start() + ax.set_title('AFTER!') + fig.configure(series={'signal':{'render_type':{'Line':{'line':{'line_width':4}}}}}) + cfg=fig.get_settings()['config'];cfg['annotations']['items'][0]['kind']['Text']['anchor']['Ratio']['x']=.4 + cfg['subplots'][0]['config']['overlays'][0]['y_axis']['max']=8. + fig.configure(config=cfg) + fig.place('right',slot='left') + session.submit('plot','request_changes') + work=session.events()[0]['work_id'];_,small=check_complete(session,work) + targets={v['target'] for v in small['figures'][0]['objects']} + assert {'panel/right/chart_title','series/signal','panel/left/annotation/note','panel/right/overlay/pressure/axis_right'} <= targets + for target in targets: + assert session.feedback_details(work,'plot',target=target)['objects'][0]['target']==target + + +@pytest.mark.parametrize('family',['radial','categorical','boxplot','radar']) +def test_bounded_titles_and_annotations_use_editor_comment_ids(tmp_path,family): + with Session(tmp_path,backend='webgpu') as session: + fig=session.figure(id='plot');ax=fig.axes[0] + if family=='radial':ax.donut([3,2,1]) + elif family=='categorical':ax.bar(['A','B'],[3,4]) + elif family=='boxplot':ax.boxplot([[1,2,3],[3,4,5]]) + else:ax.radar(['A','B','C'],[2,3,4]) + ax.set_title('Before');ax.text(.2,.3,'Note',coords='ratio',id='note') + session.start();ax.set_title('AFTER!') + state=fig.get_settings();state[family]['annotations']['items'][0]['kind']['Text']['anchor']['Ratio']['x']=.4 + fig.apply_settings(state) + session.comment('plot','요소 댓글',target='bounded/annotation/note') + session.submit('plot','request_changes') + _,small=check_complete(session,session.events()[0]['work_id']) + targets={o['target']:o for o in small['figures'][0]['objects']} + assert targets['bounded/chart_title']['change_count'] + assert targets['bounded/annotation/note']['change_count'] and targets['bounded/annotation/note']['comments'] + + +def test_summary_cli_details_errors_and_protocol_discovery(tmp_path): + with Session(tmp_path/'cache with spaces',backend='webgpu') as session: + fig=session.figure(id='plot');fig.axes[0].plot([0,1],[1,2]) + session.start() + session.comment('plot','댓글만',target='panel/main/chart_title') + session.submit('plot','request_changes') + work=session.events()[0]['work_id'] + base=[sys.executable,'-m','figgyplot.review','feedback',str(session.cache_dir),work] + run=lambda *args:json.loads(subprocess.check_output(base+list(args),text=True)) + assert run()==session.feedback(work) + small=run('--summary');validate(small) + assert small['counts']['changes']==0 and small['counts']['comments']==1 + detail=run('--figure','plot','--target','panel/main/chart_title');validate(detail) + assert not detail['changes'] and detail['objects'][0]['snapshots']['after'] + for figure,target,change in [('missing',None,None),('plot','missing',None),('plot',None,'missing')]: + with pytest.raises(ValueError):session.feedback_details(work,figure,target=target,change_id=change) + for args in [('--target','missing'),('--summary','--figure','plot')]: + result=subprocess.run(base+list(args),capture_output=True,text=True) + assert result.returncode==2 + with pytest.raises(ValueError,match='Stale'):session.feedback_summary('stale') + protocol=json.loads(subprocess.check_output([sys.executable,'-m','figgyplot.review','protocol'],text=True)) + assert protocol['summary_schema'].endswith('#/$defs/summary') and protocol['details_schema'].endswith('#/$defs/details') + + +@pytest.mark.parametrize('finish',[False,True]) +def test_summary_keeps_unchanged_accepted_figures_and_lifecycle_intent(tmp_path,finish): + with Session(tmp_path,backend='webgpu') as session: + for fid in ['unchanged','reviewed']: + fig=session.figure(id=fid);fig.axes[0].plot([0,1],[1,2],id='signal') + session.start();session.submit('unchanged','accept') + session.submit('reviewed','accept' if finish else 'request_changes',comments='저장 후 종료' if finish else '선 색상 변경',intent='finish' if finish else 'revise') + work=session.events()[0]['work_id'];_,small=check_complete(session,work) + assert small['intent']==('finish' if finish else 'revise') + unchanged=next(f for f in small['figures'] if f['id']=='unchanged') + assert unchanged['disposition']=='accept' and unchanged['objects']==[] and unchanged['change_count']==0 + reviewed=next(f for f in small['figures'] if f['id']=='reviewed') + assert reviewed['comments'][0]['text']==('저장 후 종료' if finish else '선 색상 변경') + + +def test_equal_edit_ids_in_different_figures_are_queried_with_figure_scope(tmp_path): + with Session(tmp_path,backend='webgpu') as session: + figures=[] + for fid in ['first','second']: + fig=session.figure(id=fid);fig.axes[0].plot([0,1],[1,2]);fig.axes[0].set_title('Before');figures.append(fig) + session.start() + for fig in figures:fig.axes[0].set_title('AFTER!');session.submit(fig.id,'request_changes') + work=session.events()[0]['work_id'];_,small=check_complete(session,work) + assert small['counts']['changes']==12 and len(set(all_ids(small)))==6 + for fig in figures: + detail=session.feedback_details(work,fig.id,change_id=small['figures'][0]['objects'][0]['properties'][0]['operation_ids'][0]) + assert detail['figure_id']==fig.id and len(detail['changes'])==1 + + +def test_summary_reports_binary_data_changes_even_with_identical_bounds(tmp_path): + with Session(tmp_path,backend='webgpu') as session: + fig=session.figure(id='plot');fig.axes[0].plot([0,1],[1,2],id='signal') + session.start() + opening=fig.get_settings() + column=fig.get_settings()['series'][0]['y_column'] + fig._native.remove_column(column) + fig._native.upload_column(column,np.array([2.,1.])) + assert fig.get_settings()==opening + session.submit('plot','request_changes') + work=session.events()[0]['work_id'];full,small=check_complete(session,work) + assert small['figures'][0]['data_changed'] + assert small['counts']['changes']==0 + doc=full['round']['documents']['plot'] + assert doc['baseline_columns']!=doc['columns'] + assert doc['baseline_columns'][column]['shape']==doc['columns'][column]['shape'] + assert doc['baseline_columns'][column]['sha256']!=doc['columns'][column]['sha256'] diff --git a/tests/test_round3_api_regressions.py b/tests/test_round3_api_regressions.py new file mode 100644 index 0000000..65bf828 --- /dev/null +++ b/tests/test_round3_api_regressions.py @@ -0,0 +1,187 @@ +"""User-visible contracts found by the third blind public-data evaluation.""" +import copy +import json + +import numpy as np +import pytest + +import figgyplot as fg +from figgyplot.ticker import MultipleLocator, LogLocator +from recovery_assertions import assert_renderer_columns + + +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +@pytest.mark.parametrize('method', ['constructor', 'set_label', 'axis_label']) +def test_explicit_colorbar_label_is_visible_and_explicit_hiding_is_respected(backend, method): + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + points = ax.scatter([1, 2], [3, 4], c=[10, 20], id='points') + if method == 'constructor': + bar = fig.colorbar(points, label='Temperature (K)') + else: + bar = fig.colorbar(points) + (bar.set_label if method == 'set_label' else bar.ax.yaxis.set_label_text)('Temperature (K)') + assert bar.ax.yaxis.label.get_text() == 'Temperature (K)' + assert bar.ax.yaxis.label.get_visible() + before = fig.get_settings() + bar.set_label('Hidden on purpose', visible=False) + assert not bar.ax.yaxis.label.get_visible() + after = fig.get_settings() + assert after['series'] == before['series'] + assert after['config']['colorbar']['axis']['min'] == 10 + assert after['config']['colorbar']['axis']['max'] == 20 + + +@pytest.mark.parametrize('dpi', [72, 144]) +@pytest.mark.parametrize('side', ['left', 'right', 'top', 'bottom']) +def test_colorbar_label_pixels_are_separate_from_ticks_and_inside_canvas(dpi, side): + with fg.Figure(backend='native', figsize=(10, 6), dpi=dpi) as fig: + points = fig.axes[0].scatter([1, 2], [3, 4], c=[10, 20], cmap='gray') + bar = fig.colorbar(points) + bar.set_layout(side=side) + bar.set_label('Temperature (K)', color='red', fontsize=18) + bar.ax.tick_params(labelsize=12, labelcolor='blue') + image = fig.render_rgba() + rgb = image[..., :3].astype(int) + red = (rgb[..., 0] > 200) & (rgb[..., 1] < 40) & (rgb[..., 2] < 40) + blue = (rgb[..., 2] > 200) & (rgb[..., 0] < 40) & (rgb[..., 1] < 40) + assert red.sum() > 20 and blue.sum() > 20 + ry, rx = np.where(red); by, bx = np.where(blue) + if side == 'right': assert rx.min() > bx.max() + elif side == 'left': assert rx.max() < bx.min() + elif side == 'top': assert ry.max() < by.min() + else: assert ry.min() > by.max() + # Pixel separation alone must not pass by clipping away part of a label. + bounds = json.loads(fig._native.bounds_json('colorbar_title'))['rect'] + x, y, w, h = bounds + assert x >= 0 and y >= 0 + assert x+w <= image.shape[1] and y+h <= image.shape[0] + + +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +@pytest.mark.parametrize('horizontal', [False, True]) +def test_categorical_limits_are_guarded_without_partial_edits(backend, horizontal): + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + (ax.barh if horizontal else ax.bar)(['A', 'B'], [2, 3]) + before, revision = fig.get_settings(), fig.revision + uploads = fig.info.get('column_uploads') + setter = ax.set_xlim if horizontal else ax.set_ylim + with pytest.raises(NotImplementedError, match='categorical.*automatic.*range'): + setter(0, 10) + assert fig.get_settings() == before + assert fig.revision == revision + assert fig.info.get('column_uploads') == uploads + for getter in (ax.get_xlim, ax.get_ylim): + with pytest.raises(NotImplementedError, match='automatic bounded-axis limits'): + getter() + assert fig.get_settings() == before and fig.revision == revision + + +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +@pytest.mark.parametrize('compact', [True, False]) +def test_exact_report_style_restores_explicit_white_background_and_entire_ssot(backend, compact): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + for fig in (source, target): + fig.axes[0].plot([0, 1], [2, 3], id='signal') + source.apply_style('report') + before = source.get_settings() + assert before['background'] == dict(r=1., g=1., b=1., a=1.) + target.set_facecolor('#123456') + uploads = target.info.get('column_uploads') + exec(source.to_code(mode='exact', compact=compact), {'fig': target}) + assert target.get_settings() == before + assert source.get_settings() == before + assert target.info.get('column_uploads') == uploads + if backend == 'native': + np.testing.assert_array_equal(source.render_rgba(), target.render_rgba()) + + +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +@pytest.mark.parametrize('policy', ['implicit', 'auto', 'fixed', 'mixed']) +def test_public_code_preserves_tick_intent_without_replanning_current_ticks(backend, policy): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + for fig in (source, target): + ax = fig.axes[0] + ax.plot([0, 1000], [1, 4], id='signal') + ax = source.axes[0] + if policy != 'implicit': + ax.set_xlim(0, 1000) + ax.set_ylim(0, 5) + # A deliberately retained spacing differs from the fresh 200-unit + # plan. Public setters establish this accepted state; no snapshot + # fields are fabricated, and AutoLocator cannot reproduce it. + ax.xaxis.set_major_locator(MultipleLocator(125)) + if policy in ('implicit', 'auto'): + ax.xaxis.set_tick_auto(None if policy == 'implicit' else True) + if policy in ('fixed', 'mixed'): + ax.xaxis.set_tick_auto(False) + if policy == 'mixed': + ax.secondary_xaxis('top').xaxis.set_tick_auto(True) + # A previously accepted auto layout need not match a fresh tick plan. + # Restoring policy must keep this actual SSOT rather than recompute it. + before = source.get_settings() + assert before['config']['bottom_x']['major_spacing'] == 125 + target.axes[0].xaxis.set_major_locator(MultipleLocator(25)) + uploads = target.info.get('column_uploads') + exec(source.to_code(), {'fig': target}) + after = target.get_settings() + assert after['identity'].get('tick_modes') == before['identity'].get('tick_modes') + assert after['config'] == before['config'] + assert after['series'] == before['series'] + assert source.get_settings() == before + assert target.info.get('column_uploads') == uploads + if backend == 'native': + np.testing.assert_array_equal(source.render_rgba(), target.render_rgba()) + + +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +def test_public_code_preserves_tick_policies_of_shared_panels_and_overlay(backend): + def scene(fig): + fig.set_layout([['left', 'right']]) + for panel in fig.subplots.values(): + panel.plot([0, 10], [1, 4], id='signal_'+panel.id) + panel.set_xlim(0, 10) + fig.subplots['right'].twinx(id='pressure').plot([0, 10], [100, 300], id='pressure_data') + fig.share_axes('x', ['left', 'right']) + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + scene(source); scene(target) + source.subplots['right'].axes.get_overlay('pressure').yaxis.set_major_locator(MultipleLocator(50)) + before = copy.deepcopy(source.get_settings()) + exec(source.to_code(), {'fig': target}) + after = target.get_settings() + assert after['identity'].get('tick_modes') == before['identity'].get('tick_modes') + assert after['config'] == before['config'] + assert after['series'] == before['series'] + + +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +@pytest.mark.parametrize('fixed', [False, True]) +def test_public_log_axis_replay_preserves_policy_and_current_decade_layout(backend, fixed): + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + ax.plot([1, 10, 1000], [1, 2, 4], id='signal') + ax.set_xlim(1, 1000); ax.set_xscale('log') + ax.xaxis.set_major_locator(LogLocator(100)) + if not fixed: + ax.xaxis.set_tick_auto(True) + before = fig.get_settings() + assert before['config']['bottom_x']['major_spacing'] == 2 + exec(fig.to_code(), {'fig': fig}) + assert fig.get_settings() == before + + +@pytest.mark.parametrize('backend', ['native', 'webgpu']) +@pytest.mark.parametrize('mode', ['api', 'exact']) +def test_code_replay_preserves_independently_expected_destination_arrays(backend, mode): + with fg.Figure(backend=backend) as source, fg.Figure(backend=backend) as target: + source.axes[0].plot([0, 1000], [1, 4], id='signal') + x = np.array([10., 20.], dtype=np.float64) + y = np.array([9., 8.], dtype=np.float64) + target.axes[0].plot(x, y, id='signal') + source.apply_style('report') + source.axes[0].set_xlim(0, 1000) + exec(source.to_code(mode=mode), {'fig': target}) + # Read actual native GPU buffers / browser transfer bytes independently + # of settings and upload counts. This probe is last and discards target. + assert_renderer_columns(target, {'signal/x': x, 'signal/y': y}) diff --git a/tests/test_shared_gpu.py b/tests/test_shared_gpu.py index 0d82ebc..f60f7a0 100644 --- a/tests/test_shared_gpu.py +++ b/tests/test_shared_gpu.py @@ -143,6 +143,6 @@ def test_text_size_color_and_font_change_render_and_replay(): figure.apply_settings(state) with figgy.Figure(backend='native') as other: other.axes[0].plot([0, 1], [1, 0]) - exec(figure.to_code('other'), {'other': other}) + exec(figure.to_code('other', mode='exact'), {'other': other}) assert other.get_settings() == state assert np.array_equal(figure.render_rgba(), other.render_rgba()) diff --git a/tests/test_style_inheritance.py b/tests/test_style_inheritance.py new file mode 100644 index 0000000..f9f83c3 --- /dev/null +++ b/tests/test_style_inheritance.py @@ -0,0 +1,349 @@ +"""Execution declarations inherit at construction, without replaying accepted edits.""" +import asyncio +import copy +from concurrent.futures import ThreadPoolExecutor +from contextvars import Context + +import numpy as np +import pytest +import figgyplot as fg +from figgyplot.figure import _color +from figgyplot._style_presets import PROFILES + + +@pytest.fixture(autouse=True) +def clean_defaults(): + fg.use_style(None) + yield + fg.use_style(None) + + +def body(fig,index=-1): + return next(iter(fig.get_settings()['series'][index]['render_type'].values())) + + +def close_color(actual,expected): + assert list(actual)==list(expected) + assert list(actual.values())==pytest.approx(list(expected.values())) + + +def contrast(c,b): + def luminance(value): + channels=[value[k] for k in ('r','g','b')] + linear=[v/12.92 if v<=.04045 else ((v+.055)/1.055)**2.4 for v in channels] + return sum(w*v for w,v in zip((.2126,.7152,.0722),linear)) + a,d=luminance(c),luminance(b) + return (max(a,d)+.05)/(min(a,d)+.05) + + +@pytest.mark.parametrize('backend',['native','webgpu']) +@pytest.mark.parametrize('profile',PROFILES) +@pytest.mark.parametrize('kind',['line','bar','box','pie','radar']) +def test_all_task_profiles_inherit_in_both_backends(profile,kind,backend): + with fg.style_context(profile): + fig=fg.Figure(backend=backend) + try: + # Constructors outside the declaring scope still use the captured defaults. + ax=fig.axes[0] + if kind=='line':ax.plot([1,2],[3,4],marker='o',label='Signal') + elif kind=='bar':ax.bar(['a','b'],[2,3]) + elif kind=='box':ax.boxplot([1,2,3,5]) + elif kind=='pie':ax.pie([1,2],labels=['a','b']) + else:ax.radar(['a','b','c'],[1,2,3]) + state=fig.get_settings();token=fg.styles.preset(profile) + if kind=='line':close_color(body(fig)['line']['line_color'],token.appearance['palette'][0]) + else: + family={'bar':'categorical','box':'boxplot','pie':'radial','radar':'radar'}[kind] + close_color(state[family]['background'],token.appearance['background']) + assert state[family]['font_size']==pytest.approx(PROFILES[profile]['font']*fig.dpi/72) + finally:fig.close() + + +def test_scope_nesting_reset_exception_and_figure_optout(): + fg.use_style('dashboard.operations') + with fg.Figure(backend='webgpu') as a: + with pytest.raises(RuntimeError): + with fg.style_context('dashboard.public'): + with fg.Figure(backend='webgpu') as b:assert b.style_defaults.appearance['typography']['font_size']==13 + raise RuntimeError('restore') + with fg.Figure(backend='webgpu') as c,fg.Figure(backend='webgpu',style=None) as d: + assert c.style_defaults.appearance==a.style_defaults.appearance + assert d.style_defaults is None + # Local Figure overrides are composed over current execution defaults. + with fg.Figure(backend='webgpu',style=fg.StyleToken(dict(typography=dict(font_size=12)))) as e: + assert e.style_defaults.appearance['typography']['font_size']==12 + assert e.style_defaults.appearance['background']==a.style_defaults.appearance['background'] + fg.use_style(None) + a.axes[0].plot([1,2],[3,4]) + close_color(body(a)['line']['line_color'],a.style_defaults.appearance['palette'][0]) + with fg.Figure(backend='webgpu') as f:assert f.style_defaults is None + + +def test_contexts_and_threads_do_not_share_declarations(): + fg.use_style('dashboard.operations') + def observe(): + with fg.Figure(backend='webgpu') as f:return f.style_defaults + assert Context().run(observe) is None + with ThreadPoolExecutor(1) as executor:assert executor.submit(observe).result() is None + async def scoped(name): + with fg.style_context(name): + await asyncio.sleep(0) + return observe().appearance['typography']['font_size'] + async def run():return await asyncio.gather(scoped('dashboard.operations'),scoped('dashboard.public')) + assert asyncio.run(run())==[11,13] + assert observe().appearance['typography']['font_size']==11 + + +@pytest.mark.parametrize('name,kwargs,paths',[ + ('plot',dict(color='black',linewidth=1.5,linestyle='-',marker='o',markersize=4), + [('line','line_color'),('line','line_width'),('line','line_style'),('scatter','point_shape'),('scatter','point_size')]), + ('scatter',dict(color='black',s=16,marker='o'),[('scatter','point_color'),('scatter','point_size'),('scatter','point_shape')]), + ('errorbar',dict(color='black',ecolor='black',linewidth=1.5,elinewidth=1,capsize=3,marker='o',markersize=4), + [('line','line_color'),('line','line_width'),('scatter','point_size'),('err_style','error_bar_width'),('err_style','error_bar_cap_size')]), + ('stairs',dict(color='black',edgecolor='black',linewidth=.5,gap=1,width=1), + [('bar','fill_color'),('bar','border_color'),('bar','border_width'),('bar','gap_px'),('bar','width_ratio')]), + ('contour',dict(color='black',linewidth=1),[('contour','line','line_color'),('contour','line','line_width')]), + ('quiver',dict(color='black',width=1,head_length=6,head_width=4), + [('style','color'),('style','width_px'),('style','head_length_px'),('style','head_width_px')]), +]) +def test_explicit_values_equal_to_old_defaults_win(name,kwargs,paths): + def draw(fig): + ax=fig.axes[0] + args={'plot':([1,2],[3,4]),'scatter':([1,2],[3,4]),'errorbar':([1,2],[3,4]), + 'stairs':([1,2],[0,1,2]),'contour':(np.arange(9).reshape(3,3),), + 'quiver':([1,2],[3,4],[.2,.3],[.3,.2])}[name] + extra=dict(yerr=.1) if name=='errorbar' else {} + getattr(ax,name)(*args,**kwargs,**extra) + with fg.Figure(backend='webgpu') as original,fg.Figure(backend='webgpu',style='dashboard.wallboard') as themed: + draw(original);draw(themed) + for path in paths: + a,b=body(original),body(themed) + for part in path:a,b=a[part],b[part] + assert a==b,(name,path,a,b) + + +@pytest.mark.parametrize('name',['step','hist','contourf','fill_between']) +def test_nested_constructors_do_not_treat_forwarded_defaults_as_explicit(name): + with fg.Figure(backend='webgpu',style='dashboard.operations') as fig: + ax=fig.axes[0] + if name=='step':ax.step([1,2],[3,4],where='post') + elif name=='hist':ax.hist([1,1,2,3],bins=3) + elif name=='contourf':ax.contourf(np.arange(9).reshape(3,3)) + else:ax.fill_between([1,2],[3,4],[2,3]) + b=body(fig) + if name=='step': + close_color(b['line']['line_color'],_color('#64c6ef')) + assert b['line']['interpolation']=='StepPost' + elif name=='hist':close_color(b['bar']['fill_color'],_color('#64c6ef')) + elif name=='contourf':assert b['contour']['line']['line_width']==0 + else: + assert all(next(iter(s['render_type'].values()))['line']['line_width']==0 for s in fig.get_settings()['series']) + close_color(dict(b['line']['fill_color'],a=1),_color('#64c6ef')) + assert b['line']['fill_color']['a']==pytest.approx(.3) + + +def test_existing_edits_are_not_replayed_by_new_series_annotations_or_layout(): + with fg.Figure(backend='webgpu',style='dashboard.operations') as fig: + ax=fig.axes[0];ax.plot([1,2],[3,4],id='kept') + ax.text(.5,.5,'Keep this',coords='ratio',id='text') + fig.configure(series={'kept':{'render_type':{'Line':{'line':{'line_width':23}}}}}) + state=fig.get_settings();state['config']['chart_title']['text']['font_size']=31 + state['config']['annotations']['items'][0]['kind']['Text']['text']['font_size']=29 + fig.apply_settings(state);before=fig.get_settings() + ax.scatter([1,2],[3,4]);ax.text(.2,.3,'New text',coords='ratio') + fig.set_layout([['main','new']]);overlay=ax.twinx(id='right') + after=fig.get_settings() + assert after['series'][0]==before['series'][0] + assert after['config']['chart_title']==before['config']['chart_title'] + assert after['config']['annotations']['items'][0]==before['config']['annotations']['items'][0] + assert after['config']['subplots'][0]['config']['chart_title']['text']['font_size']==pytest.approx(14*fig.dpi/72) + assert overlay._overlay(after)['y_axis']['label_style']['font_size']==pytest.approx(11*fig.dpi/72) + + +@pytest.mark.parametrize('name',['text','arrow','axhline','axvspan']) +def test_annotation_defaults_and_explicit_style_preserve_geometry(name): + def add(fig,explicit): + kw=dict(color='black') if explicit else {} + ax=fig.axes[0] + if name=='text':ax.text(.3,.4,'Do not replace',coords='ratio',**kw) + elif name=='arrow':ax.arrow(.1,.2,.3,.4,coords='ratio',**kw) + elif name=='axhline':ax.axhline(2,**kw) + else:ax.axvspan(1,3,**kw) + return next(iter(fig.get_settings()['config']['annotations']['items'][-1]['kind'].values())) + with fg.Figure(backend='webgpu') as original,fg.Figure(backend='webgpu',style='dashboard.operations') as themed: + before=add(original,True);after=add(themed,True) + # Geometry and explicit black are identical despite dark profile. + for key in ('anchor','from','to','axis','value'): + if key in before:assert after[key]==before[key] + color=lambda b: b['text']['color'] if name=='text' else b['style']['line']['color'] if name=='arrow' else b['style']['color'] if name=='axhline' else b['fill'] + close_color(color(before),color(after)) + default=add(themed,False) + assert color(default)!=color(before) + + +def test_stable_cross_figure_category_keys_and_explicit_semantic_roles(): + with fg.style_context('dashboard.operations'): + with fg.Figure(backend='webgpu') as first,fg.Figure(backend='webgpu') as second: + a,b=first.axes[0],second.axes[0] + a.plot([0,1],[1,2],style_key='service-a');a.plot([0,1],[2,3],style_key='service-b') + b.plot([0,1],[2,3],style_key='service-b');b.plot([0,1],[1,2],style_key='service-a') + assert body(first,0)['line']==body(second,1)['line'] + assert body(first,1)['line']==body(second,0)['line'] + b.plot([0,1],[4,4],style_key='warning') + assert body(second)['line']['line_style']=='Dash' + b.scatter([0,1],[4,4],style_key='warning') + assert body(second)['scatter']['point_shape']=='TriangleFilled' + b.plot([0,1],[5,5],color='black',style_key='critical') + close_color(body(second)['line']['line_color'],_color('black')) + with fg.Figure(backend='webgpu') as f: + with pytest.raises(ValueError,match='requires'):f.axes[0].plot([1,2],style_key='service-a') + + +def test_palette_exhaustion_adds_dashes_then_reports_capacity(): + token=fg.StyleToken(dict(palette=['#0067a5'],line_cycle=['Solid','Dash'])) + with fg.Figure(backend='webgpu',style=token) as fig: + fig.axes[0].plot([1,2]);fig.axes[0].plot([1,2]) + assert body(fig,0)['line']['line_style']=='Solid';assert body(fig,1)['line']['line_style']=='Dash' + with pytest.warns(fg.StyleWarning,match='exhausted'):fig.axes[0].plot([1,2]) + + +def test_set_style_and_one_shot_have_distinct_future_behavior(): + with fg.Figure(backend='webgpu') as fig: + fig.apply_style('dashboard.operations');assert fig.style_defaults is None + fig.axes[0].plot([1,2]);fig.set_style('dashboard.operations') + fig.axes[0].plot([2,3]);close_color(body(fig)['line']['line_color'],_color('#baa5ff')) + fig.set_style(None);assert fig.style_defaults is None + before=fig.get_settings()['series'] + fig.axes[0].plot([3,4]);assert fig.get_settings()['series'][:2]==before + + +def test_declarations_do_not_replay_scale_or_text_and_are_copied(): + with fg.Figure(backend='webgpu') as source: + source.axes[0].set_title('SOURCE');source.axes[0].set_xlim(100,200) + token=source.extract_style(scales=True,text=True) + fg.use_style(token);token.appearance['panel']={'legend':{'padding':99}} + with fg.Figure(backend='webgpu') as fig: + assert fig.style_defaults.scales is None and fig.style_defaults.text is None + assert fig.get_settings()['config']['bottom_x']['min']!=100 + assert 'SOURCE' not in str(fig.get_settings()['config']['chart_title']) + copied=fig.style_defaults;copied.appearance['panel']={'legend':{'padding':123}} + assert fig.style_defaults.appearance!=copied.appearance + + +def test_rejected_style_has_no_partial_columns_or_accepted_document(): + token=fg.styles.preset('dashboard.operations') + with fg.Figure(backend='webgpu',style=token) as fig: + before=fig.get_settings();columns=copy.deepcopy(fig._native.columns) + fig._style_defaults.appearance['series_defaults']['line']['line_width']=-1 + with pytest.raises(ValueError):fig.axes[0].plot([1,2],[3,4]) + assert fig.get_settings()==before + assert fig._native.columns==columns + + +def test_annotation_creation_does_not_consume_category_colors(): + with fg.Figure(backend='webgpu',style='dashboard.operations') as fig: + fig.axes[0].text(.2,.2,'Note',coords='ratio');fig.axes[0].axhline(1) + fig.axes[0].plot([1,2]);close_color(body(fig)['line']['line_color'],_color('#64c6ef')) + + +def test_secondary_axis_inherits_axis_appearance_and_is_readable(): + with fg.Figure(backend='webgpu',style='dashboard.instrument') as fig: + ax=fig.axes[0];ax.configure(left_y={'tick':'Outside'}) + secondary=ax.twinx() + axis=secondary._overlay(fig.get_settings())['y_axis'] + assert axis['tick']=='Inside' + assert axis['label_style']['label_visible'] + assert ax._config(fig.get_settings())['left_y']['tick']=='Outside' + + +def test_role_map_existing_series_and_solid_contour_contrast(): + with fg.Figure(backend='webgpu') as fig: + fig.axes[0].plot([1,2],[3,4],id='latency') + fig.apply_style('dashboard.operations',role_map={'latency':'critical'}) + close_color(body(fig)['line']['line_color'],_color('#ff7c85')) + with fg.Figure(backend='webgpu',style='dashboard.operations') as fig: + fig.axes[0].contour(np.arange(9).reshape(3,3)) + close_color(body(fig)['contour']['line']['line_color'],_color('#64c6ef')) + + +def test_single_bounded_series_key_and_ambiguous_multi_series_guard(): + with fg.Figure(backend='webgpu',style='dashboard.operations') as fig: + fig.axes[0].bar(['a','b'],[1,2],style_key='critical') + close_color(fig.get_settings()['categorical']['series'][0]['color'],_color('#ff7c85')) + with fg.Figure(backend='webgpu',style='dashboard.operations') as fig: + before=fig.get_settings() + with pytest.raises(ValueError,match='one series'): + fig.axes[0].bar(['a','b'],[[1,2],[3,4]],style_key='critical') + assert fig.get_settings()==before + + +def test_default_equal_font_and_bounded_style_overrides(): + with fg.Figure(backend='webgpu',style='dashboard.operations') as fig: + fig.axes[0].text(.2,.2,'Explicit',coords='ratio',fontsize=11,color='black') + rich=fig.get_settings()['config']['annotations']['items'][0]['kind']['Text']['text'] + assert rich['font_size']==pytest.approx(11*fig.dpi/72) + close_color(rich['color'],_color('black')) + with fg.Figure(backend='webgpu',style='dashboard.operations') as fig: + fig.axes[0].bar(['a','b'],[1,2],font_size=18,style=dict(corner_radius=0),color='black') + chart=fig.get_settings()['categorical'] + assert chart['font_size']==18 and chart['style']['corner_radius']==0 + close_color(chart['series'][0]['color'],_color('black')) + + +@pytest.mark.parametrize('theme',['light','dark']) +@pytest.mark.parametrize('density',['compact','comfortable']) +def test_task_variants_contrast_and_print_guards(theme,density): + for name,p in PROFILES.items(): + if p.get('print'): + if theme=='dark' or density=='compact': + with pytest.raises(ValueError):fg.styles.preset(name,theme=theme,density=density) + continue + token=fg.styles.preset(name,theme=theme,density=density);a=token.appearance + assert all(contrast(c,a['background'])>=4.5 for c in a['palette']) + assert all(contrast(v['line']['line_color'],a['background'])>=4.5 for v in a['roles'].values()) + with fg.Figure(backend='webgpu',style=token) as fig: + fig.axes[0].plot([1,2],[3,4]);assert fig.get_settings()['series'] + + +def test_marker_encodings_do_not_repeat_within_declared_capacity(): + for name in PROFILES: + a=fg.styles.preset(name).appearance;seen=set() + with fg.Figure(backend='webgpu',style=name) as fig: + for i in range(fg.styles.describe(name)['category_capacity']): + fig.axes[0].scatter([1,2],[3,4]);s=body(fig)['scatter'] + encoding=tuple(s['point_color'].values())+(s['point_shape'],) + assert encoding not in seen,(name,i) + seen.add(encoding) + + +def test_physical_font_limit_is_reported_without_shrinking(): + with fg.Figure(backend='webgpu',dpi=300,style='print.monochrome') as fig: + before=fig.get_settings() + with pytest.raises(ValueError,match='8..32'):fig.axes[0].boxplot([1,2,3,4]) + assert fig.get_settings()==before + # A caller can choose valid renderer pixels explicitly for this family. + fig.axes[0].boxplot([1,2,3,4],font_size=32) + assert fig.get_settings()['boxplot']['font_size']==32 + + +def test_one_shot_and_bounded_palette_exhaustion_are_reported(): + token=fg.StyleToken(dict(palette=['#0067a5'],line_cycle=['Solid','Dash'])) + with fg.Figure(backend='webgpu') as fig: + for i in range(3):fig.axes[0].plot([1,2],[i,i+1]) + before=fig.get_settings() + with pytest.raises(ValueError,match='encodings exhausted'):fig.apply_style(token,strict=True) + assert fig.get_settings()==before + with pytest.warns(fg.StyleWarning,match='encodings exhausted'):report=fig.apply_style(token) + assert any('encodings exhausted' in s for s in report.skipped) + with fg.Figure(backend='webgpu',style='dashboard.operations') as fig: + with pytest.warns(fg.StyleWarning,match='bounded palette exhausted'): + fig.axes[0].pie([1]*7) + + +def test_concurrent_additions_keep_unique_local_category_assignments(): + with fg.Figure(backend='webgpu',style='dashboard.operations') as fig: + def add(i):fig.axes[0].plot([0,1],[i,i+1],id='s'+str(i)) + with ThreadPoolExecutor(6) as pool:list(pool.map(add,range(6))) + actual=[tuple(next(iter(s['render_type'].values()))['line']['line_color'].values()) for s in fig.get_settings()['series']] + assert len(set(actual))==6 + assert fig.info['columns']==12 diff --git a/tests/test_styles.py b/tests/test_styles.py new file mode 100644 index 0000000..7c28fba --- /dev/null +++ b/tests/test_styles.py @@ -0,0 +1,349 @@ +"""Style reuse preserves destination meaning; expectations use public chart values.""" +import copy +import json + +import numpy as np +import pytest +import figgyplot as fg + + +BACKENDS=['native','webgpu'] + +def populate(fig,kind,offset=0): + ax=fig.axes[0] + x=np.array([1.,2.,3.]);y=np.array([2.,4.,3.])+offset + if kind=='line':ax.plot(x,y,marker='s',id='signal',label='Destination') + elif kind=='mapped':ax.scatter(x,y,c=[1,4,9],id='signal') + elif kind=='error':ax.errorbar(x,y,yerr=[.1,.2,.3],id='signal',label='Destination') + elif kind=='hist':ax.hist([1,2,2,3,4],bins=3,id='signal') + elif kind=='heat':ax.imshow(np.array([[1.,2.],[3.,4.]])+offset,id='signal') + elif kind=='contour':ax.contour(np.arange(9).reshape(3,3)+offset,id='signal') + elif kind=='vector':ax.quiver(x,y,[.1,.2,.3],[.3,.2,.1],scale=17,id='signal') + elif kind=='fill':ax.fill_between(x,y,[.5,1,1.5],id='signal') + elif kind=='steps':ax.step(x,y,where='post',id='signal') + elif kind=='bar':ax.bar(['One','Two'],[2+offset,3+offset]) + elif kind=='box':ax.boxplot([[1,2,4],[2,3,5]]) + elif kind=='radar':ax.radar(['A','B','C'],[2+offset,4+offset,3+offset]) + elif kind=='pie':ax.pie([2+offset,3+offset],labels=['One','Two']) + ax.set_title('Destination title') + return ax + + +def meaning(state): + """Fields with scientific meaning, not a reconstruction of style projection.""" + result={} + for key in ['radial','categorical','boxplot','radar']: + if key in state: + chart=state[key] + result[key]={k:copy.deepcopy(chart[k]) for k in ['categories','mode','direction','kind','value_range','ring_spacing'] if k in chart} + result[key]['items']=[{k:copy.deepcopy(v) for k,v in s.items() if k in ('id','label','value','values','explode')} + for s in chart.get('slices',chart.get('series',[]))] + result[key]['title']=chart['title'] + panels=[state['config']]+[p['config'] for p in state['config'].get('subplots',[])] + result['axes']=[{k:{p:c[k][p] for p in ['scale','min','max','major_spacing','inverted']} for k in ['bottom_x','top_x','left_y','right_y']} for c in panels] + result['text']=[] + for c in panels: + titles={k:c[k]['title_option']['text']['segments'] for k in ['bottom_x','top_x','left_y','right_y']} + titles['title']=c['chart_title']['text']['segments'];result['text'].append(titles) + result['series']=[] + for s in state['series']: + tag,body=next(iter(s['render_type'].items())) + entry=dict(kind=tag,label=s['label'],id=s['series_id'],x=s['x_column'],y=s['y_column']) + for k in ['matrix','components','err_x','err_y']: + if k in body:entry[k]=body[k] + for part,keys in [('line',['fill','interpolation']),('scatter',['point_color_map','point_size_map']),('bar',['baseline','orientation']),('contour',['levels']),('style',['scale','color_map'])]: + for k in keys: + if k in body.get(part,{}):entry[part+'/'+k]=body[part][k] + result['series'].append(entry) + result['identity']=state['identity'] + return copy.deepcopy(result) + + +@pytest.mark.parametrize('backend',BACKENDS) +@pytest.mark.parametrize('kind',['line','bar','box','radar','pie']) +@pytest.mark.parametrize('preset',fg.styles.available()) +def test_presets_preserve_data_text_domains_ids_and_do_not_reupload(backend,kind,preset): + with fg.Figure(backend=backend) as fig: + populate(fig,kind) + before=meaning(fig.get_settings());uploads=fig.info.get('column_uploads') + report=fig.apply_style(preset,strict=True) + assert report.skipped==() + assert meaning(fig.get_settings())==before + assert fig.info.get('column_uploads')==uploads + if backend=='native' and preset=='dark': + image=fig.render_rgba() + assert image[0,0,:3].max()<70 + + +@pytest.mark.parametrize('backend',BACKENDS) +@pytest.mark.parametrize('kind',['line','mapped','error','hist','heat','contour','vector','fill','steps','bar','box','radar','pie']) +def test_extraction_and_json_reuse_never_import_data_or_wording(backend,kind,tmp_path): + with fg.Figure(backend=backend) as source,fg.Figure(backend=backend) as target: + populate(source,kind);populate(target,kind,offset=5) + source.axes[0].set_title('SOURCE ONLY') + source.apply_style('paper') + token=source.extract_style() + assert token.scales is None and token.text is None + assert 'SOURCE ONLY' not in token.to_json() + assert 'x_column' not in token.to_json() and 'err_y' not in token.to_json() + token.save(tmp_path/'style.json');token=fg.StyleToken.load(tmp_path/'style.json') + before=meaning(target.get_settings()) + target.apply_style(token,strict=True) + assert meaning(target.get_settings())==before + if backend=='native':target.render_rgba() + + +@pytest.mark.parametrize('backend',BACKENDS) +def test_scale_and_text_payloads_require_explicit_application(backend): + with fg.Figure(backend=backend) as source,fg.Figure(backend=backend) as target: + sa=populate(source,'line');ta=populate(target,'line',5) + sa.set_title('Source');sa.set_xlabel('Source X');sa.set_ylabel('Source Y') + sa.set_xlim(1,100);sa.set_xscale('log');ta.set_xlabel('Target X') + token=source.extract_style(scales=True,text=True) + before=meaning(target.get_settings());target.apply_style(token) + assert meaning(target.get_settings())==before + target.apply_style(token,text={'axis_titles'}) + s=target.get_settings();assert s['config']['bottom_x']['title_option']['text']['segments']==source.get_settings()['config']['bottom_x']['title_option']['text']['segments'] + assert s['config']['chart_title']['text']['segments']==before['text'][0]['title'] + target.apply_scales(token.scales,limits=False) + assert ta.get_xscale()=='log' + assert target.get_settings()['config']['bottom_x']['max']==before['axes'][0]['bottom_x']['max'] + target.apply_style(token,scales=True,text=True) + assert ta.get_xscale()=='log' + assert target.get_settings()['config']['bottom_x']['max']==100 + assert target.get_settings()['series'][0]['label']==source.get_settings()['series'][0]['label'] + + +@pytest.mark.parametrize('backend',BACKENDS) +def test_log_failure_unknown_fields_and_stale_application_are_atomic(backend): + with fg.Figure(backend=backend) as fig: + ax=fig.axes[0];ax.plot([-1,0,1],[1,2,3]) + token=fig.extract_style(scales=True);token.scales['panels'][0]['axes']['bottom_x']['scale']='Logarithmic' + before=fig.get_settings();revision=fig.revision + with pytest.raises(ValueError,match='positive'):fig.apply_scales(token.scales,limits=False) + bad=fg.styles.preset('paper');bad.appearance['panel']['bottom_x']['min']=10 + with pytest.raises(ValueError,match='unknown style'):fig.apply_style(bad) + with pytest.raises(ValueError,match='stale'):fig.apply_style('dark',expected_revision=revision-1) + assert fig.get_settings()==before and fig.revision==revision + + +def test_visual_scale_and_cross_dpi_reuse_are_absolute_not_multiplicative(): + with fg.Figure(dpi=100) as source,fg.Figure(dpi=200) as target: + populate(source,'line');populate(target,'line') + source.apply_style('presentation');t=source.extract_style();before=meaning(target.get_settings()) + target.apply_style(t,size_scale=1.5) + s=source.get_settings();v=target.get_settings() + assert v['config']['chart_title']['text']['font_size']==pytest.approx(s['config']['chart_title']['text']['font_size']*3) + assert v['series'][0]['render_type']['ScatterLine']['line']['line_width']==pytest.approx(s['series'][0]['render_type']['ScatterLine']['line']['line_width']*3) + assert meaning(v)==before + revision=target.revision;target.apply_style(t,size_scale=1.5) + assert target.revision==revision + + +@pytest.mark.parametrize('backend',BACKENDS) +def test_grid_overlay_and_colorbar_axis_titles_and_ranges(backend): + with fg.Figure(backend=backend,grid=[['left','right']]) as source,fg.Figure(backend=backend,grid=[['a','b']]) as target: + for fig in [source,target]: + ids=list(fig.subplots);fig.subplots[ids[0]].axes.scatter([1,2],[3,4],c=[10,20],id='mapped') + ax=fig.subplots[ids[1]].axes;ax.plot([1,2],[2,3]);twin=ax.twinx(id='secondary');twin.plot([1,2],[100,200]);twin.set_ylabel('Pressure') + st=source.get_settings();st['config']['colorbar']['axis']['title_option']['text']['segments']=fg.text_segments('Energy') + st['config']['colorbar']['axis']['min']=1;st['config']['colorbar']['axis']['max']=25 + st['config']['overlays']=[] + source.apply_settings(st);token=source.extract_style(scales=True,text=True) + original=target.get_settings();target.apply_style(token) + assert target.get_settings()['config']['colorbar']['axis']['max']==20 + target.apply_style(token,scales=True,text={'axis_titles'}) + result=target.get_settings();assert result['config']['colorbar']['axis']['max']==25 + assert result['config']['colorbar']['axis']['title_option']['text']['segments']==st['config']['colorbar']['axis']['title_option']['text']['segments'] + assert result['identity']['figure']==original['identity']['figure'] + assert [p['id'] for p in result['identity']['panels']]==['a','b'] + + +def test_token_validation_preset_independence_and_single_undo(): + a=fg.styles.preset('paper');b=fg.styles.preset('paper');a.appearance['palette'][0]['a']=.1 + assert b.appearance['palette'][0]['a']==1 + with pytest.raises(ValueError,match='version'):fg.StyleToken.from_dict(dict(version=2,appearance={})) + with pytest.raises(ValueError):fg.StyleToken(dict(panel=dict(chart_title=dict(text=dict(segments=[]))))) + with fg.Figure() as fig: + populate(fig,'line');fig.mark_edit_baseline();before=fig.get_settings() + fig.apply_style('dark');fig.undo() + assert fig.get_settings()==before + + +def test_notebook_extraction_waits_for_barrier(): + import asyncio + with fg.Figure(backend='webgpu') as fig: + populate(fig,'line');calls=[] + async def sync(**kwargs): + calls.append(kwargs);fig.axes[0].set_title('Accepted after barrier') + fig.sync=sync + token=asyncio.run(fig.extract_style_async(text=True)) + assert calls==[dict(timeout=30)] + assert ''.join(s['text'] for s in token.text['panels'][0]['titles'])=='Accepted after barrier' + + +@pytest.mark.parametrize('backend',BACKENDS) +def test_layout_topology_mapping_preserves_physical_plot_margins(backend): + with fg.Figure(backend=backend,grid=[['left','right']]) as source,fg.Figure(backend=backend,grid=[['a'],['b']],figsize=(12,8)) as target: + for fig in [source,target]: + for p in fig.subplots.values():p.axes.plot([1,2],[3,4]) + source.edit_property('panel/left','Plot area/bounds',[60.,50.,300,300]);state=source.get_settings() + t=source.extract_style();before=target.shared_axes + target.apply_style(t,layout=True,panel_map={0:'b',1:'a'}) + result=target.get_settings();layout=result['identity']['layout'] + assert layout['placement']=={'slot_0':'b','slot_1':'a'} + assert result['config']['chart_area']['x']>result['config']['subplots'][0]['config']['chart_area']['x'] + assert result['config']['subplots'][0]['config']['left_y']['out_margin']==pytest.approx(state['config']['left_y']['out_margin']) + assert layout['gap']==pytest.approx(12) + assert target.shared_axes==before + + +@pytest.mark.parametrize('backend',BACKENDS) +def test_shared_scale_conflict_and_incompatible_style_fail_before_commit(backend): + with fg.Figure(backend=backend,grid=[['l','r']]) as source,fg.Figure(backend=backend,grid=[['a','b']]) as target: + for fig in [source,target]: + for i,p in enumerate(fig.subplots.values()):p.axes.plot([1+10*i,2+10*i],[1,2]) + target.share_axes('x',['a','b']);before=target.get_settings() + with pytest.raises(ValueError,match='shared-axis'):target.apply_style(source.extract_style(scales=True),scales=True) + assert target.get_settings()==before + token=fg.StyleToken(dict(colormap='Magma')) + with pytest.raises(ValueError,match='incompatible'):target.apply_style(token,strict=True) + assert target.get_settings()==before + + +@pytest.mark.parametrize('backend',BACKENDS) +def test_series_mapping_does_not_use_source_ids_or_reconnect_columns(backend): + with fg.Figure(backend=backend) as source,fg.Figure(backend=backend) as target: + source.axes[0].plot([1,2],[3,4],color='red',id='src-red',label='Red') + source.axes[0].plot([1,2],[4,3],color='blue',id='src-blue',label='Blue') + target.axes[0].plot([10,20],[30,40],id='a',label='A') + target.axes[0].plot([10,20],[40,30],id='b',label='B') + token=source.extract_style(text=True);target.apply_style(token,series_map={0:'b',1:'a'},text={'legend_labels'}) + series=target.get_settings()['series'] + assert [s['series_id'] for s in series]==['a','b'] + assert [s['x_column'] for s in series]==['a/x','b/x'] + assert series[1]['render_type']['Line']['line']['line_color']['r']==1 + assert ''.join(c['text'] for c in series[1]['label']['segments'])=='Red' + + +@pytest.mark.parametrize('backend',BACKENDS) +def test_annotations_keep_distinct_styles_and_destination_geometry_and_text(backend): + with fg.Figure(backend=backend) as source,fg.Figure(backend=backend) as target: + for fig in [source,target]:fig.axes[0].plot([1,2],[3,4]) + source.axes[0].text(.1,.2,'source one',coords='ratio',color='red',fontsize=8) + source.axes[0].text(.4,.5,'source two',coords='ratio',color='blue',fontsize=16) + target.axes[0].text(.2,.3,'keep one',coords='ratio',id='one') + target.axes[0].text(.6,.7,'keep two',coords='ratio',id='two') + before=copy.deepcopy(target.get_settings()['config']['annotations']['items']) + target.apply_style(source.extract_style()) + items=target.get_settings()['config']['annotations']['items'] + assert [v['id'] for v in items]==['one','two'] + for a,b in zip(items,before): + assert a['kind']['Text']['anchor']==b['kind']['Text']['anchor'] + assert a['kind']['Text']['text']['segments']==b['kind']['Text']['text']['segments'] + assert items[1]['kind']['Text']['text']['font_size']==pytest.approx(2*items[0]['kind']['Text']['text']['font_size']) + + +def test_native_and_notebook_accept_the_same_style_document(): + states=[] + for backend in BACKENDS: + with fg.Figure(backend=backend,id='same') as fig: + populate(fig,'line');fig.apply_style('dark');states.append(fig.get_settings()) + assert states[0]==states[1] + + +@pytest.mark.parametrize('mode',['sketch','milkyway','constellation']) +def test_extracted_draw_styles_replace_mode_without_importing_plot_kind(mode): + with fg.Figure() as source,fg.Figure() as target: + + if mode=='milkyway': + source.axes[0].plot([1,2],[3,4]);target.axes[0].plot([10,20],[30,40]) + else:populate(source,'line');populate(target,'line') + source.set_draw_style(mode);target.set_draw_style('sketch') + target.apply_style(source.extract_style()) + assert target.get_settings()['config']['draw_style']==source.get_settings()['config']['draw_style'] + target.render_rgba() + + +def test_default_white_precise_extraction_replaces_dark_sketch_appearance(): + with fg.Figure() as source,fg.Figure() as target: + populate(source,'line');populate(target,'line') + target.apply_style('dark');target.set_draw_style('sketch') + target.apply_style(source.extract_style()) + state=target.get_settings() + assert state.get('background',dict(r=1,g=1,b=1,a=1))==dict(r=1,g=1,b=1,a=1) + assert state['config'].get('draw_style',dict(mode='precise'))['mode']=='precise' + + +@pytest.mark.parametrize('kind',['line','bar','pie']) +def test_common_typography_override_is_effective_after_extraction(kind): + with fg.Figure(dpi=144) as fig: + populate(fig,kind);t=fig.extract_style() + t.appearance['typography']=dict(font_size=15,title_size=23,color='#551188') + fig.apply_style(t) + s=fig.get_settings() + assert s['config']['chart_title']['text']['font_size']==46 + if kind!='line': + chart=s['categorical' if kind=='bar' else 'radial'] + assert chart['font_size']==30 + assert chart['chart_title']['text']['font_size']==46 + else:assert s['config']['left_y']['label_style']['font_size']==30 + + +@pytest.mark.parametrize('backend',BACKENDS) +def test_colormap_recolors_mapped_data_and_keeps_normalization_and_columns(backend): + with fg.Figure(backend=backend) as fig: + populate(fig,'mapped');before=fig.get_settings() + if backend=='native':image=fig.render_rgba() + token=fg.StyleToken(dict(colormap='magma')) + fig.apply_style(token,strict=True);after=fig.get_settings() + assert after['config']['colorbar']['colormap']=='Magma' + assert after['config']['colorbar']['axis']==before['config']['colorbar']['axis'] + assert after['series']==before['series'] + if backend=='native': + rendered=fig.render_rgba() + x,y,w,h=map(round,json.loads(fig._native.bounds_json('data_area'))['rect']) + # The plot interior must recolor as well as the bar outside it. + assert not np.array_equal(image[y:y+h,x:x+w],rendered[y:y+h,x:x+w]) + + +def test_invalid_renderer_style_combination_is_atomic(): + with fg.Figure() as source,fg.Figure() as target: + source.axes[0].plot([1,2],[3,4]);source.set_draw_style('milkyway') + populate(target,'line');before=target.get_settings() + # Marker lines cannot use this renderer material. Do not remove markers + # or convert the chart type merely to make a style compatible. + with pytest.warns(fg.StyleWarning,match='no ScatterLine'): + with pytest.raises(ValueError,match='line-only'):target.apply_style(source.extract_style()) + assert target.get_settings()==before + + +def test_renderer_font_limit_is_reported_without_partial_application(): + with fg.Figure(dpi=144) as fig: + populate(fig,'bar');before=fig.get_settings() + token=fg.StyleToken(dict(typography=dict(font_size=17))) + with pytest.raises(ValueError,match='8..32 logical pixels'):fig.apply_style(token) + assert fig.get_settings()==before + + +def test_captured_implicit_auto_ticks_stay_auto_when_imported(): + with fg.Figure() as source,fg.Figure() as target: + populate(source,'line');populate(target,'line') + token=source.extract_style(scales=True) + assert token.scales['panels'][0]['axes']['bottom_x']['tick_mode']=='Auto' + target.apply_scales(token.scales) + assert target.get_settings()['identity']['tick_modes']['main']['bottom_x']=='Auto' + source.configure(config={'bottom_x':{'major_spacing':.25}}) + target.apply_scales(source.extract_style(scales=True).scales) + assert target.get_settings()['identity']['tick_modes']['main']['bottom_x']=='Fixed' + + +def test_preset_base_font_adjusts_all_roles_with_relative_hierarchy(): + with fg.Figure(dpi=144) as fig: + populate(fig,'line');token=fg.styles.preset('paper') + token.appearance['typography']['font_size']=15 + fig.apply_style(token) + cfg=fig.get_settings()['config'] + assert cfg['left_y']['label_style']['font_size']==30 + assert cfg['chart_title']['text']['font_size']==pytest.approx(30*14/11) + assert cfg['legend']['content']['font_size']==pytest.approx(30*10/11) diff --git a/tests/test_typing_desktop.py b/tests/test_typing_desktop.py new file mode 100644 index 0000000..bb22c17 --- /dev/null +++ b/tests/test_typing_desktop.py @@ -0,0 +1,127 @@ +"""Actual key input must preserve live text, recovery and unrelated chart state.""" +import copy +import json +import os +import shutil + +import pytest + +from figgyplot.review import Session +from native_input import set_clipboard +from test_radial_desktop import Inputs + + +def assert_rich_text(text, expected): + # The renderer's Unicode parser represents U+00B2 as a styled "2". + segments = text['segments'] + assert ''.join(s['text'] for s in segments) == expected.replace('²','2') + if expected.endswith('²'): + assert segments[-1]['text'] == '2' and segments[-1]['superscript'] + assert not segments[-1]['subscript'] + + +@pytest.mark.skipif(not os.environ.get('DISPLAY') or not all(shutil.which(x) for x in ('xdotool','xclip','wmctrl')), reason='requires real X11 input') +@pytest.mark.parametrize('mode', ['inline', 'inspector', 'review', 'target']) +@pytest.mark.parametrize('with_image',[False,True]) +def test_native_typing_does_not_rebuild_unrelated_chart_work(tmp_path, monkeypatch, mode, with_image): + monkeypatch.setenv('FIGGY_PROFILE_HOST', '1') + with Session(cache_dir=tmp_path/'review', backend='native') as session: + fig = session.figure(id='plot') + ax = fig.axes[0] + ax.plot([0,1,2], [0,2,1], id='signal') + ax.text(.2,.4,'Original',coords='ratio',id='note') + ax.text(.7,.7,'Keep this',coords='ratio',id='fixed') + session.present() + handle = session._handles['plot'] + inputs = Inputs(handle) + try: + if with_image: + import subprocess + from PIL import Image + image=tmp_path/'reference.png';Image.new('RGB',(96,64),'#E69F00').save(image) + subprocess.run(['xclip','-selection','clipboard','-t','image/png','-i',str(image)],check=True, + stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=10) + inputs.click('review_comments');inputs.key('ctrl+v');session.poll() + assert len(session._review_state('plot')['attachments'])==1 + # Keep the inspector open even for comment/inline drafts: typing + # there must not reconstruct an unchanged document's object tree. + inputs.click('properties') + rect = json.loads(fig._native.bounds_json('annotation:note'))['rect'] + panel = inputs.info()['chart_panel'] + x = panel[0]+(rect[0]+rect[2]/2)*panel[2]/960 + y = panel[1]+(rect[1]+rect[3]/2)*panel[3]/600 + inputs.input_frame('mousemove','--window',inputs.wid,round(x),round(y)) + if mode == 'review': + inputs.click('review_comments') + elif mode == 'target': + inputs.input_frame('click',3) + key = next(k for k in inputs.info()['controls'] if k.startswith('review_target:')) + inputs.click(key) + inputs.click('review_target_text') + elif mode == 'inline': + inputs.input_frame('click','--repeat',2,'--delay',100,1) + assert 'inline_text' in inputs.info()['controls'] + else: + inputs.input_frame('click',1) + inputs.click('annotation_text') + inputs.key('ctrl+a') + before = fig.get_settings() + work = fig.info['host_work'] + builds = inputs.info()['property_catalog_builds'] + uploads = fig.info['column_uploads'] + typed = '' + for char in 'Typing': + inputs.input_frame('type','--clearmodifiers','--delay',0,char) + typed += char + if mode == 'inspector': + item = fig.get_settings()['config']['annotations']['items'][0] + assert_rich_text(item['kind']['Text']['text'], typed) + else: + assert fig.get_settings() == before + set_clipboard(' 한글 α²') + inputs.key('ctrl+v') + typed += ' 한글 α²' + after_work = fig.info['host_work'] + for stage in ('annotation_view','native_axis_refresh','native_styles','apply_settings'): + assert after_work.get(stage,{}).get('count',0) == work.get(stage,{}).get('count',0), (stage,work,after_work) + if mode != 'inspector': + assert after_work.get('native_navigation',{}).get('count',0) == work.get('native_navigation',{}).get('count',0) + assert inputs.info()['property_catalog_builds'] == builds + else: + assert inputs.info()['property_catalog_builds']-builds <= len('Typing')+1 + assert fig.info['column_uploads'] == uploads + if mode in ('inline','inspector'): + inputs.key('Return') + after = fig.get_settings() + text = after['config']['annotations']['items'][0]['kind']['Text']['text'] + assert_rich_text(text, typed) + expected = copy.deepcopy(before) + expected['config']['annotations']['items'][0]['kind']['Text']['text']['segments'] = text['segments'] + assert after == expected + elif mode == 'target': + inputs.click('review_target_save') + assert json.loads(handle.review_draft_json())['target_comments'][0]['text'] == typed + else: + assert json.loads(handle.review_draft_json())['comments'] == typed + # UI comments and accepted SSOT remain durably recoverable after + # polling; no debouncing/dropping of characters is used as a fix. + session.poll() + restored = Session.open(session.cache_dir) + doc = restored.snapshot()['rounds'][-1]['documents']['plot'] + if mode in ('inline','inspector'): + assert doc['draft'] == fig.get_settings() + inputs.click('undo') + old = fig.get_settings()['config']['annotations']['items'][0] + assert_rich_text(old['kind']['Text']['text'], 'Original') + inputs.click('redo') + new = fig.get_settings()['config']['annotations']['items'][0] + assert_rich_text(new['kind']['Text']['text'], typed) + else: + channel = 'ui_target' if mode == 'target' else 'ui' + assert any(c['text'] == typed and c['channel'] == channel for c in doc['comments']) + assert fig.get_settings() == before + assert not inputs.info()['error'] + (tmp_path/'typing-work.json').write_text(json.dumps(dict(before=work,after=after_work),indent=2)) + finally: + session.close() + Inputs.pump() diff --git a/tests/test_wrapper_edit_paths.py b/tests/test_wrapper_edit_paths.py new file mode 100644 index 0000000..f1d3f4e --- /dev/null +++ b/tests/test_wrapper_edit_paths.py @@ -0,0 +1,280 @@ +"""Shared edit contracts and bounded work, independent of wall-clock CI speed.""" + +import copy + +import pytest + +import figgyplot as fg +from figgyplot import _document, _matplotlib, _native + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +@pytest.mark.parametrize("side", ["left", "right", "top", "bottom"]) +def test_colorbar_ticks_edit_only_the_scale_without_a_temporary_figure( + monkeypatch, backend, side +): + with fg.Figure(backend=backend) as fig: + points = fig.axes[0].scatter([0, 1], [2, 3], c=[10, 20]) + bar = fig.colorbar(points) + bar.set_layout(side=side) + before = fig.get_settings() + + def forbidden(*args, **kwargs): + pytest.fail( + "a normal axis edit must not instantiate a code-generation Figure" + ) + + monkeypatch.setattr(_matplotlib, "_StateFigure", forbidden) + bar.ax.tick_params( + which="both", direction="inout", length=6, labelsize=14, labelcolor="red" + ) + after = fig.get_settings() + axis = after["config"]["colorbar"]["axis"] + assert axis["tick"] == "Both" + assert axis["major_tick_length"] == pytest.approx(6 * fig.dpi / 72) + assert axis["minor_tick_length"] == pytest.approx(6 * fig.dpi / 72) + assert axis["label_style"]["font_size"] == pytest.approx(14 * fig.dpi / 72) + assert axis["label_style"]["color"] == dict(r=1, g=0, b=0, a=1) + after["config"]["colorbar"] = before["config"]["colorbar"] + assert after == before + bar.ax.tick_params(reset=True) + reset = fig.get_settings()["config"]["colorbar"]["axis"] + default = fig._default_config[ + "right_y" if side in ("left", "right") else "bottom_x" + ] + for key in ("tick", "major_tick_length", "minor_tick_length", "label_style"): + assert reset[key] == default[key] + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +@pytest.mark.parametrize( + "options", + [ + dict(grid_color="red"), + dict(grid_alpha=0.5), + dict(grid_linewidth=2), + dict(grid_linestyle="--"), + ], +) +def test_colorbar_rejects_grid_edits_instead_of_silently_discarding_them( + backend, options +): + with fg.Figure(backend=backend) as fig: + bar = fig.axes[0].add_colorbar() + before, revision = fig.get_settings(), fig.revision + with pytest.raises(NotImplementedError, match="no grid"): + bar.ax.tick_params(labelsize=22, **options) + assert fig.get_settings() == before + assert fig.revision == revision + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_scale_edits_fit_once_for_the_complete_domain_and_skip_palette_only( + monkeypatch, backend +): + with fg.Figure(backend=backend) as fig: + ax = fig.axes[0] + points = ax.scatter([0, 1], [2, 3], c=[0, 1]) + bar = fig.colorbar(points, label="Concentration") + measure = _native.fitted_colorbar_title_margin + calls = [] + + def recorded(config): + import json + + scale = json.loads(config)["colorbar"] + calls.append( + (scale["axis"]["min"], scale["axis"]["max"], scale["colormap"]) + ) + return measure(config) + + monkeypatch.setattr(_native, "fitted_colorbar_title_margin", recorded) + fig.update(points.id, cmap="gray", vmin=10, vmax=20) + assert len(calls) == 1 + assert calls[0][:2] == (10, 20) + assert points.get_clim() == (10, 20) + calls.clear() + points.set_cmap("viridis") + bar.set_cmap("gray") + assert calls == [] + before, revision = fig.get_settings(), fig.revision + with pytest.raises(ValueError, match="increasing"): + fig.update(points.id, cmap="viridis", vmin=30, vmax=20) + assert fig.get_settings() == before and fig.revision == revision + assert calls == [] + # Initialization of an existing scale must also use its accepted label. + ax.add_colorbar(cmap="gray", vmin=100, vmax=200) + assert len(calls) == 1 and calls[0][:2] == (100, 200) + assert bar.ax.yaxis.label.get_text() == "Concentration" + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +@pytest.mark.parametrize("overlay", [False, True]) +def test_mappable_paths_resolve_the_same_panel_and_preserve_other_panels( + backend, overlay +): + with fg.Figure(backend=backend, grid=[["first", "second"]]) as fig: + first = fig.subplots["first"].axes + owner = fig.subplots["second"].axes + ax = owner.twinx(id="extra") if overlay else owner + first.scatter([1, 2], [3, 4], c=[50, 100], id="other") + points = ax.scatter([1, 2], [3, 4], c=[1, 2], id="target") + original = first._config(fig.get_settings())["colorbar"] + bar = fig.colorbar(points, ax=owner, label="Target") + points.set_clim(5, 9) + bar.set_cmap("gray") + assert points.get_clim() == (5, 9) + assert ( + points.get_cmap() + == owner._config(fig.get_settings())["colorbar"]["colormap"] + ) + points.markers.map_color(points.data.x, clim=(0, 10), cmap="viridis") + assert points.get_clim() == (0, 10) + assert first._config(fig.get_settings())["colorbar"] == original + before = fig.get_settings() + with pytest.raises(ValueError, match="mappable panel"): + fig.colorbar(points, ax=first) + assert fig.get_settings() == before + + +@pytest.mark.parametrize("backend", ["native", "webgpu"]) +def test_reduced_copies_preserve_snapshot_isolation_and_nested_rollback(backend): + with fg.Figure(backend=backend) as fig: + fig.axes[0].plot([1, 2], [3, 4], id="data") + original = fig.get_settings() + candidate = copy.deepcopy(original) + with fig.batch_update(): + candidate["config"]["chart_title"]["visible"] = False + fig.apply_settings(candidate) + candidate["config"]["chart_title"]["visible"] = True + assert not fig.get_settings()["config"]["chart_title"]["visible"] + accepted = fig.get_settings() + with pytest.raises(ValueError): + with fig.batch_update(): + fig.axes[0].set_title("Rolled back") + broken = fig.get_settings() + broken["identity"]["panels"].append(dict(id="invalid", overlays=[])) + fig.apply_settings(broken) + assert fig.get_settings() == accepted + detached = fig.get_settings() + detached["series"].clear() + assert fig.get_settings()["series"] == original["series"] + assert fig.get_settings() == accepted + revision = fig.revision + with pytest.raises(RuntimeError): + with fig.batch_update(): + fig.axes[0].set_title("Also rolled back") + raise RuntimeError("cancel outer transaction") + assert fig.get_settings() == accepted and fig.revision == revision + fig.apply_settings(original, reset=True) + original["series"].clear() + assert len(fig.get_settings()["series"]) == 1 + + +@pytest.mark.parametrize("panels", [1, 16]) +def test_colorbar_edit_snapshot_work_does_not_grow_with_panel_count( + monkeypatch, panels +): + with fg.Figure( + backend="webgpu", + figsize=(panels * 3, 4), + grid=[[str(i) for i in range(panels)]], + ) as fig: + ax = fig.subplots[str(panels - 1)].axes + points = ax.scatter([1, 2], [3, 4], c=[1, 2]) + bar = fig.colorbar(points) + get = fig.get_settings + calls = [] + + def snapshot(): + calls.append(None) + return get() + + monkeypatch.setattr(fig, "get_settings", snapshot) + bar.set_label("Value", fontsize=18) + assert len(calls) <= 2 + calls.clear() + fig.colorbar(points) + assert len(calls) <= 4 + calls.clear() + fig.update(points.id, cmap="gray", vmin=10, vmax=20) + assert len(calls) <= 2 + + +class CountedList(list): + def __init__(self, values, counter): + super().__init__(values) + self.counter = counter + + def __iter__(self): + for value in super().__iter__(): + self.counter[0] += 1 + yield value + + +def test_linked_groups_visit_members_linearly_and_keep_dimensions_separate(): + n, work = 500, [0] + groups = { + str(i): dict(axis="x", members=CountedList([str(i), str(i + 1)], work)) + for i in reversed(range(n)) + } + groups["cycle"] = dict(axis="x", members=["20", "21", "22"]) + groups["y"] = dict(axis="y", members=["0", "y-only"]) + meta = dict(groups=groups) + assert _document.tick_owners(meta, "0", "bottom_x") == { + str(i) for i in range(n + 1) + } + assert work[0] <= 4 * n + assert _document.tick_owners(meta, "0", "left_y") == {"0", "y-only"} + assert _document.tick_owners(meta, "0", "top_x") == {"0"} + assert _document.tick_owners(meta, "isolated", "bottom_x") == {"isolated"} + + +def test_grid_placement_sums_weights_once_and_resolves_panels_once(): + n, weights_work, panels_work = 300, [0], [0] + state = dict( + config=dict(subplots=[dict(config={}) for _ in range(n - 1)]), + identity=dict( + panels=CountedList([dict(id=str(i)) for i in range(n)], panels_work), + layout=dict( + grid=[[str(i) for i in range(n)]], + placement={str(i): str(i) for i in range(n)}, + row_weights=[1], + column_weights=CountedList([1] * n, weights_work), + gap=0, + size=[100 * n, 200], + ), + ), + ) + _document.place_grid(state) + assert weights_work[0] <= 2 * n + assert panels_work[0] <= n + assert state["config"]["chart_area"] == dict(x=0, y=0, width=100, height=200) + for index, panel in enumerate(state["config"]["subplots"], start=1): + assert panel["config"]["chart_area"] == dict( + x=100 * index, y=0, width=100, height=200 + ) + + +def test_tick_options_convert_once_and_do_not_alias_per_axis_colors(monkeypatch): + import importlib + + module = importlib.import_module("figgyplot.figure") + with fg.Figure(backend="webgpu") as fig: + conversions = [] + color = module._color + + def counted(value): + conversions.append(value) + return color(value) + + monkeypatch.setattr(module, "_color", counted) + fig.axes[0].tick_params( + axis="both", which="both", labelcolor="red", grid_color="blue" + ) + assert conversions == ["red", "blue"] + state = fig.get_settings() + state["config"]["bottom_x"]["label_style"]["color"]["a"] = 0.2 + state["config"]["grid"]["major_x_color"]["a"] = 0.3 + assert state["config"]["top_x"]["label_style"]["color"]["a"] == 1 + assert state["config"]["grid"]["minor_x_color"]["a"] == 1