From 5bf952a1ec4f61c03babfa7bc22612db406d149f Mon Sep 17 00:00:00 2001 From: youo he Date: Thu, 22 Oct 2026 09:45:16 +0000 Subject: [PATCH 01/23] Improve layout editing and review feedback interactions --- .github/workflows/pr-tests.yml | 4 +- docs/DESKTOP_EDITOR.md | 31 +++ docs/EDITOR_CONTROLS.csv | 13 ++ docs/HOST_VALIDATION.md | 29 +++ docs/REVIEW_MODE.md | 35 ++- docs/images/layout-plot-controls.png | Bin 0 -> 64994 bytes docs/images/review-element-comment-cjk.png | Bin 0 -> 74388 bytes .../layout-review-host-work-20261022.json | 33 +++ examples/review_workflow.py | 7 +- python/figgyplot/_review_font.py | 30 +++ python/figgyplot/editor.css | 10 + .../figgyplot/review-feedback-v1.schema.json | 3 +- python/figgyplot/review.py | 62 ++++- .../skills/figgyplot-review/SKILL.md | 46 +++- python/figgyplot/webgpu.js | 80 ++++++- python/figgyplot/widget.py | 7 +- src/bounded_host.rs | 20 +- src/desktop.rs | 76 +++++- src/editor.rs | 83 +++++-- src/editor/review.rs | 191 +++++++++++++++ src/lib.rs | 7 + src/navigation.rs | 26 ++- src/property_catalog.rs | 74 +++++- src/property_catalog/layout.rs | 179 ++++++++++++++ src/radial_editor.rs | 48 +++- src/ui_fonts.rs | 38 +++ tests/test_interaction_parity.py | 219 +++++++++++++++++- tests/test_layout_review_extensions.py | 142 ++++++++++++ 28 files changed, 1419 insertions(+), 74 deletions(-) create mode 100644 docs/images/layout-plot-controls.png create mode 100644 docs/images/review-element-comment-cjk.png create mode 100644 docs/measurements/layout-review-host-work-20261022.json create mode 100644 python/figgyplot/_review_font.py create mode 100644 src/editor/review.rs create mode 100644 src/property_catalog/layout.rs create mode 100644 tests/test_layout_review_extensions.py diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 059e365..32a6910 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -72,7 +72,7 @@ jobs: - 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 + sudo apt-get install -y git mesa-vulkan-drivers libvulkan1 libxkbcommon0 libxkbcommon-x11-0 xvfb xdotool xclip openbox wmctrl fonts-noto-cjk - name: Build and install the exact PR wheel run: | cargo fmt --check @@ -136,7 +136,7 @@ jobs: - 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 + sudo apt-get install -y git mesa-vulkan-drivers libvulkan1 libxkbcommon0 libxkbcommon-x11-0 xvfb xdotool xclip openbox wmctrl fonts-noto-cjk - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: pr-linux-wheel 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/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..9a54dc9 100644 --- a/docs/HOST_VALIDATION.md +++ b/docs/HOST_VALIDATION.md @@ -7,6 +7,35 @@ and `figgy-model 0.12.0`. 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. +### Layout and review editing follow-up (2026-10-22) + +The installed release wheel passed **273 focused model, history, recovery and +oracle cases**, **9 real native/Jupyter interaction cases**, and **131 workflow +and packaged-asset checks**, with no skips in those runs. Full collection contains +806 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. + 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) diff --git a/docs/REVIEW_MODE.md b/docs/REVIEW_MODE.md index 7b28651..f371cb9 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. @@ -103,7 +103,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 +116,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 +134,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/images/layout-plot-controls.png b/docs/images/layout-plot-controls.png new file mode 100644 index 0000000000000000000000000000000000000000..92a14ffc580215d2c3852949bd76a096777090b0 GIT binary patch literal 64994 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 literal 0 HcmV?d00001 diff --git a/docs/images/review-element-comment-cjk.png b/docs/images/review-element-comment-cjk.png new file mode 100644 index 0000000000000000000000000000000000000000..6471caeb84beb7ba6b9a09216755386f77680d76 GIT binary patch literal 74388 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 literal 0 HcmV?d00001 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..53b9cde --- /dev/null +++ b/docs/measurements/layout-review-host-work-20261022.json @@ -0,0 +1,33 @@ +{ + "native": { + "moves": 6, + "inspector_rebuilds_during": 0, + "elapsed": 1.2204828760004602, + "last_input_to_submit_ms": 15.535155999999999, + "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": "84296781d0bd93138dd16dc49d7c28197a594cffa72c832e97cfa01bf9f50093", + "after_ssot_sha256": "5398e4f6fa6e051046a0bdd4f10286d22c5dbc55cbf11aa74d7f33c5f791e3ab" + }, + "browser": { + "moves": 8, + "inspector_rebuilds_during": 1, + "elapsed": 0.6332177699950989, + "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": "6679b21391077989e0a58527a1f35a2ff3dbe26363072eb47fbe584f8d2cb4a5", + "after_ssot_sha256": "fd637029ca65df1996a3f2d6962615d3ceff14fb37d076b28855cbf74ba55bad" + }, + "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": "260aa58a2ade208ca26fed322b7fe955dcf52f25645a4df5d84232aa2b538e55" +} 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/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/editor.css b/python/figgyplot/editor.css index 065096b..8510e0a 100644 --- a/python/figgyplot/editor.css +++ b/python/figgyplot/editor.css @@ -99,3 +99,13 @@ .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;} diff --git a/python/figgyplot/review-feedback-v1.schema.json b/python/figgyplot/review-feedback-v1.schema.json index 41445e2..f4c6b55 100644 --- a/python/figgyplot/review-feedback-v1.schema.json +++ b/python/figgyplot/review-feedback-v1.schema.json @@ -95,7 +95,8 @@ "channel": { "enum": [ "api", - "ui" + "ui", + "ui_target" ] }, "text": { diff --git a/python/figgyplot/review.py b/python/figgyplot/review.py index 48eaa48..28d8b3f 100644 --- a/python/figgyplot/review.py +++ b/python/figgyplot/review.py @@ -108,7 +108,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 +130,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) @@ -264,6 +270,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 +286,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,10 +304,45 @@ 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) + 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), + 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'} or not all(isinstance(item.get(k),str) and item[k].strip() for k in ('id','text','target')) 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) + updated=[c for c in doc['comments'] if c.get('channel')!='ui_target']+[dict(c,channel='ui_target') for c in comments] + 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 _comment_text(self, figure_id): return _ui_comment(self.snapshot()['rounds'][-1]['documents'][figure_id]) @@ -489,6 +534,7 @@ def poll(self): request=json.loads(handle.review_request_json()) if request is None:continue try: + self._set_target_comments(fid,request.get('target_comments',[])) self.submit(fid,request['disposition'],comments=request['comments'],expected_revision=request['revision']) except Exception as exc: handle.review_result(str(exc)) @@ -506,6 +552,10 @@ def poll(self): 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','') + targeted=json.loads(handle.info_json())['review'].get('target_comments',[]) + previous=[v for v in doc['comments'] if v.get('channel')=='ui_target'] + if targeted!=previous: + doc['comments']=[v for v in doc['comments'] if v.get('channel')!='ui_target']+[dict(v,channel='ui_target') for v in targeted];changed=True if text!=_ui_comment(doc): _replace_ui_comment(doc,text);changed=True if changed:self._store.write(state) @@ -607,13 +657,14 @@ 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','feedback','claim','regenerate','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,default=180,help='Maximum subscription wait or candidate execution/render seconds') args=parser.parse_args() package=Path(__file__).parent if args.action=='protocol': @@ -624,6 +675,7 @@ def main(): with Session.open(args.cache) as session: if args.action=='status':result=session.status elif args.action=='events':result=session.events() + elif args.action=='subscribe':result=session.subscribe(after=args.after,timeout=args.timeout) elif args.action=='feedback':result=session.feedback(args.work) elif args.action=='claim':result=session.claim(args.work,worker_id=args.worker) elif args.action=='regenerate': diff --git a/python/figgyplot/skills/figgyplot-review/SKILL.md b/python/figgyplot/skills/figgyplot-review/SKILL.md index ca9bf1d..30c0314 100644 --- a/python/figgyplot/skills/figgyplot-review/SKILL.md +++ b/python/figgyplot/skills/figgyplot-review/SKILL.md @@ -11,30 +11,49 @@ 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. -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 +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. Obtain the session cache path from the controller. Keep the controller running + on its rendering host and 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. 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 + 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`. +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, +6. 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 +63,10 @@ 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 the complete next round. 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. diff --git a/python/figgyplot/webgpu.js b/python/figgyplot/webgpu.js index 2215406..c99b6ee 100644 --- a/python/figgyplot/webgpu.js +++ b/python/figgyplot/webgpu.js @@ -59,11 +59,32 @@ export function render({model,el}) { 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=''; + 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;} + if(state.enabled&&!reviewLoaded){reviewComment.value=state.comments??'';reviewTargets=clone(state.target_comments??[]);reviewLoaded=true;} + reviewComment.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){ @@ -74,7 +95,7 @@ export function render({model,el}) { 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,comments:reviewComment.value,target_comments:reviewTargets,revision}); }catch(e){reviewFailure(String(e));} } function reviewFailure(message){ @@ -86,7 +107,37 @@ export function render({model,el}) { const reviewMessage=message=>{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})); + function saveReviewDraft(){model.send({action:'review_comment',comments:reviewComment.value,target_comments:reviewTargets});} + 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(); + save.onclick=()=>{if(!input.value.trim())return;const item={id:existing?.id??crypto.randomUUID(),target,label,text:input.value,channel:'ui_target'};reviewTargets=reviewTargets.filter(v=>v.id!==item.id);reviewTargets.push(item);saveReviewDraft();reviewDialog.close();}; + reviewDialog.append(heading,input,save,cancel);reviewDialog.showModal();input.focus(); + } + const commentsButton=document.createElement('button');commentsButton.dataset.reviewTargetList='';commentsButton.textContent='Element comments';reviewPanel.querySelector('div').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();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();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 +203,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);} @@ -597,6 +648,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');} @@ -822,11 +875,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; @@ -1026,7 +1089,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 +1154,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 +1232,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();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..18d70a5 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 @@ -135,7 +136,10 @@ def _command(self, widget, content, buffers): if 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']) except Exception as exc:self.send(dict(action='review_result',error=str(exc))) elif action == "review_submit": try: @@ -147,6 +151,7 @@ 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._submit(self.figure.id, content['disposition'], content.get('comments'), content['revision']) except Exception as exc: diff --git a/src/bounded_host.rs b/src/bounded_host.rs index c9c3d73..d490fb6 100644 --- a/src/bounded_host.rs +++ b/src/bounded_host.rs @@ -160,7 +160,7 @@ impl SurfaceWindow { } else { !modifiers.ctrl || modifiers.shift }; - if delta != 0. && allowed { + if delta != 0. && allowed && view["axis"] != "radius" { let [low, high]: [f64; 2] = serde_json::from_value(view["range"].clone()).unwrap(); let area = surface @@ -207,6 +207,22 @@ impl SurfaceWindow { (p.y - image_rect.min.y) * logical_size.1 as f32 / image_rect.height(), ] }; + if self.editor.review_enabled() && ctx.input(|i|i.pointer.secondary_clicked()) && let Some(p)=ctx.input(|i|i.pointer.interact_pos()).filter(|p|image_rect.contains(*p)) { + let point=to_local(p); + let annotation=surface.annotations.annotations().hit_test(point).and_then(|id|surface.annotations.annotations().element(id)).map(|a|(renderer::LayerRef::Annotations,format!("annotation_{}",a.index))); + let targets=if let Some(hit)=annotation {vec![hit]} else { + let mut candidates=map.entries().filter_map(|(id,e)|map.bounds(id).filter(|r|point[0]>=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..6aa6865 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -349,7 +349,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 +624,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(()); @@ -997,6 +1007,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(()); @@ -1135,6 +1148,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 @@ -1512,7 +1584,7 @@ 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}); + *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(()) } } diff --git a/src/editor.rs b/src/editor.rs index 28a864d..56a2a92 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, @@ -36,6 +48,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(), @@ -71,6 +88,23 @@ 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 comments = state["comments"].as_str().unwrap_or("").to_owned(); egui::Panel::bottom("review_comments") @@ -81,7 +115,12 @@ impl Editor { .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 +129,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…"), @@ -110,7 +149,7 @@ impl Editor { if button.clicked() { c.end_edit(); state["request"] = json!({"disposition":disposition, - "comments":comments,"revision":c.data.revision,"finalized":false}); + "comments":comments,"target_comments":state["target_comments"],"revision":c.data.revision,"finalized":false}); state["error"] = json!(""); } } @@ -585,7 +624,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 +664,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,6 +712,7 @@ impl Editor { } }); }); + self.navigator.review_enabled = self.review_enabled(); let nodes = navigation::nodes(&c.snapshot()); if let Some(node) = self.navigator.show( ui, @@ -678,6 +732,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 +1181,11 @@ 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(), - ); + 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() @@ -1577,10 +1627,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..f639ed2 --- /dev/null +++ b/src/editor/review.rs @@ -0,0 +1,191 @@ +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, +} +impl Editor { + 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; + } + } + 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.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.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); + ui.horizontal(|ui|{ + let save=ui.add_enabled(!self.review_context.text.trim().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); + comments.push(json!({"id":id,"target":target,"label":label,"text":self.review_context.text,"channel":"ui_target"})); + 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()); + } + 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; + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 51ce634..5d63b80 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1910,6 +1910,7 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { 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)?)?; @@ -2026,3 +2027,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..cab6901 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, @@ -1054,8 +1055,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 +1089,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 +1102,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 +1175,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 +1274,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 +1431,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..022500f 100644 --- a/src/radial_editor.rs +++ b/src/radial_editor.rs @@ -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,14 @@ impl Editor { .unwrap_or_else(|| "data_area".into()) }) }; + if target == "data_area" || target == "figure" { + 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/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/test_interaction_parity.py b/tests/test_interaction_parity.py index afd2953..041bf14 100644 --- a/tests/test_interaction_parity.py +++ b/tests/test_interaction_parity.py @@ -129,6 +129,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 +1472,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 +1490,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'] @@ -2869,3 +2879,208 @@ 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) 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 + subprocess.run(['xclip','-selection','clipboard'],input=text,text=True,check=True) + 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') + subprocess.run(['xclip','-selection','clipboard'],input=text+'!',text=True,check=True) + 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+'!' + source="import figgyplot as fg\nfrom figgyplot.review import Session\nsession=Session(backend='webgpu',review_font="+repr(family)+")\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) + 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_annotation_drag_reuses_inspector_descriptions_until_release(tmp_path): + from time import perf_counter + measurement={} + 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)) + a=old['config']['annotations']['items'][0]['kind']['Arrow'] + b=new['config']['annotations']['items'][0]['kind']['Arrow'] + assert a['from']!=b['from'] and a['to']!=b['to'] + for key in ('from','to'): + 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') + fig.axes[0].arrow(.2,.2,.3,.2,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'] + inputs.input_frame('mousemove','--window',inputs.wid,*map(round,start));inputs.input_frame('mousedown',1) + 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) + 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() + source="import figgyplot as fg\nfig,ax=fg.subplots(backend='webgpu')\nax.plot([0,1],[2,3],id='one');ax.arrow(.2,.2,.3,.2,coords='ratio',id='arrow')\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()') + 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)) 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 From 879dda6b2105ffcde8df172173b86c1650752f25 Mon Sep 17 00:00:00 2001 From: youo he Date: Thu, 22 Oct 2026 10:10:08 +0000 Subject: [PATCH 02/23] Apply review text size consistently and avoid the slow CI package mirror --- .github/workflows/pr-tests.yml | 4 ++++ docs/HOST_VALIDATION.md | 8 +++++++ docs/images/review-element-comment-cjk.png | Bin 74388 -> 72316 bytes .../ci61-ubuntu-mirror-20261022.json | 21 ++++++++++++++++++ .../layout-review-host-work-20261022.json | 16 ++++++------- python/figgyplot/webgpu.js | 2 +- tests/test_interaction_parity.py | 5 +++-- 7 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 docs/measurements/ci61-ubuntu-mirror-20261022.json diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 32a6910..a6f0e14 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -71,6 +71,8 @@ jobs: components: rustfmt - name: Install native software-GPU and X11 runtime run: | + # Avoid the Azure mirror that served 105 MB at 120 kB/s in PR run 61. + sudo sed -i 's|azure\.archive\.ubuntu\.com|archive.ubuntu.com|g' /etc/apt/sources.list.d/ubuntu.sources sudo apt-get update sudo apt-get install -y git mesa-vulkan-drivers libvulkan1 libxkbcommon0 libxkbcommon-x11-0 xvfb xdotool xclip openbox wmctrl fonts-noto-cjk - name: Build and install the exact PR wheel @@ -135,6 +137,8 @@ jobs: - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 - name: Install native software-GPU and X11 runtime run: | + # Avoid the Azure mirror that served 105 MB at 120 kB/s in PR run 61. + sudo sed -i 's|azure\.archive\.ubuntu\.com|archive.ubuntu.com|g' /etc/apt/sources.list.d/ubuntu.sources sudo apt-get update sudo apt-get install -y git mesa-vulkan-drivers libvulkan1 libxkbcommon0 libxkbcommon-x11-0 xvfb xdotool xclip openbox wmctrl fonts-noto-cjk - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 diff --git a/docs/HOST_VALIDATION.md b/docs/HOST_VALIDATION.md index 9a54dc9..92538c9 100644 --- a/docs/HOST_VALIDATION.md +++ b/docs/HOST_VALIDATION.md @@ -36,6 +36,14 @@ 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. + 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) diff --git a/docs/images/review-element-comment-cjk.png b/docs/images/review-element-comment-cjk.png index 6471caeb84beb7ba6b9a09216755386f77680d76..830ed10accae59aae9b91eee024f1c4833d3bb60 100644 GIT binary patch literal 72316 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�I#^ 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 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/layout-review-host-work-20261022.json b/docs/measurements/layout-review-host-work-20261022.json index 53b9cde..402edf8 100644 --- a/docs/measurements/layout-review-host-work-20261022.json +++ b/docs/measurements/layout-review-host-work-20261022.json @@ -2,8 +2,8 @@ "native": { "moves": 6, "inspector_rebuilds_during": 0, - "elapsed": 1.2204828760004602, - "last_input_to_submit_ms": 15.535155999999999, + "elapsed": 1.1376548249972984, + "last_input_to_submit_ms": 17.884905999999997, "changed_ssot_paths": [ "/config/annotations/items/0/kind/Arrow/from/Ratio/x", "/config/annotations/items/0/kind/Arrow/from/Ratio/y", @@ -11,13 +11,13 @@ "/config/annotations/items/0/kind/Arrow/to/Ratio/y" ], "additional_column_uploads": 0, - "before_ssot_sha256": "84296781d0bd93138dd16dc49d7c28197a594cffa72c832e97cfa01bf9f50093", - "after_ssot_sha256": "5398e4f6fa6e051046a0bdd4f10286d22c5dbc55cbf11aa74d7f33c5f791e3ab" + "before_ssot_sha256": "5127bec2d9f505e1f25ce060b22424f368070422b098a083a416cc63cc80e165", + "after_ssot_sha256": "58fa155eb4932c6446bc86c8ebc72c879a614d2d0fe0c7538d434b80a372a2a0" }, "browser": { "moves": 8, "inspector_rebuilds_during": 1, - "elapsed": 0.6332177699950989, + "elapsed": 0.6138199410052039, "changed_ssot_paths": [ "/config/annotations/items/0/kind/Arrow/from/Ratio/x", "/config/annotations/items/0/kind/Arrow/from/Ratio/y", @@ -25,9 +25,9 @@ "/config/annotations/items/0/kind/Arrow/to/Ratio/y" ], "additional_column_uploads": 0, - "before_ssot_sha256": "6679b21391077989e0a58527a1f35a2ff3dbe26363072eb47fbe584f8d2cb4a5", - "after_ssot_sha256": "fd637029ca65df1996a3f2d6962615d3ceff14fb37d076b28855cbf74ba55bad" + "before_ssot_sha256": "9de4d043330443860b2f49c8fa17bff7c48bfc89f05d34d5693b727c7d105610", + "after_ssot_sha256": "b9b58cb9f23aca7ce0c86b9337bc7439858c74f692fa9c8ea93e4ec9962c9121" }, "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": "260aa58a2ade208ca26fed322b7fe955dcf52f25645a4df5d84232aa2b538e55" + "wheel_sha256": "aa645f288addec66ca41d043821ef7604214b0f5ed49c670b6fed90f78bfe733" } diff --git a/python/figgyplot/webgpu.js b/python/figgyplot/webgpu.js index c99b6ee..1576f23 100644 --- a/python/figgyplot/webgpu.js +++ b/python/figgyplot/webgpu.js @@ -77,7 +77,7 @@ export function render({model,el}) { 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??'';reviewTargets=clone(state.target_comments??[]);reviewLoaded=true;} - reviewComment.style.fontSize=`${state.font_size??14}px`; + 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'); diff --git a/tests/test_interaction_parity.py b/tests/test_interaction_parity.py index 041bf14..1cf0860 100644 --- a/tests/test_interaction_parity.py +++ b/tests/test_interaction_parity.py @@ -2960,7 +2960,7 @@ def test_element_review_comments_use_selected_font_and_preserve_ssot(tmp_path): 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) as session: + 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') @@ -3000,7 +3000,7 @@ def test_element_review_comments_use_selected_font_and_preserve_ssot(tmp_path): inputs.click('review_changes');session.poll() assert session.events() assert session.feedback(session.events()[0]['work_id'])['round']['documents']['plot']['comments'][0]['text']==text+'!' - source="import figgyplot as fg\nfrom figgyplot.review import Session\nsession=Session(backend='webgpu',review_font="+repr(family)+")\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))" + 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') !== ''") @@ -3009,6 +3009,7 @@ def test_element_review_comments_use_selected_font_and_preserve_ssot(tmp_path): 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 From bdb73e9a2d1763d9ac3ab5107248e8d96a9f7643 Mon Sep 17 00:00:00 2001 From: youo he Date: Thu, 22 Oct 2026 10:23:16 +0000 Subject: [PATCH 03/23] Isolate CI runtime packages from image vendor feeds and mirrorlists --- .github/workflows/pr-tests.yml | 12 +--- docs/HOST_VALIDATION.md | 15 +++- .../ci62-image-apt-sources-20261022.json | 18 +++++ scripts/ci-preflight.sh | 3 +- scripts/install_ci_runtime.sh | 26 +++++++ tests/test_ci_runtime.py | 70 +++++++++++++++++++ 6 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 docs/measurements/ci62-image-apt-sources-20261022.json create mode 100644 scripts/install_ci_runtime.sh create mode 100644 tests/test_ci_runtime.py diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index a6f0e14..9b69faa 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -70,11 +70,7 @@ jobs: toolchain: '1.99.0' components: rustfmt - name: Install native software-GPU and X11 runtime - run: | - # Avoid the Azure mirror that served 105 MB at 120 kB/s in PR run 61. - sudo sed -i 's|azure\.archive\.ubuntu\.com|archive.ubuntu.com|g' /etc/apt/sources.list.d/ubuntu.sources - sudo apt-get update - sudo apt-get install -y git mesa-vulkan-drivers libvulkan1 libxkbcommon0 libxkbcommon-x11-0 xvfb xdotool xclip openbox wmctrl fonts-noto-cjk + run: bash scripts/install_ci_runtime.sh - name: Build and install the exact PR wheel run: | cargo fmt --check @@ -136,11 +132,7 @@ jobs: persist-credentials: false - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 - name: Install native software-GPU and X11 runtime - run: | - # Avoid the Azure mirror that served 105 MB at 120 kB/s in PR run 61. - sudo sed -i 's|azure\.archive\.ubuntu\.com|archive.ubuntu.com|g' /etc/apt/sources.list.d/ubuntu.sources - sudo apt-get update - sudo apt-get install -y git mesa-vulkan-drivers libvulkan1 libxkbcommon0 libxkbcommon-x11-0 xvfb xdotool xclip openbox wmctrl fonts-noto-cjk + run: bash scripts/install_ci_runtime.sh - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: pr-linux-wheel diff --git a/docs/HOST_VALIDATION.md b/docs/HOST_VALIDATION.md index 92538c9..b4c37ff 100644 --- a/docs/HOST_VALIDATION.md +++ b/docs/HOST_VALIDATION.md @@ -10,9 +10,9 @@ current release candidate. ### Layout and review editing follow-up (2026-10-22) The installed release wheel passed **273 focused model, history, recovery and -oracle cases**, **9 real native/Jupyter interaction cases**, and **131 workflow +oracle cases**, **9 real native/Jupyter interaction cases**, and **134 workflow and packaged-asset checks**, with no skips in those runs. Full collection contains -806 cases; the focused runs do not establish a complete CI result. +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 @@ -44,6 +44,17 @@ 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). + 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) 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/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/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' From 455559798ccf29d53939a91af85fd88cecf9e308 Mon Sep 17 00:00:00 2001 From: youo he Date: Thu, 22 Oct 2026 10:59:22 +0000 Subject: [PATCH 04/23] Restore ordinary native chart forms after clearing selection --- docs/HOST_VALIDATION.md | 12 ++++++++++- .../layout-review-host-work-20261022.json | 16 +++++++------- .../native-unselected-form-20261022.json | 20 ++++++++++++++++++ src/radial_editor.rs | 7 ++++++- tests/test_radial_desktop.py | 21 +++++++++++++++++++ 5 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 docs/measurements/native-unselected-form-20261022.json diff --git a/docs/HOST_VALIDATION.md b/docs/HOST_VALIDATION.md index b4c37ff..09b5cd8 100644 --- a/docs/HOST_VALIDATION.md +++ b/docs/HOST_VALIDATION.md @@ -10,7 +10,7 @@ current release candidate. ### Layout and review editing follow-up (2026-10-22) The installed release wheel passed **273 focused model, history, recovery and -oracle cases**, **9 real native/Jupyter interaction cases**, and **134 workflow +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. @@ -55,6 +55,16 @@ 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) diff --git a/docs/measurements/layout-review-host-work-20261022.json b/docs/measurements/layout-review-host-work-20261022.json index 402edf8..80bbe98 100644 --- a/docs/measurements/layout-review-host-work-20261022.json +++ b/docs/measurements/layout-review-host-work-20261022.json @@ -2,8 +2,8 @@ "native": { "moves": 6, "inspector_rebuilds_during": 0, - "elapsed": 1.1376548249972984, - "last_input_to_submit_ms": 17.884905999999997, + "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", @@ -11,13 +11,13 @@ "/config/annotations/items/0/kind/Arrow/to/Ratio/y" ], "additional_column_uploads": 0, - "before_ssot_sha256": "5127bec2d9f505e1f25ce060b22424f368070422b098a083a416cc63cc80e165", - "after_ssot_sha256": "58fa155eb4932c6446bc86c8ebc72c879a614d2d0fe0c7538d434b80a372a2a0" + "before_ssot_sha256": "891f834495a93d874ad5f71d490c7fcdd2bb24c1d356df512a0b79960f3e546b", + "after_ssot_sha256": "76eac254fd9b6fb06ba52acc4ecb38877c9714be237ad5cfe97103ee9315ed12" }, "browser": { "moves": 8, "inspector_rebuilds_during": 1, - "elapsed": 0.6138199410052039, + "elapsed": 0.8355179329810198, "changed_ssot_paths": [ "/config/annotations/items/0/kind/Arrow/from/Ratio/x", "/config/annotations/items/0/kind/Arrow/from/Ratio/y", @@ -25,9 +25,9 @@ "/config/annotations/items/0/kind/Arrow/to/Ratio/y" ], "additional_column_uploads": 0, - "before_ssot_sha256": "9de4d043330443860b2f49c8fa17bff7c48bfc89f05d34d5693b727c7d105610", - "after_ssot_sha256": "b9b58cb9f23aca7ce0c86b9337bc7439858c74f692fa9c8ea93e4ec9962c9121" + "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": "aa645f288addec66ca41d043821ef7604214b0f5ed49c670b6fed90f78bfe733" + "wheel_sha256": "c9cbe07af2f5ec504e78dd4ec6a2cdb8645d36561be9fd6e8f5b2185f0243f58" } 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/src/radial_editor.rs b/src/radial_editor.rs index 022500f..d9a5d1d 100644 --- a/src/radial_editor.rs +++ b/src/radial_editor.rs @@ -576,7 +576,12 @@ impl Editor { .unwrap_or_else(|| "data_area".into()) }) }; - if target == "data_area" || target == "figure" { + 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); diff --git a/tests/test_radial_desktop.py b/tests/test_radial_desktop.py index 3ddd10c..2fd6b5c 100644 --- a/tests/test_radial_desktop.py +++ b/tests/test_radial_desktop.py @@ -138,6 +138,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() Date: Thu, 22 Oct 2026 14:06:26 +0000 Subject: [PATCH 05/23] Fix native annotation drag churn and durable review lifecycle --- .github/workflows/pr-tests.yml | 12 + docs/HOST_VALIDATION.md | 41 +- docs/REVIEW_MODE.md | 27 + .../native-annotation-drag-20261022.json | 986 ++++++++++++++++++ .../native-review-package-20261022.json | 4 + python/figgyplot/_review_store.py | 62 +- .../figgyplot/review-feedback-v1.schema.json | 2 + python/figgyplot/review.py | 137 ++- .../skills/figgyplot-review/SKILL.md | 29 +- python/figgyplot/webgpu.js | 7 +- python/figgyplot/widget.py | 2 +- scripts/profile_annotation_drag.py | 134 +++ src/desktop.rs | 142 ++- src/editor.rs | 9 +- src/host_work.rs | 54 + src/identity.rs | 8 +- src/lib.rs | 97 +- tests/test_interaction_parity.py | 63 +- tests/test_review_lifecycle.py | 115 ++ tests/test_review_store.py | 123 +++ 20 files changed, 1945 insertions(+), 109 deletions(-) create mode 100644 docs/measurements/native-annotation-drag-20261022.json create mode 100644 docs/measurements/native-review-package-20261022.json create mode 100644 scripts/profile_annotation_drag.py create mode 100644 src/host_work.rs create mode 100644 tests/test_review_lifecycle.py create mode 100644 tests/test_review_store.py diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 9b69faa..5f25c31 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 diff --git a/docs/HOST_VALIDATION.md b/docs/HOST_VALIDATION.md index 09b5cd8..8390aea 100644 --- a/docs/HOST_VALIDATION.md +++ b/docs/HOST_VALIDATION.md @@ -2,11 +2,48 @@ ## 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. + ### Layout and review editing follow-up (2026-10-22) The installed release wheel passed **273 focused model, history, recovery and diff --git a/docs/REVIEW_MODE.md b/docs/REVIEW_MODE.md index f371cb9..033a5eb 100644 --- a/docs/REVIEW_MODE.md +++ b/docs/REVIEW_MODE.md @@ -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, 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/python/figgyplot/_review_store.py b/python/figgyplot/_review_store.py index 5f15421..278ce24 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 @@ -13,6 +14,27 @@ 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 +60,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): @@ -56,29 +78,35 @@ def __init__(self, path): self.path.mkdir(parents=True, exist_ok=True) (self.path/'data').mkdir(exist_ok=True) self.mutex = threading.RLock() + self._local = threading.local() self._seen_generation = None @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')) diff --git a/python/figgyplot/review-feedback-v1.schema.json b/python/figgyplot/review-feedback-v1.schema.json index f4c6b55..4b8d44a 100644 --- a/python/figgyplot/review-feedback-v1.schema.json +++ b/python/figgyplot/review-feedback-v1.schema.json @@ -39,6 +39,7 @@ "work_id": { "type": "string" }, + "intent": {"enum": ["revise", "finish"]}, "source": { "type": "object" }, @@ -245,6 +246,7 @@ "type": "string", "minLength": 1 }, + "intent": {"enum": ["revise", "finish"]}, "manifest": { "type": "string", "minLength": 1 diff --git a/python/figgyplot/review.py b/python/figgyplot/review.py index 28d8b3f..da4feab 100644 --- a/python/figgyplot/review.py +++ b/python/figgyplot/review.py @@ -33,6 +33,22 @@ def _replace_ui_comment(doc, text): if text:doc['comments'].append(dict(id=_id(),text=text,target=None,channel='ui')) +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): value = copy.deepcopy(value or {}) if value.get('entrypoint'): @@ -343,6 +359,29 @@ def subscribe(self, *, after=None, timeout=60): 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]) @@ -362,7 +401,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') @@ -370,30 +409,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'] @@ -415,13 +456,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) @@ -436,6 +480,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'] @@ -443,7 +488,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): @@ -522,6 +567,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 @@ -535,7 +614,7 @@ def poll(self): if request is None:continue try: self._set_target_comments(fid,request.get('target_comments',[])) - self.submit(fid,request['disposition'],comments=request['comments'],expected_revision=request['revision']) + 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 @@ -581,15 +660,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']} @@ -614,10 +697,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') @@ -630,6 +726,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: @@ -657,14 +754,14 @@ def __exit__(self,*args):self.close() def main(): parser=argparse.ArgumentParser(description=__doc__) - parser.add_argument('action',choices=['protocol','skill','status','events','subscribe','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 subscription wait or candidate execution/render seconds') + parser.add_argument('--timeout',type=float,help='Maximum wait or candidate execution/render seconds; connect waits indefinitely by default') args=parser.parse_args() package=Path(__file__).parent if args.action=='protocol': @@ -675,13 +772,17 @@ def main(): with Session.open(args.cache) as session: if args.action=='status':result=session.status elif args.action=='events':result=session.events() - elif args.action=='subscribe':result=session.subscribe(after=args.after,timeout=args.timeout) + 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':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 30c0314..4657e16 100644 --- a/python/figgyplot/skills/figgyplot-review/SKILL.md +++ b/python/figgyplot/skills/figgyplot-review/SKILL.md @@ -20,8 +20,15 @@ without installing this skill. 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. Obtain the session cache path from the controller. Keep the controller running - on its rendering host and subscribe using +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 @@ -37,6 +44,13 @@ without installing this skill. 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. + 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 @@ -45,6 +59,12 @@ without installing this skill. 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. + For finish-only feedback, save requested exports from the accepted checkpoint + and 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 + another review round. Do not call ordinary `regenerate` for an explicit finish + request. Verify terminal `status`, then stop subscribing. 6. 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 @@ -66,7 +86,10 @@ the round; `request_changes` submits another work batch after every disposition. After publishing a successor, immediately return to the subscription step. -The controller presents the complete next round. No vendor SDK is invoked by +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. diff --git a/python/figgyplot/webgpu.js b/python/figgyplot/webgpu.js index 1576f23..be9fa20 100644 --- a/python/figgyplot/webgpu.js +++ b/python/figgyplot/webgpu.js @@ -56,7 +56,7 @@ export function render({model,el}) { 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,reviewTargets=[],reviewFontLoaded=''; @@ -87,7 +87,7 @@ export function render({model,el}) { } if(state.approved)reviewStatus.textContent='Review saved'; } - async function submitReview(disposition){ + async function submitReview(disposition,intent='revise'){ if(reviewBusy)return; reviewBusy=true;reviewPanel.querySelectorAll('button,textarea').forEach(v=>v.disabled=true); reviewStatus.textContent='Saving review…'; @@ -95,7 +95,7 @@ export function render({model,el}) { 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,target_comments:reviewTargets,revision}); + model.send({action:'review_submit',disposition,intent,comments:reviewComment.value,target_comments:reviewTargets,revision}); }catch(e){reviewFailure(String(e));} } function reviewFailure(message){ @@ -107,6 +107,7 @@ export function render({model,el}) { const reviewMessage=message=>{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')); + reviewPanel.querySelector('[data-review-finish]').addEventListener('click',()=>submitReview('accept','finish')); function saveReviewDraft(){model.send({action:'review_comment',comments:reviewComment.value,target_comments:reviewTargets});} reviewComment.addEventListener('input',saveReviewDraft); function editTargetComment(target,label,existing=null){ diff --git a/python/figgyplot/widget.py b/python/figgyplot/widget.py index 18d70a5..9502af4 100644 --- a/python/figgyplot/widget.py +++ b/python/figgyplot/widget.py @@ -153,7 +153,7 @@ def _command(self, widget, content, buffers): # common commit path; do not start another barrier inside a comm. session._set_target_comments(self.figure.id,content.get('target_comments',[])) 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/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(), styles: Vec, + style_specs: Vec, + last_stamp: Option, last_revision: u64, last_scale: f32, last_pool_generation: u32, @@ -218,6 +220,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, @@ -746,12 +750,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(); } @@ -1071,6 +1106,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)?; @@ -1124,6 +1160,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() @@ -1287,6 +1324,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 @@ -1378,12 +1416,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 @@ -1409,17 +1454,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(), @@ -1428,31 +1494,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 { @@ -1466,6 +1540,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, @@ -1529,6 +1605,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); } @@ -1584,6 +1663,7 @@ 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]})) }); + 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(()) } diff --git a/src/editor.rs b/src/editor.rs index 56a2a92..7161eb8 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -140,15 +140,16 @@ impl Editor { input.inner.rect.intersect(input.inner_rect), ); 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, + state["request"] = json!({"disposition":disposition,"intent":intent, "comments":comments,"target_comments":state["target_comments"],"revision":c.data.revision,"finalized":false}); state["error"] = json!(""); } 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 5d63b80..bf199b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -202,6 +202,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 +599,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(&serde_json::to_string(&state).map_err(runtime)?); + self.data.host_work.finish("commit_config", started); + result } fn snapshot(&self) -> Settings { Settings { @@ -1006,6 +1010,7 @@ impl FigureCore { columns: HashMap::new(), revision: 0, history: Default::default(), + host_work: Default::default(), uploads: 0, readbacks: 0, png_encodes: 0, @@ -1197,6 +1202,12 @@ impl FigureAccess<'_> { Ok(()) } fn apply_settings(&mut self, json: &str) -> PyResult<()> { + let started = self.data.host_work.start(); + let result = self.apply_settings_inner(json); + self.data.host_work.finish("apply_settings", started); + result + } + fn apply_settings_inner(&mut self, json: &str) -> PyResult<()> { let before = serde_json::to_value(self.snapshot()).map_err(runtime)?; let base_revision = self.data.revision; let mut next: Settings = parse(json)?; @@ -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; diff --git a/tests/test_interaction_parity.py b/tests/test_interaction_parity.py index 1cf0860..2fac1b1 100644 --- a/tests/test_interaction_parity.py +++ b/tests/test_interaction_parity.py @@ -3033,23 +3033,64 @@ def test_element_review_comments_use_selected_font_and_preserve_ssot(tmp_path): @pytest.mark.skipif(NEEDS_UI, reason='requires native and real Jupyter input') -def test_annotation_drag_reuses_inspector_descriptions_until_release(tmp_path): +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)) - a=old['config']['annotations']['items'][0]['kind']['Arrow'] - b=new['config']['annotations']['items'][0]['kind']['Arrow'] - assert a['from']!=b['from'] and a['to']!=b['to'] - for key in ('from','to'): + 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') - fig.axes[0].arrow(.2,.2,.3,.2,coords='ratio',id='arrow') + 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: @@ -3057,22 +3098,30 @@ def assert_only_arrow_coordinates_changed(host,before,after): 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() - source="import figgyplot as fg\nfig,ax=fg.subplots(backend='webgpu')\nax.plot([0,1],[2,3],id='one');ax.arrow(.2,.2,.3,.2,coords='ratio',id='arrow')\nax.text(.7,.7,'Keep this annotation',coords='ratio',id='fixed')\ndisplay(fig.widget())" + 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() diff --git a/tests/test_review_lifecycle.py b/tests/test_review_lifecycle.py new file mode 100644 index 0000000..fa80fcf --- /dev/null +++ b/tests/test_review_lifecycle.py @@ -0,0 +1,115 @@ +"""Review acceptance follows actual settings AND data, across complete rounds.""" +import json +from pathlib import Path +import runpy +import subprocess +import sys + +import pytest + +from figgyplot.review import Session +from figgyplot._review_diff import persistent + + +def builder(path, *, values='[1,2]', width=2.5, dpi=120, donut='[3,2,1]'): + path.write_text("def build(session):\n" + f" line=session.figure(id='line',dpi={dpi})\n" + f" line.axes[0].plot([0,1],{values},id='signal',linewidth={width})\n" + " ring=session.figure(id='donut')\n" + f" ring.axes[0].donut({donut})\n") + + +@pytest.mark.parametrize('change',['unchanged','same_bounds_data','style','dpi','accepted_donut_data']) +def test_successor_carries_only_exact_accepted_chart_content(tmp_path,change): + source=tmp_path/'plots.py';builder(source) + with Session(tmp_path/'cache',source={'entrypoint':str(source)},backend='webgpu') as session: + runpy.run_path(str(source))['build'](session);session.start() + accepted='donut' if change=='accepted_donut_data' else 'line' + edited='line' if accepted=='donut' else 'donut' + previous=session.snapshot()['rounds'][-1] + session.submit(accepted,'accept') + session.submit(edited,'request_changes',comments='Revise only this chart') + if change=='same_bounds_data':builder(source,values='[2,1]') + elif change=='style':builder(source,width=4) + elif change=='dpi':builder(source,dpi=144) + elif change=='accepted_donut_data':builder(source,donut='[2,3,1]') + work=session.events()[0]['work_id'];assert session.regenerate(work)==2 + current=session.snapshot()['rounds'][-1] + if change=='unchanged': + with pytest.raises(ValueError,match='already regenerated'): + session.finish(work) + assert session.status['pending']==[edited] + approval=current['documents'][accepted]['approval'] + assert approval['carried_from']['round_id']==previous['id'] + assert approval['settings']==current['documents'][accepted]['baseline'] + assert approval['changes']==[] + # The unchanged chart remains recoverable, but no new review view opens. + with Session.open(session.cache_dir) as reopened: + restored=reopened.restore(accepted) + assert persistent(restored.get_settings())==persistent(approval['settings']) + else: + assert set(session.status['pending'])=={'line','donut'} + assert current['documents'][accepted]['approval'] is None + + +def test_finish_is_a_durable_lifecycle_signal_not_a_new_edit_round(tmp_path): + with Session(tmp_path,backend='webgpu') as session: + fig=session.figure(id='plot');fig.axes[0].plot([0,1],[2,3],id='signal') + session.start();before=fig.get_settings() + session.submit('plot','accept',comments='저장 후 종료',intent='finish') + event=session.subscribe(timeout=0) + assert event['intent']=='finish' + with pytest.raises(ValueError,match='use finish'): + session.regenerate(event['work_id']) + assert session.finish(event['work_id'])==1 + assert session.finish(event['work_id'])==1 + assert session.status['state']=='completed' and not session.status['pending'] + assert session.subscribe(timeout=0) is None + state=session.snapshot() + assert len(state['rounds'])==1 + assert state['rounds'][0]['documents']['plot']['approval']['settings']==before + with Session.open(tmp_path) as resumed: + assert resumed.status['state']=='completed' + assert persistent(resumed.restore('plot').get_settings())==persistent(before) + + +def test_source_finish_validates_exact_data_and_never_opens_another_review(tmp_path): + source=tmp_path/'plots.py';builder(source) + with Session(tmp_path/'cache',source={'entrypoint':str(source)},backend='webgpu') as session: + runpy.run_path(str(source))['build'](session);session.start() + session.submit('line','accept') + session.submit('donut','accept',comments='Save and close',intent='finish') + work=session.events()[0]['work_id'] + builder(source,values='[2,1]') # Same settings/extents, different arrays. + with pytest.raises(ValueError,match='exact approved settings and data'): + session.finish(work) + assert session.status['state']=='ready' + builder(source) + assert session.finish(work)==2 + assert session.status['state']=='completed' and not session.status['pending'] + assert not session._handles and not session.events() + + +def test_finish_cli_requires_the_writer_lease_and_returns_terminal_state(tmp_path): + with Session(tmp_path,backend='webgpu') as session: + session.figure(id='plot').axes[0].plot([0,1],[2,3]) + session.start();session.submit('plot','accept',intent='finish') + work=session.events()[0]['work_id'];claim=session.claim(work,worker_id='cli') + args=[sys.executable,'-m','figgyplot.review','finish',str(tmp_path),work] + without=subprocess.run(args,capture_output=True,text=True) + assert without.returncode!=0 and '--claim-token' in without.stderr + accepted=subprocess.run(args+['--claim-token',claim['token']],capture_output=True,text=True) + assert accepted.returncode==0,accepted.stderr + assert json.loads(accepted.stdout)==dict(round=1,finished=True) + assert session.status['state']=='completed' + + +def test_finish_on_one_figure_does_not_override_requested_changes_on_another(tmp_path): + with Session(tmp_path,backend='webgpu') as session: + for fid in ('line','donut'):session.figure(id=fid).axes[0].plot([0,1],[2,3]) + session.start() + session.submit('line','accept',intent='finish') + session.submit('donut','request_changes',comments='Change the palette') + assert session.events()[0]['intent']=='revise' + with pytest.raises(ValueError,match='Finish intent requires'): + session.submit('donut','request_changes',intent='finish') diff --git a/tests/test_review_store.py b/tests/test_review_store.py new file mode 100644 index 0000000..af89bd5 --- /dev/null +++ b/tests/test_review_store.py @@ -0,0 +1,123 @@ +"""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_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 From c83f42b8b1e696cf6a720cf98e0a5f2c689bf8d0 Mon Sep 17 00:00:00 2001 From: youo he Date: Thu, 22 Oct 2026 15:18:05 +0000 Subject: [PATCH 06/23] Detach clipboard owner streams so CI logging can finish --- .github/workflows/pr-tests.yml | 1 + docs/HOST_VALIDATION.md | 14 ++++++++ .../ci65-clipboard-pipe-20261022.json | 25 +++++++++++++ scripts/run_ci_tests.py | 11 +++++- tests/native_input.py | 9 +++++ tests/test_desktop_editor.py | 3 +- tests/test_interaction_parity.py | 8 ++--- tests/test_native_input.py | 36 +++++++++++++++++++ 8 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 docs/measurements/ci65-clipboard-pipe-20261022.json create mode 100644 tests/test_native_input.py diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 5f25c31..aff4d87 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -178,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/docs/HOST_VALIDATION.md b/docs/HOST_VALIDATION.md index 8390aea..fba093e 100644 --- a/docs/HOST_VALIDATION.md +++ b/docs/HOST_VALIDATION.md @@ -44,6 +44,20 @@ 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 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/scripts/run_ci_tests.py b/scripts/run_ci_tests.py index 54986a6..d5958ce 100644 --- a/scripts/run_ci_tests.py +++ b/scripts/run_ci_tests.py @@ -1,8 +1,11 @@ #!/usr/bin/env python3 """Run the complete pytest collection once, with past failures first.""" import json +import atexit +import os from pathlib import Path import sys +import threading import pytest @@ -61,4 +64,10 @@ def rank(item): if __name__ == '__main__': - raise SystemExit(pytest.main(sys.argv[1:], plugins=[PriorityFirst()])) + def boundary(phase, code=None): + print('CI PYTEST BOUNDARY '+json.dumps(dict(phase=phase,pid=os.getpid(),exit_code=code, + threads=[dict(name=t.name,daemon=t.daemon,alive=t.is_alive()) for t in threading.enumerate()])),flush=True) + atexit.register(lambda:boundary('interpreter-exit')) + code=pytest.main(sys.argv[1:], plugins=[PriorityFirst()]) + boundary('pytest-returned',int(code)) + raise SystemExit(code) 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_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_interaction_parity.py b/tests/test_interaction_parity.py index 2fac1b1..ecb26fd 100644 --- a/tests/test_interaction_parity.py +++ b/tests/test_interaction_parity.py @@ -8,6 +8,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 @@ -2812,8 +2813,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']: @@ -2973,7 +2973,7 @@ def test_element_review_comments_use_selected_font_and_preserve_ssot(tmp_path): 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 - subprocess.run(['xclip','-selection','clipboard'],input=text,text=True,check=True) + 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() @@ -2982,7 +2982,7 @@ def test_element_review_comments_use_selected_font_and_preserve_ssot(tmp_path): 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') - subprocess.run(['xclip','-selection','clipboard'],input=text+'!',text=True,check=True) + 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. 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 From c36378ac2ed7b070d28ddbac3f6a0c44841aa0d8 Mon Sep 17 00:00:00 2001 From: youo he Date: Thu, 22 Oct 2026 16:27:06 +0000 Subject: [PATCH 07/23] Reduce native text editing and review polling overhead --- docs/HOST_VALIDATION.md | 41 ++ docs/measurements/native-typing-20261022.json | 464 ++++++++++++++++++ python/figgyplot/review.py | 5 +- scripts/profile_text_input.py | 94 ++++ src/desktop.rs | 4 + src/edit_history.rs | 3 + src/editor.rs | 57 ++- src/lib.rs | 7 +- tests/test_typing_desktop.py | 118 +++++ 9 files changed, 773 insertions(+), 20 deletions(-) create mode 100644 docs/measurements/native-typing-20261022.json create mode 100644 scripts/profile_text_input.py create mode 100644 tests/test_typing_desktop.py diff --git a/docs/HOST_VALIDATION.md b/docs/HOST_VALIDATION.md index fba093e..e7bdfaf 100644 --- a/docs/HOST_VALIDATION.md +++ b/docs/HOST_VALIDATION.md @@ -735,3 +735,44 @@ 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. 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/python/figgyplot/review.py b/python/figgyplot/review.py index da4feab..bf5dffe 100644 --- a/python/figgyplot/review.py +++ b/python/figgyplot/review.py @@ -630,8 +630,9 @@ 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','') - targeted=json.loads(handle.info_json())['review'].get('target_comments',[]) + 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'] if targeted!=previous: doc['comments']=[v for v in doc['comments'] if v.get('channel')!='ui_target']+[dict(v,channel='ui_target') for v in targeted];changed=True diff --git a/scripts/profile_text_input.py b/scripts/profile_text_input.py new file mode 100644 index 0000000..4a3645d --- /dev/null +++ b/scripts/profile_text_input.py @@ -0,0 +1,94 @@ +"""Profile actual native typing, including the durable review controller. + +Requires an installed release wheel, X11, xdotool and the test dependencies. +Run with an output JSON path. Host stages exclude input-driver waits; they +are CPU wall times, not physical GPU timings or a hardware FPS estimate. +""" +import argparse +import json +import os +from pathlib import Path +import sys +import time + +os.environ['FIGGY_PROFILE_HOST'] = '1' +sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'tests')) + +from figgyplot.desktop import poll_events +from figgyplot.review import Session +from test_radial_desktop import Inputs + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('output', type=Path) + args = parser.parse_args() + result = dict(kind='CPU wall time; driver_poll_ms includes the driver and is not frame time', cases=[]) + for mode in ['inline','inspector','review','target']: + with Session(backend='native') as session: + fig = session.figure(id='plot', figsize=(10,6), dpi=120) + ax = fig.axes[0] + for i in range(12): + ax.plot([0,1,2],[i,i+2,i+1],id=f's{i}') + for i in range(16): + ax.text(.2+(i%4)*.14,.2+(i//4)*.14,f'Note {i}',coords='ratio',id=f'n{i}') + session.present() + handle = session._handles['plot'] + inputs = Inputs(handle) + + def send(*command): + before = json.loads(handle.info_json())['surface_frames'] + inputs.xdo(*command) + deadline = time.monotonic()+10 + while json.loads(handle.info_json())['surface_frames'] <= before: + session.poll() + assert time.monotonic() String { + let state = self.control.review.lock().unwrap(); + json!({"comments":state["comments"],"target_comments":state["target_comments"]}).to_string() + } fn review_result(&self, error: &str) { let mut state = self.control.review.lock().unwrap(); state["request"] = Value::Null; 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 7161eb8..7f3e8ea 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -29,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>, @@ -63,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(), @@ -714,10 +716,19 @@ impl Editor { }); }); self.navigator.review_enabled = self.review_enabled(); - let nodes = navigation::nodes(&c.snapshot()); + 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, @@ -1182,6 +1193,14 @@ impl Editor { } fn properties(&mut self, ui: &mut egui::Ui, c: &mut FigureAccess<'_>, target: &str) { + 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/") @@ -1201,21 +1220,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(), } @@ -1223,6 +1251,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") diff --git a/src/lib.rs b/src/lib.rs index bf199b1..ed04e33 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -602,7 +602,7 @@ impl FigureAccess<'_> { let started = self.data.host_work.start(); let mut state = self.snapshot(); state.config = config; - let result = 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 } @@ -1203,14 +1203,13 @@ impl FigureAccess<'_> { } fn apply_settings(&mut self, json: &str) -> PyResult<()> { let started = self.data.host_work.start(); - let result = self.apply_settings_inner(json); + let result = parse(json).and_then(|next| self.apply_settings_model(next)); self.data.host_work.finish("apply_settings", started); result } - fn apply_settings_inner(&mut self, json: &str) -> PyResult<()> { + 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)?; } diff --git a/tests/test_typing_desktop.py b/tests/test_typing_desktop.py new file mode 100644 index 0000000..fd945b1 --- /dev/null +++ b/tests/test_typing_desktop.py @@ -0,0 +1,118 @@ +"""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']) +def test_native_typing_does_not_rebuild_unrelated_chart_work(tmp_path, monkeypatch, mode): + 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: + # 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() From fbf4fd2839fbd2e730d877f8f4286d6b7c813a58 Mon Sep 17 00:00:00 2001 From: youo he Date: Thu, 22 Oct 2026 18:27:26 +0000 Subject: [PATCH 08/23] Add object summaries and exact detail queries for review feedback --- docs/HOST_VALIDATION.md | 22 + docs/README.md | 1 + docs/REVIEW_FEEDBACK_VIEWS.md | 89 +++ .../measurements/review-summary-20261022.json | 168 ++++++ python/figgyplot/_review_diff.py | 9 +- python/figgyplot/_review_summary.py | 152 +++++ .../figgyplot/review-feedback-v1.schema.json | 528 +++++++++++++++++- python/figgyplot/review.py | 28 +- .../skills/figgyplot-review/SKILL.md | 24 +- src/lib.rs | 4 + src/property_catalog.rs | 168 ++++-- tests/test_interaction_parity.py | 6 + tests/test_review_summary.py | 228 ++++++++ 13 files changed, 1385 insertions(+), 42 deletions(-) create mode 100644 docs/REVIEW_FEEDBACK_VIEWS.md create mode 100644 docs/measurements/review-summary-20261022.json create mode 100644 python/figgyplot/_review_summary.py create mode 100644 tests/test_review_summary.py diff --git a/docs/HOST_VALIDATION.md b/docs/HOST_VALIDATION.md index e7bdfaf..9d8e243 100644 --- a/docs/HOST_VALIDATION.md +++ b/docs/HOST_VALIDATION.md @@ -776,3 +776,25 @@ 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/README.md b/docs/README.md index bbac637..695bb12 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ | [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/REVIEW_FEEDBACK_VIEWS.md b/docs/REVIEW_FEEDBACK_VIEWS.md new file mode 100644 index 0000000..6115a61 --- /dev/null +++ b/docs/REVIEW_FEEDBACK_VIEWS.md @@ -0,0 +1,89 @@ +# 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. + +## 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/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/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_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/review-feedback-v1.schema.json b/python/figgyplot/review-feedback-v1.schema.json index 4b8d44a..63ec276 100644 --- a/python/figgyplot/review-feedback-v1.schema.json +++ b/python/figgyplot/review-feedback-v1.schema.json @@ -39,7 +39,12 @@ "work_id": { "type": "string" }, - "intent": {"enum": ["revise", "finish"]}, + "intent": { + "enum": [ + "revise", + "finish" + ] + }, "source": { "type": "object" }, @@ -246,7 +251,12 @@ "type": "string", "minLength": 1 }, - "intent": {"enum": ["revise", "finish"]}, + "intent": { + "enum": [ + "revise", + "finish" + ] + }, "manifest": { "type": "string", "minLength": 1 @@ -255,6 +265,520 @@ "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" + } + } + } + }, + "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" + } + } + } + }, + "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" + } + } + } + }, + "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" + } + } + } + }, + "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 } } } diff --git a/python/figgyplot/review.py b/python/figgyplot/review.py index bf5dffe..10148f6 100644 --- a/python/figgyplot/review.py +++ b/python/figgyplot/review.py @@ -497,6 +497,16 @@ 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 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 details(self.feedback(work_id),figure_id,target=target,change_id=change_id) + 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 @@ -763,10 +773,21 @@ def main(): 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,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') @@ -775,7 +796,10 @@ def main(): elif args.action=='events':result=session.events() 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':result=session.feedback(args.work) + 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') diff --git a/python/figgyplot/skills/figgyplot-review/SKILL.md b/python/figgyplot/skills/figgyplot-review/SKILL.md index 4657e16..f709856 100644 --- a/python/figgyplot/skills/figgyplot-review/SKILL.md +++ b/python/figgyplot/skills/figgyplot-review/SKILL.md @@ -39,8 +39,28 @@ without installing this skill. 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. Read `feedback CACHE WORK_ID`. Use the baseline and approved settings, exact - change operations and stable selectors. Read natural-language review comments +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. diff --git a/src/lib.rs b/src/lib.rs index ed04e33..90e2a02 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1971,6 +1971,10 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { 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!(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)?)?; diff --git a/src/property_catalog.rs b/src/property_catalog.rs index cab6901..7c6c9b1 100644 --- a/src/property_catalog.rs +++ b/src/property_catalog.rs @@ -346,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, @@ -919,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" { diff --git a/tests/test_interaction_parity.py b/tests/test_interaction_parity.py index ecb26fd..b130d9a 100644 --- a/tests/test_interaction_parity.py +++ b/tests/test_interaction_parity.py @@ -3000,6 +3000,12 @@ def test_element_review_comments_use_selected_font_and_preserve_ssot(tmp_path): 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) 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'] From d5a4306dfe020b19d488f072c2a500500e9ca046 Mon Sep 17 00:00:00 2001 From: youo he Date: Mon, 26 Oct 2026 13:20:13 +0000 Subject: [PATCH 09/23] Support review image attachments and opaque target dialogs Normalize reference images into durable comment attachments across native and notebook review editors. Keep chart-stage focus outlines beneath floating review dialogs and retain existing text input, recovery and manual workflows. Validation: 154 focused review, storage, schema, asset and execution checks passed; all 9 native popup and typing cases passed on a fresh X11 display; cargo fmt --check passed. --- Cargo.lock | 670 +++++++++++++++++- Cargo.toml | 5 + THIRD_PARTY_NOTICES.md | 2 + docs/REVIEW_FEEDBACK_VIEWS.md | 36 + docs/measurements/review-images-20261023.json | 43 ++ .../review-popup-occlusion-20261023.json | 118 +++ python/figgyplot/_review_store.py | 50 ++ python/figgyplot/editor.css | 8 + .../figgyplot/review-feedback-v1.schema.json | 83 ++- python/figgyplot/review.py | 108 ++- .../skills/figgyplot-review/SKILL.md | 77 +- python/figgyplot/webgpu.js | 83 ++- python/figgyplot/widget.py | 13 +- src/desktop.rs | 56 +- src/editor.rs | 8 +- src/editor/review.rs | 71 +- src/lib.rs | 2 + src/review_images.rs | 280 ++++++++ tests/test_interaction_parity.py | 104 +++ tests/test_review_images.py | 174 +++++ tests/test_review_popup_desktop.py | 75 ++ tests/test_review_store.py | 20 + tests/test_typing_desktop.py | 11 +- 23 files changed, 2046 insertions(+), 51 deletions(-) create mode 100644 docs/measurements/review-images-20261023.json create mode 100644 docs/measurements/review-popup-occlusion-20261023.json create mode 100644 src/review_images.rs create mode 100644 tests/test_review_images.py create mode 100644 tests/test_review_popup_desktop.py diff --git a/Cargo.lock b/Cargo.lock index bfdf8a3..b7f4546 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", @@ -676,17 +963,22 @@ dependencies = [ name = "figgyplot" version = "0.3.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..4a16a35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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/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/REVIEW_FEEDBACK_VIEWS.md b/docs/REVIEW_FEEDBACK_VIEWS.md index 6115a61..c5d8df6 100644 --- a/docs/REVIEW_FEEDBACK_VIEWS.md +++ b/docs/REVIEW_FEEDBACK_VIEWS.md @@ -37,6 +37,42 @@ 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 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/python/figgyplot/_review_store.py b/python/figgyplot/_review_store.py index 278ce24..8ed1dcd 100644 --- a/python/figgyplot/_review_store.py +++ b/python/figgyplot/_review_store.py @@ -11,6 +11,8 @@ import tempfile import threading import time +import base64 +import struct import numpy as np @@ -68,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'): @@ -77,9 +85,11 @@ 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): @@ -123,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'] @@ -167,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 @@ -176,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/editor.css b/python/figgyplot/editor.css index 8510e0a..843a884 100644 --- a/python/figgyplot/editor.css +++ b/python/figgyplot/editor.css @@ -109,3 +109,11 @@ .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/review-feedback-v1.schema.json b/python/figgyplot/review-feedback-v1.schema.json index 63ec276..93c9a29 100644 --- a/python/figgyplot/review-feedback-v1.schema.json +++ b/python/figgyplot/review-feedback-v1.schema.json @@ -111,7 +111,13 @@ "id": { "type": "string" }, - "target": {} + "target": {}, + "attachments": { + "type": "array", + "items": { + "$ref": "#/$defs/image_attachment" + } + } } } }, @@ -415,6 +421,12 @@ }, "label": { "type": "string" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/$defs/image_attachment" + } } } } @@ -489,6 +501,12 @@ }, "label": { "type": "string" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/$defs/image_attachment" + } } } } @@ -584,6 +602,12 @@ }, "label": { "type": "string" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/$defs/image_attachment" + } } } } @@ -739,6 +763,12 @@ }, "label": { "type": "string" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/$defs/image_attachment" + } } } } @@ -779,6 +809,57 @@ } }, "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 10148f6..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,14 @@ 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): @@ -167,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 @@ -321,6 +328,8 @@ def widget(self, figure): 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();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 @@ -332,6 +341,8 @@ def widget(self, figure): 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',{})) @@ -339,12 +350,17 @@ 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'} or not all(isinstance(item.get(k),str) and item[k].strip() for k in ('id','text','target')) or item['id'] in ids or ('label' in item and not isinstance(item['label'],str)): + 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) - updated=[c for c in doc['comments'] if c.get('channel')!='ui_target']+[dict(c,channel='ui_target') for c in comments] + 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): @@ -385,13 +401,54 @@ def connect(self, *, after=None, timeout=None): 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(): @@ -500,12 +557,23 @@ def feedback(self, work_id): def feedback_summary(self, work_id): """Readable object groups linked to every exact snapshot-diff operation.""" from ._review_summary import summary - return summary(self.feedback(work_id)) + 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 details(self.feedback(work_id),figure_id,target=target,change_id=change_id) + 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.""" @@ -624,6 +692,7 @@ def poll(self): if request is None:continue try: 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)) @@ -644,10 +713,25 @@ def poll(self): text=draft.get('comments') or '' targeted=draft.get('target_comments') or [] previous=[v for v in doc['comments'] if v.get('channel')=='ui_target'] - if targeted!=previous: - doc['comments']=[v for v in doc['comments'] if v.get('channel')!='ui_target']+[dict(v,channel='ui_target') for v in targeted];changed=True - if text!=_ui_comment(doc): - _replace_ui_comment(doc,text);changed=True + 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): diff --git a/python/figgyplot/skills/figgyplot-review/SKILL.md b/python/figgyplot/skills/figgyplot-review/SKILL.md index f709856..4e6a95b 100644 --- a/python/figgyplot/skills/figgyplot-review/SKILL.md +++ b/python/figgyplot/skills/figgyplot-review/SKILL.md @@ -10,6 +10,9 @@ 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. 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 @@ -64,6 +67,16 @@ without installing this skill. 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. + 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 @@ -80,12 +93,17 @@ without installing this skill. edit and satisfy the comments. Source builders use `build(session, **arguments)` and `session.figure(id=...)`; they do not start an agent or approve themselves. For finish-only feedback, save requested exports from the accepted checkpoint - and reconcile any exact human edits into source. Then run + 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 - another review round. Do not call ordinary `regenerate` for an explicit finish + 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. Run `regenerate CACHE WORK_ID --claim-token TOKEN`. This executes the builder, +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. @@ -113,3 +131,56 @@ 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/webgpu.js b/python/figgyplot/webgpu.js index be9fa20..e39f576 100644 --- a/python/figgyplot/webgpu.js +++ b/python/figgyplot/webgpu.js @@ -59,7 +59,61 @@ export function render({model,el}) { reviewPanel.innerHTML='
'; root.append(reviewPanel); const reviewComment=reviewPanel.querySelector('textarea'),reviewStatus=reviewPanel.querySelector('[data-review-status]'); - let reviewBusy=false,reviewLoaded=false,reviewTargets=[],reviewFontLoaded=''; + 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();}; @@ -75,8 +129,9 @@ export function render({model,el}) { } 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??'';reviewTargets=clone(state.target_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; @@ -88,14 +143,14 @@ export function render({model,el}) { if(state.approved)reviewStatus.textContent='Review saved'; } async function submitReview(disposition,intent='revise'){ - if(reviewBusy)return; + 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,intent,comments:reviewComment.value,target_comments:reviewTargets,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){ @@ -104,11 +159,11 @@ 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')); reviewPanel.querySelector('[data-review-finish]').addEventListener('click',()=>submitReview('accept','finish')); - function saveReviewDraft(){model.send({action:'review_comment',comments:reviewComment.value,target_comments:reviewTargets});} + 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(); @@ -116,12 +171,14 @@ export function render({model,el}) { 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(); - save.onclick=()=>{if(!input.value.trim())return;const item={id:existing?.id??crypto.randomUUID(),target,label,text:input.value,channel:'ui_target'};reviewTargets=reviewTargets.filter(v=>v.id!==item.id);reviewTargets.push(item);saveReviewDraft();reviewDialog.close();}; - reviewDialog.append(heading,input,save,cancel);reviewDialog.showModal();input.focus(); + 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('div').append(commentsButton); + 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();const point=localPoint(event),clientPoint=[event.clientX,event.clientY];enqueue(async()=>{ + 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; @@ -137,7 +194,7 @@ export function render({model,el}) { 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();openReviewMenu([node],[event.clientX,event.clientY]); + 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);} @@ -1233,6 +1290,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);if(reviewDialog.open)reviewDialog.close();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 9502af4..c31816b 100644 --- a/python/figgyplot/widget.py +++ b/python/figgyplot/widget.py @@ -133,13 +133,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: 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: @@ -152,6 +162,7 @@ def _command(self, widget, content, buffers): # 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('intent','revise')) except Exception as exc: diff --git a/src/desktop.rs b/src/desktop.rs index 2f3a2d5..284c3e4 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -55,7 +55,26 @@ impl WindowHandle { } fn review_draft_json(&self) -> String { let state = self.control.review.lock().unwrap(); - json!({"comments":state["comments"],"target_comments":state["target_comments"]}).to_string() + 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(); @@ -1402,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() { @@ -1806,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/editor.rs b/src/editor.rs index 7f3e8ea..3462e6e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -108,9 +108,10 @@ impl Editor { }, ); 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)) @@ -141,6 +142,8 @@ 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, intent) in [ ("review_changes", "Request changes", "request_changes", "revise"), @@ -152,7 +155,7 @@ impl Editor { if button.clicked() { c.end_edit(); state["request"] = json!({"disposition":disposition,"intent":intent, - "comments":comments,"target_comments":state["target_comments"],"revision":c.data.revision,"finalized":false}); + "comments":comments,"attachments":attachments,"target_comments":state["target_comments"],"revision":c.data.revision,"finalized":false}); state["error"] = json!(""); } } @@ -169,6 +172,7 @@ impl Editor { }); }); state["comments"] = json!(comments); + state["attachments"] = json!(attachments); } pub(crate) fn finalize_review(&self, revision: u64) { diff --git a/src/editor/review.rs b/src/editor/review.rs index f639ed2..c1f9d4e 100644 --- a/src/editor/review.rs +++ b/src/editor/review.rs @@ -8,8 +8,40 @@ pub(super) struct Context { 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(); @@ -21,6 +53,7 @@ impl Editor { 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( @@ -69,7 +102,9 @@ impl Editor { if state.is_null() || !state["request"].is_null() { return; } - if root.input(|i| i.key_pressed(egui::Key::Escape)) { + 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; @@ -96,6 +131,7 @@ impl Editor { 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; } } @@ -116,15 +152,19 @@ impl Editor { 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(),egui::Button::new("Save comment")); + 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); - comments.push(json!({"id":id,"target":target,"label":label,"text":self.review_context.text,"channel":"ui_target"})); + 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); @@ -170,6 +210,8 @@ impl Editor { 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( @@ -187,5 +229,28 @@ impl Editor { }); 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/lib.rs b/src/lib.rs index 90e2a02..93b4a11 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}, @@ -1975,6 +1976,7 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { 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)?)?; 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/tests/test_interaction_parity.py b/tests/test_interaction_parity.py index b130d9a..5e57724 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 @@ -3140,3 +3142,105 @@ def assert_only_arrow_coordinates_changed(host,before,after): 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']) diff --git a/tests/test_review_images.py b/tests/test_review_images.py new file mode 100644 index 0000000..6e377a3 --- /dev/null +++ b/tests/test_review_images.py @@ -0,0 +1,174 @@ +"""Reference images belong to review comments, never chart Settings or text blobs.""" +import base64 +import copy +import io +import json +import os +from pathlib import Path +import runpy +import shutil +import subprocess +import sys + +from PIL import Image +import pytest +from jsonschema import Draft202012Validator + +from figgyplot.review import Session +from figgyplot._review_diff import persistent + + +def picture(format='PNG', color='#E69F00'): + stream=io.BytesIO() + Image.new('RGB',(96,64),color).save(stream,format=format) + return stream.getvalue() + + +def figure(session): + fig=session.figure(id='plot');fig.axes[0].plot([0,1],[2,3],id='signal') + fig.axes[0].text(.3,.3,'Review me',coords='ratio',id='note') + return fig + + +@pytest.mark.parametrize('format',['PNG','JPEG','WEBP']) +def test_images_normalize_to_durable_png_and_summary_exposes_viewable_paths(tmp_path,format): + with Session(tmp_path/'cache',backend='webgpu') as session: + fig=figure(session);session.start();before=fig.get_settings();revision=fig.revision + ref=session.attach_image('plot',picture(format),name='배색 참고.'+format.lower()) + session.comment('plot','이 배색을 참고해 주세요',target='panel/main/annotation/note',attachments=[ref]) + session.submit('plot','request_changes') + work=session.events()[0]['work_id'] + assert fig.get_settings()==before and fig.revision==revision + raw=session.feedback(work);comment=raw['round']['documents']['plot']['comments'][0] + image,=comment['attachments'] + assert image['mime_type']=='image/png' and (image['width'],image['height'])==(96,64) + assert 'data' not in image and 'path' not in image + with Image.open(session.cache_dir/image['file']) as decoded: + assert decoded.format=='PNG';decoded.load() + small=session.feedback_summary(work) + obj,=small['figures'][0]['objects'];assert obj['target']=='panel/main/annotation/note' + assert obj['change_count']==0 + exact=session.feedback_details(work,'plot',target=obj['target']) + assert not exact['changes'] + for view in [small,exact]: + attachment=view['figures'][0]['objects'][0]['comments'][0]['attachments'][0] if view['view']=='summary' else view['objects'][0]['comments'][0]['attachments'][0] + assert Path(attachment['path'])==session.cache_dir/image['file'] + assert 'data:image' not in json.dumps(view) and len(json.dumps(view))<12000 + import figgyplot.review as review + schema=json.loads(Path(review.__file__).with_name('review-feedback-v1.schema.json').read_text()) + Draft202012Validator(schema).validate(raw) + for view in [small,exact]:Draft202012Validator({'$ref':'#/$defs/'+view['view'],'$defs':schema['$defs']}).validate(view) + assert session.feedback(work)==raw + with Session.open(tmp_path/'cache') as resumed: + assert resumed.feedback_summary(work)['figures'][0]['objects'][0]['comments'][0]['attachments'][0]['path']==str(resumed.cache_dir/image['file']) + + +def test_image_only_general_and_target_comments_recover_and_remove_independently(tmp_path): + with Session(tmp_path,backend='webgpu') as session: + fig=figure(session);session.start();before=copy.deepcopy(fig.get_settings()) + raw=dict(data=base64.b64encode(picture()).decode(),name='Reference.png') + session._set_comment_images('plot',[raw]) + canonical=session._review_state('plot')['attachments'] + session._set_comment_text('plot','Keep this palette') + assert session._review_state('plot')['attachments']==canonical + session._set_target_comments('plot',[dict(id='comment-1',target='panel/main/annotation/note',text='',attachments=canonical)]) + session._set_comment_text('plot','') + session._set_comment_images('plot',[]) + assert session._review_state('plot')['attachments']==[] + assert len(session._review_state('plot')['target_comments'][0]['attachments'])==1 + assert fig.get_settings()==before + session.submit('plot','request_changes') + work=session.events()[0]['work_id'] + with Session.open(tmp_path) as recovered: + detail=recovered.feedback_details(work,'plot',target='panel/main/annotation/note') + assert detail['objects'][0]['comments'][0]['text']=='' + assert Path(detail['objects'][0]['comments'][0]['attachments'][0]['path']).is_file() + + +def test_jpeg_orientation_matches_the_review_preview(tmp_path): + from PIL import ImageOps + source=Image.new('RGB',(96,64),'white') + for x in range(48): + for y in range(64):source.putpixel((x,y),(230,159,0)) + exif=Image.Exif();exif[274]=6 + payload=io.BytesIO();source.save(payload,format='JPEG',exif=exif) + expected=ImageOps.exif_transpose(Image.open(io.BytesIO(payload.getvalue()))).convert('RGB') + with Session(tmp_path,backend='webgpu') as session: + figure(session);session.start() + ref=session.attach_image('plot',payload.getvalue(),name='Portrait.jpg') + assert (ref['width'],ref['height'])==(64,96) + with Image.open(session.cache_dir/ref['file']) as actual: + actual=actual.convert('RGB') + assert actual.size==expected.size + for point in [(10,10),(10,80)]: + assert all(abs(a-b)<=3 for a,b in zip(actual.getpixel(point),expected.getpixel(point))) + + +def test_staged_upload_survives_collection_and_duplicate_images_share_one_file(tmp_path): + with Session(tmp_path,backend='webgpu') as session: + figure(session);session.start() + ref=session.attach_image('plot',picture(),name='first.png') + again=session.attach_image('plot',picture(),name='second.png') + assert ref['file']==again['file'] + for _ in range(3):session.snapshot() + assert (session.cache_dir/ref['file']).exists() + assert len(list((tmp_path/'attachments').glob('*.png')))==1 + session._set_comment_images('plot',[ref,again]);assert len(session._review_state('plot')['attachments'])==1 + + +@pytest.mark.parametrize('bad',['not an image','truncated_png','path_escape','bad_metadata','oversized_dimensions']) +def test_invalid_images_fail_without_mutating_review_comments(tmp_path,bad): + with Session(tmp_path,backend='webgpu') as session: + figure(session);session.start();before=session.snapshot() + if bad=='path_escape': + ref=session.attach_image('plot',picture());ref['file']='../outside.png';image=ref + elif bad=='bad_metadata': + ref=session.attach_image('plot',picture());ref['width']+=1;image=ref + elif bad=='oversized_dimensions': + image=io.BytesIO();Image.new('RGB',(8193,1)).save(image,format='PNG');image=image.getvalue() + elif bad=='truncated_png':image=picture()[:30] + else:image=bad.encode() + comments=copy.deepcopy(session.snapshot()['rounds'][-1]['documents']['plot']['comments']) + with pytest.raises((ValueError,OSError)): + session._set_comment_images('plot',[image]) + assert session.snapshot()['rounds'][-1]['documents']['plot']['comments']==comments + + +def test_corrupt_image_is_detected_before_recovery_cleanup(tmp_path): + with Session(tmp_path,backend='webgpu') as session: + figure(session);session.start();session._set_comment_images('plot',[picture()]) + ref=session._review_state('plot')['attachments'][0] + path=session.cache_dir/ref['file'];path.write_bytes(path.read_bytes()[:-1]+b'!') + manifest=(tmp_path/'manifest.json').read_bytes() + with pytest.raises(ValueError,match='image integrity'): + Session.open(tmp_path) + assert path.exists() and (tmp_path/'manifest.json').read_bytes()==manifest + + +def test_images_retained_on_failed_rebuild_then_collected_after_successor_commit(tmp_path): + source=tmp_path/'plots.py' + source.write_text("def build(s):\n s.figure(id='plot').axes[0].plot([0,1],[2,3],id='signal')\n") + with Session(tmp_path/'cache',backend='webgpu',source={'entrypoint':str(source)}) as session: + runpy.run_path(str(source))['build'](session);session.start() + session.comment('plot','Reference',attachments=[picture()]);session.submit('plot','request_changes') + work=session.events()[0]['work_id'];path=Path(session.feedback_summary(work)['figures'][0]['comments'][0]['attachments'][0]['path']) + original=source.read_text();source.write_text("def build(s):\n raise RuntimeError('retry me')\n") + with pytest.raises(RuntimeError,match='retry me'):session.regenerate(work) + assert path.is_file() and session.status['state']=='ready' + source.write_text(original);session.regenerate(work) + assert not path.exists() and session.status['round']==2 + + +def test_widget_upload_uses_binary_buffers_and_canonical_comment_refs(tmp_path): + with Session(tmp_path,backend='webgpu') as session: + fig=figure(session);session.start();widget=session.widget(fig);sent=[] + widget.send=lambda message,**kwargs:sent.append(message) + widget._command(widget,dict(action='review_image',request='upload-1',name='sample.jpg'),[memoryview(picture('JPEG'))]) + response=sent[-1];assert response['action']=='review_image_result' and response['request']=='upload-1' + ref=response['attachment'];assert 'data' not in ref + widget._command(widget,dict(action='review_comment',comments='',attachments=[ref],target_comments=[]),[]) + assert session._review_state('plot')['attachments']==[ref] + before=fig.get_settings() + widget._command(widget,dict(action='review_submit',disposition='request_changes',comments='',attachments=[ref],target_comments=[],revision=fig.revision),[]) + assert session.status['state']=='ready' and fig.get_settings()==before + assert session.feedback_summary(session.events()[0]['work_id'])['figures'][0]['comments'][0]['attachments'][0]['path'] diff --git a/tests/test_review_popup_desktop.py b/tests/test_review_popup_desktop.py new file mode 100644 index 0000000..ddb5a5b --- /dev/null +++ b/tests/test_review_popup_desktop.py @@ -0,0 +1,75 @@ +"""A native review popup must occlude the selected chart's focus outline.""" +import json +import os + +import numpy as np +from PIL import ImageGrab + +from figgyplot.review import Session +from test_radial_desktop import Inputs, requires_x11 + + +@requires_x11 +def test_review_popup_occludes_chart_focus_outline(tmp_path): + panels = ['a', 'b', 'c', 'd', 'e', 'ch'] + with Session(tmp_path / 'cache', backend='native', review_font_size=18) as session: + fig = session.figure(id='spectra', figsize=(16, 9), dpi=100, + grid=[panels[:3], panels[3:]]) + for name in panels: + ax = fig.subplots[name].axes + ax.plot([0, 1, 2], [0, 1, 0], id='data-' + name) + ax.set_title(name) + session.present() + handle = session._handles['spectra'] + inputs = Inputs(handle) + inputs.xdo('windowsize', inputs.wid, 1600, 1000) + inputs.pump() + stage = inputs.info()['chart_panel'] + inputs.input_frame('mousemove', '--window', inputs.wid, + round(stage[0] + stage[2] * .86), + round(stage[1] + stage[3] * .75)) + inputs.input_frame('click', 1) + title = inputs.info()['decorations']['subplot_4:chart_title'] + inputs.input_frame('mousemove', '--window', inputs.wid, + round(title[0] + title[2] / 2), + round(title[1] + title[3] / 2)) + inputs.input_frame('click', 3) + inputs.click('review_target:panel/ch/chart_title') + inputs.click('review_target_text') + inputs.input_frame('type', '--clearmodifiers', '--delay', 0, + 'Review only this different wavelength band. Keep the ' + 'original wavelengths; label the true maximum in nm ' + 'and leave the other five panels unchanged.') + inputs.pump() + info = inputs.info() + assert info['focused_panel'] == 'ch' + text = info['controls']['review_target_text'] + # The right edge of the selected last panel crosses the popup's white + # padding just below its text input. This used to paint blue on top of + # the input, title bar and padding despite an opaque window background. + stage = info['chart_panel'] + edge = round(stage[0] + stage[2] - 2) + assert text[0] + 5 < edge < text[0] + text[2] - 5 + y = round(text[1] + text[3] + 4) + # Obtain client-to-root coordinates from real pointer translation; + # getwindowgeometry can include the WM frame offsets a second time. + inputs.input_frame('mousemove', '--window', inputs.wid, 0, 0) + pointer = dict(line.split('=', 1) for line in + inputs.xdo('getmouselocation', '--shell').splitlines()) + x0, y0 = int(pointer['X']), int(pointer['Y']) + image = ImageGrab.grab(xdisplay=os.environ['DISPLAY']) + image.save(tmp_path / 'review-popup.png') + patch = np.asarray(image.convert('RGB'))[y0+y-1:y0+y+2, x0+edge-1:x0+edge+2] + (tmp_path / 'popup-probe.json').write_text(json.dumps({ + 'edge': edge, 'y': y, 'text': text, 'origin': [x0, y0], 'pixels': patch.tolist(), + })) + assert patch.shape == (3, 3, 3) + assert patch.min() >= 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 index af89bd5..350542e 100644 --- a/tests/test_review_store.py +++ b/tests/test_review_store.py @@ -39,6 +39,26 @@ def storage_reader(path,start): 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=[])) diff --git a/tests/test_typing_desktop.py b/tests/test_typing_desktop.py index fd945b1..bb22c17 100644 --- a/tests/test_typing_desktop.py +++ b/tests/test_typing_desktop.py @@ -22,7 +22,8 @@ def assert_rich_text(text, expected): @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']) -def test_native_typing_does_not_rebuild_unrelated_chart_work(tmp_path, monkeypatch, mode): +@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') @@ -34,6 +35,14 @@ def test_native_typing_does_not_rebuild_unrelated_chart_work(tmp_path, monkeypat 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') From 7e93d46836335f7f02809975ae321822abaed83a Mon Sep 17 00:00:00 2001 From: youo he Date: Mon, 26 Oct 2026 13:20:44 +0000 Subject: [PATCH 10/23] Add reusable style tokens and execution-local task profiles Capture and replay portable appearance with explicit scale and wording opt-in. Add execution-local style declarations, Figure defaults, construction-time inheritance and explicit argument precedence while preserving existing accepted edits. Provide fourteen documented task profiles alongside six legacy presets, light/dark and density variants, stable category keys, semantic roles and encoding-capacity warnings. Include source guidance, measured contrasts and a reproducible native gallery. Validation: 657 style, chart and Python API regression cases passed across native and notebook models; all fourteen task profiles rendered natively. Python 3.8 syntax and git diff checks passed. --- README.md | 32 + docs/README.md | 3 + docs/STYLE_PRESET_RESEARCH.md | 125 +++ docs/STYLE_PROFILES.md | 127 +++ docs/STYLE_TOKENS.md | 242 ++++++ docs/images/style-profiles-publication.png | Bin 0 -> 144456 bytes docs/images/style-profiles-screens.png | Bin 0 -> 490517 bytes .../style-preset-audit-20261026.json | 255 ++++++ .../measurements/style-profiles-20261026.json | 724 ++++++++++++++++++ examples/README.md | 1 + examples/style_gallery.py | 130 ++++ python/figgyplot/__init__.py | 6 +- python/figgyplot/_charts.py | 13 + python/figgyplot/_features.py | 13 + python/figgyplot/_style_presets.py | 125 +++ python/figgyplot/_style_runtime.py | 271 +++++++ python/figgyplot/figure.py | 91 ++- python/figgyplot/pyplot.py | 1 + python/figgyplot/styles.py | 701 +++++++++++++++++ tests/test_style_inheritance.py | 349 +++++++++ tests/test_styles.py | 349 +++++++++ 21 files changed, 3551 insertions(+), 7 deletions(-) create mode 100644 docs/STYLE_PRESET_RESEARCH.md create mode 100644 docs/STYLE_PROFILES.md create mode 100644 docs/STYLE_TOKENS.md create mode 100644 docs/images/style-profiles-publication.png create mode 100644 docs/images/style-profiles-screens.png create mode 100644 docs/measurements/style-preset-audit-20261026.json create mode 100644 docs/measurements/style-profiles-20261026.json create mode 100644 examples/style_gallery.py create mode 100644 python/figgyplot/_style_presets.py create mode 100644 python/figgyplot/_style_runtime.py create mode 100644 python/figgyplot/styles.py create mode 100644 tests/test_style_inheritance.py create mode 100644 tests/test_styles.py diff --git a/README.md b/README.md index 6b23626..dd1d2cd 100644 --- a/README.md +++ b/README.md @@ -356,6 +356,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 diff --git a/docs/README.md b/docs/README.md index 695bb12..5a13709 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,9 @@ | [Project README](../README.md) | Installation, quick start, interactive demo and chart gallery | | [Example index](../examples/README.md) | Runnable chart recipes, image links and commands | | [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 | 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..5450cb5 --- /dev/null +++ b/docs/STYLE_TOKENS.md @@ -0,0 +1,242 @@ +# 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. +- `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. + +## 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/style-profiles-publication.png b/docs/images/style-profiles-publication.png new file mode 100644 index 0000000000000000000000000000000000000000..80dae9afb2f71a24c37ab7f3577587d626ee2fe6 GIT binary patch literal 144456 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 $^i$ugK5+weYM*HrKFu~n!vPoc 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 YH5o~0BE3p_1pJYgQI^h=dj0PI0h1_M^8f$< literal 0 HcmV?d00001 diff --git a/docs/images/style-profiles-screens.png b/docs/images/style-profiles-screens.png new file mode 100644 index 0000000000000000000000000000000000000000..c5f8aca0ea9554a7bab9322db57a340bf8839e2b GIT binary patch literal 490517 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%0DEpsY_I&wr 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
6dNA5cT_}GCvs<4gCuy7B5yg8BkME zhY7Ei_pU9I1^=YRN;%ClAS;o<3pI)ad112#=xQY7tqPb2c<5PPuUvaB-#ws-P(r9O z#2{3#*sj0U`9^=Sv6l`!VIg8|8k43KIZ^>;>JcyH-twExHy-)h4eRzh2N8D9jGxh~ ztE)?b&jH7FLesB$YO>*+<2KlGFvWvZKAzq$>gsSBa}cF^u0R;Uw{66dzP{o7YB7Bo zJpcs!YX=mqXz-@(jysy-TDF_-gBrWZaoAAr3gK#DQjDEysZ|PPAvC{RLZy# zW7ag(U8@$Dt2Q)-Es>9G%FNU_BnjMQMeA#C-ydZumJ&X1wX0p{gvlq zjpm!5^DhDtO}>6j1jzk}YeUa256f)l{zylu@yEv>{je}mFS&J0Jh)Ec`u=AQ5DN5UEBugMa=UPw^b zp_K58T~k4)ahG&)if{K?76AG9k?PGeyrdU>cLIJ&zKX0?-<#dpsT6@ z-j|5#8N{-3g`=nba_zkSf={%UQ2=<`1x?a=FI6JfmXK2$n0ij=ZCr!#obD3>iK%8V zqp#%fIVJA#K;90=Pt4zj2gRL#1db@yV)lX+!87^dB*pD~xL`W9%igV;)*z>u=AoLS zY}ibU%w#1p>xES&p!}I-W=R&};i~bYhphn}X=04Zg71td1|xKhsu`eK~KCs88-hlmj=);cTFP z0f2F0(ossy!vsN^UlP7hE<>(b?#p%*(;z%T4`uv|p@nW3wA;A-$n~M}q~*wa8vk(N z@RZAG@$@=kVzPD4>Ze)e`}-Kznn2vMTOS;OuPc|TAIh{UD!(EOlyO;jo*(*V6q*l? z?-R?kj5*!{36di_6PwkFwEHf6AwQS~K4oU^@U|Id!Cu2SH+(~Wj3BPPS_FFf$YTJN zCJ&icu<_JBr!P7t+uNutI@!2QH*cP3R`=|ljl#0Bux%{Wx`k|g8d09?u>j~5$81j& zS(VEiTV~7jYy8dY`*eHS+;}7-n1bX6m8v8-ip1R!NR(aVJ(nk}>ig2Tai!~ZdFXjy z=Z@0aqxfwz~D?!n;f%BHR9lTymmj*oycvqXwUvYnYJ~_quNioEgeB&QM+8v-Q z3wE2lZS}R&^?>cAdh@51tW!mmjet3bu1D!Q6_l=%{dW2dg%){%W2+PS5%x4KR6}#7 zt+#q}wp9X<1NA(mXpg=-hYs`c==opjrwe)8PdHHQM5V+OAV>7n8_#en~q0%(Xp0Ac9|(qRVdfi+fGDlbIX)( zd(snirCo<0bs9Kt`Q-5>c=?|pM+0?_v$=Uf)61*f%f<=8zaTrZ5M0&x`s8GU|IO*b z$S2M)DTavgGhLYDZ`2E3bWxbf@)wzwFE&lFNtDs(k|i?q(>d|oXC}5P5mZcZCs~K* zlZUfvnVG5;j^@5=ift{53GcHe@I=1MvuELh@C>ZZ()iU&y+Hw=RUZRoq)JJM(Mi!> zDtJB^%$0pM#wY(_FNev!J1B4hq06>6zc$#3UhftJP;vf)^MOT{HXWTc;$^|&cA8uB zyt(x=(s|vjLMQ1EN^QZ~C{|{~AzjuSvtc`!>ixh|`D${}aGRykHS|+kD(;eThXl?r zPE?Y)e*q^0+j}Y&Hkv^~GBa$>U2qRlH)Y@IDcpQ2mw@x$hNK_9FPC20_%ui4uDF*= z$IuhoF`jPtblQ>FY%b7a8iFCE6M?LQ4@5D^?Hj{hcknM>+SWeNpRBhP_@YSZiLrv1 z8(tt?$z&{)Wi0PHHa~a1urOuKWw1&okN&C*Dc{Zrs;{VUJnSM`@w~UITk@N^Uh#Vs z@&f)=ew%y6!*%uV6Qf4y#qEQBlUu{~eo;`c9p-8i!cVKxqp;s<^To6Vtq}kH6vSv>A628Tu5$_g5IM=r28*t1O26cBXb*Ypo2$B#y5Z^D}+k zv9OeumwzssL3h(v`tjf0I}yl?&3I||neN>`$ori6Qt#gfQc8Aaprn;JJUmNekSuX_ z0It;>clhc$a*w;ib~vBYWsClx#_3>5ToP zDRVCKN8cZZcI9RYHWfN&_7)xQ!!S%NVfGnI_XUWadOWR;BPtz*p+I$gj!XWyzST}GRzzWL7S9#s|zQ@8T8FC@aT7WO`>1*Sm`4ndjRxav& zoyPRBd;R`C+0+)NL|m~%!&Fw~;&&p!@qA~*VuwC(8GRJnZInm3ZN1$%B#dCtATaIQ z=UifPkcyhN2KEXbAr&r)VEeBDQ;K=#hm++E!0)utcCPW4=LfMLSqy%CzB%sOyn#PH zOi8M{o^5`QBxHViYC?!nzpJK@jdQstbC1jlhbqd-0!)}|%~Gw)jloPnxAoNSjZn;B zbA4)V3koVuGGJrjVEWQGZtm&XMDDZt2|jL_;ZvzPb9lY%>pfSw1zd~cvFx7Pov6h| zI}+8Y(H}qlh=n%uJz{k@B3jS$W_G-vgK+?iwk*w~WCmdhb0ob6Brm~1L7yIWH-NwI zj~jYSDg>?{UKMGSrSh4Nhhn8!45fBLys!3X?=OxYrxy@(aIfoh_iBfkIj~3GWo%WtZQ?80CG{cM z(m&<7yQN4Z1x?r|nV)5Wn8lH{hr0_^rDdo4qgO=C;^4X*m)plDV4LkfSJ?w;(qrk{KmxTJ zalFNP)=RT-Ebs1?{EROwZOQQ#Gc++yCuxDXK@cR*3i*$R%(rp|D!~CvKwjkCw8X2Z z$~PNTvZ{-uN|2iV)iS+(Ix=ikukcJbbnDBIRLtMGlpOVYba;1S7Tstq5e5oio8DE& zMXt(-#29mRpx|uuPVO~D0ioY6ZXJG^E$?Y9>u+ipt;qhj1?+Nk^DxE9_fU?~gb%2D zt3P>4F8whPiK7?o!ABK|XZWscJ%yEPU`S1+=Zu@X#hhF$4Tsagzl^51W6FCUQqQ_?7_wf`Kf>zOA7znHvSH}}(8#CGfc?YWAr37Lxtkxd96z1f_-@NeXx#H?g;SgGpX zaCMX3^#al=;0!b&ZCepL{Y9?^Y_EWxc9{P0k}o)s#A#F}&}de-wkXR@NQy^7xuXL} zR1VfG)5+GyWv2g*o5j0n3`-yG1~`=UcAb*Hj}6-%&4izyfN?ARc}F0o$CQ;==>8#6 z;(R-Jy{~_N958~~caJ9JMK*Qm(Z6Pu-5D}u{rVS_u~Rd=UMdMVQtwX`Y3lhL-Y}Lf zR~o-zeM>^(4$vxr=j%BUGKbmvz9YAtt)s|A?I2OyIULUl)BS+^zAWo2Z5pe8B7U`RfiDtu3vlTb_Syw6T&C=pz)&-YGeT6e= zH}o+U?qs;Rb|}Bb8i`WVFic@AzvB5W&fJ8WYaXwa+)L68U#FY3gYmE%NS~G#4ldFB z&Yl}-+-gzKi38J5t3tP{zm3hf9`e4sDnq!9_;9=LId}t`U~9voD>THDK-lo#r5l&B zL4!4rLKLkMqCg3kc_toOe|O9h-2H+1DfeVyZ{eJw2rG}v;?L{fM&0wimAwUC2+KCS!)4CAp5V{mm^eYVaB7+o4CCRPqe z(TxWS;QeeyUle4cjsFlYyc`oi?F?n4;i2$8%9Z0x5^TNv(d|D3dn zZLR8QX{Fr3kE$i#Te|*Y^@IDuxubet4oS1pgpdeJB}*w2i-GV#)bC|3mPgJA?MtT) zFhE8y9+9TIv$~iLhno`{#0p0mjUgJe34$S{f@D^y+qLuTRjS;fz=N$-AG=lvIke zG?d>EV$Y_9NV{>9{#@RplmhP-7hmx_N>gffCy)y1Gkqp{6H6)MRMkca0-39Qd`U6v zQ<+mKyow6UtKG1`ECx)nUis0SRyQgJ1_sZJww*us!FKJKTGHVPjJvCzt&MPC8}N^i zeA~+E)*_Om;^e$ZfDRHtMzuF4(ihE=0D(1I-;06si3>^xxDLSl+u>bqisoviHxnEwSL*l2( zrKI21;Uf)dwEpnhzI-me2hHWV21J2}*hKNjr2JE7*3jL)cVqPcX3C@a)3ka*guME< zX?3p;pGI9ryS}GR3f)`Sv7Y^;7#`Nwpj+-CzNb>C#tBC{jeWwTz{wJp-4y{XPXMpZ)Qmuj6dr5rsTaHTviHnHK^^*t& z8N?jE*-4564q)qW%t(!>tdYZ|O5PyO4V(0+dm07dC&)xZJP{t6BZqkgsrNTm?*A+t z>g@{I^{U3%Um8nIF*=?ew!t6-QU9=3C~@$Qs`HLW2uCTfXb4klQ_?6L`@4rKpXS|> zp40qVAz~n6lB)F}1d3x(JE9oAPdCuIlH`LKuFd>MByTkS-{I_u+Zt7cV=P(c7bqdM z6u5xl=3&)k!JP=f$k6BNd6+_Wm-oS3RTiUti}SuM^!!Um%12K@#wV%sCto-EOBxfl!oimENTmNsR|sMbAph zOH(IEHv0c)`p!T&pD)~1!s>+RHH09-61{~e(W5Lultk~n*B~J*5}#>%fI+kGpsEp2On?Cg{0Se%yMwf6o}%P!b6dD4(kUmh}}I zR9%olBTgZI%v1q-$^FU|)eR!c{S`#R5bW2qoQlK&L386I+FAxuGn{eyr-@ zW3ahzvF>nGOErtl>ip4RZ5!-yQF%|nVgE*GWO{JX@1BjFxTyk4GTE}y()$3-_GZx{ zKIG&8ALKfkiIhHD!ik~b>zS=n^Y|_gR!O+u_~C4sWGZq8zrM!%TIeGicwcYqa)n09 z0?c%mCUnZCneTRs=mNI6=$wCFqL!3M$X!_2d=m~uMIxy@_&uBlzSb?p@_k$~xV>a6 zD_nh;puHr>MeeGx5RfD&d$G_C7m6D;2>tiET{b;dn z{oG$Ejy^dp z>2kU_+f2~;ouS{=AKpsPU#vkhSuc2b$sViZNH{*RI=rmxa2AQ&*f~7)m8f+&Jof+L zJJir&T$+L5yQt0jZvR9F$^Nkm9Zte#YQ@?bJ*VZ`N77;DK1~&`! zc%E}F`yM0?%FO<<69&t%s)apyT}UuOxTZ((B z^>4P->9*EJCNXat;+!O?aejoY^4f`_W$PKhoF963KJlSPRg94_mn?3GF#4{G2mQCG zkd7b33i|zC_p3SK!*m_o!I#a!4X`wM!XrsqcH@j|YMQn^^A`D?=Ii(Skqf3Z*8A_tl)yk`dO9eH zs4cJ@$oM6<{66CAVcY%kLenAh3)}V$K4TaPN87)qN6*xB+5J~o*t58qnOjUuBniV# zYC-NRyry>thryQ+F~I~uiGYEOA1>)4_G5m=Nayig61acbxL@e1T-lZQr7RXL)XF8_ zygI^XH&yDxJB7(s)RAr1X6bZQmGAT%e1CWU)0N&gUSv5$5k;YWHlgpg0|zZFz1vL` zw=8)2g7fXgdZr1nM7hpO5N&u~wN1#J1-VMLo^dg*hi?P3FR{${!B_Xw{XXw8r^U&J z7sv(P(gbzx@M!=0l?;hw5re-%d?G|rk;xXda~80>mYuu9w$DZB!KkMe>e^2Xzfs6b z3w|~eIXQ=|9V7`x{OnDWbDkB?ZreaiArG(3q5nZmoXEi?RijM`HnVbw)As zm)6!OfO4JA^8_qHPvS6H`3gMm-q-KacIymGWMlYS|Jy%yWwb%gK8V|$MQQ~H-wkzy zH(7e=TRwUI+!+{#(JsTzU~9GI6|?RqK98EuZam^;uWAPmn?BrJj*TT5yEN%)C*gcx zW{Y^^=;-LLpTfuSR(tFl`)3=k?X%vq?TXXVjMIN}t4}&c1}^dOF_QB*;ygyUAcyA^ zlEzOuI&95+`Q|?id|>kw;4?er!!T{4%7;73WSju?7pLXfOYHDH1I0MmU7P(2%)GM? zt04K3tQ>ILjzONRuaYe;B3%||51V(-8_*K-OC8~|n6-RfM5{Nz$CQ}QHM|A0aq==r z7LARM1KvIr&Wo$Z`uh5uoSfjWA$O^6H?yGIexgBHAHY_D-=toOoqe_WRO#k=*JWU4 zCW&-^cV~yuJ93;lf^OpBhuJ4wIE5Nb`kPNHPJQhcMvGtC>CUo&UPkpk%!ji*6Lfre zk!JcTFM#x+x?FWPw90P%?nzDGB%hZ-@_TI#DJ@)Wv>yYZ4>pi~r7mVWV#2kG)l14O zf{HTd;3j4xS0L5H1A`!PpSTb^th8cFy6GPmn?h_ zS>SPRS>@mU`ROgs`m_XpsjSa<_>i{~<;M4aYX-G4C9bN~_zm0KIz<Kcadu&X4wGrQa1elik z_&HLu%j|jyylfeD8jD$(#uTe^zXAisuFfsHxmvP;l2T)+IEe`tm$R(BT=;&+)FaQLexCx=x=#@Tq_e3w(BqN3t< zZ#4c@Sa>(Ah2!Z{Fq@u%uC&WSws+I%4gqEOGwR>Ravkrv#wjbEPPBeBT0~;8DskuY zPbq^i28kJ)KV}4Ee zvV=SJwtz<+MGmm)t#aNJmDCQG#cflcb)diaGdbJCyFqM6+&_`MZPv(uKAFS%+k-qF zStS^tx_l^J{=~Izs)4CWf4^iijJ!y%D+0FX28F&l{@kpHjd%stg}?9`(0!E5!zQP# zZ}GZ>uU@xl;akTs1seg|gn0`nm@e=kJ1qn+31Z&vW~8Skx?mA{?L8T_@^&_ z;IZK*ea6LinQKtdPKt_hg=!Nh+uPd%10*j%i5Yi^?x(dNW;(l#1HF$RJC_|zKi9|# zoWJbj|8@8Ihr7=jeQc3Uw%-nV8=>B^tb|^I>D%r+lc#&oD}aK-U(D=kuKb;S&Q)1P zQK%=7ZsAW7w*z@2fG3=C$Y*$!T3p1_x)`;!@G2}p;)C4T1T?kMP_NNlu0M_>y6=l0 z1qJrQ&{;oq^bVKI%m%ACT2*hYN{%C3FdXAa%<$bx$kv`E{d2yK_XU?~NLN6S zY{zzEebIhTpCD2$@@PWQH86|R$XOP|xLCw&0V4NdU8jv0gq=k>nmcl^SfCcLptq9V{`HP+J<*37N)F(N1$k55rWc7#{QX7QA$l~!K}q< zN%ikDDNag3_ii)428f+&=MA$+m+qV3$j(y5ODIxOoV$nr>b|ReZUGo-e9id;knLc@ zLqlJiO|B9+?2c=T-nGp-oBVVg)>;g9ASH3|lrZ(Px;|{XooWqg>g|QY{;J!;MLtM` zYa=El88Ic#*dx;5*Dae0q}IIQDL^@ZQ0vs}$WO1-dPMb0^%dp^nIebRr=C5$ZlowxoXa%-h+8aY5YjZR*#*xG`*c z_z!W~qW_a4xOzBmyv|eM_JA&m87KL>7GCJYLgvtz3@QoENP&lD6t!MLE4B2L2$=|< zF#mweEyMd&nbK%8>6sa600QK8sE%u6(g=LzWT)i{BTAFvh^R@WF*r!EA_xUIbqQ-N zj%yVdUP9}eyS>VmA7X#GtLN$qFmB2WT1ktxB57MsKl)6*|TcNOcSOzwZ#RzI?P>e%i)b|sILR{!=y z(SDoCRis!G`5dd`p2aqk4c7B((;2=8ihgbm=5p)r=^4`c>2qnPp*f+gUW`HpNQPJ8; z%Jz_+{vk7SR(3W54jVDE9Gw5?*J{_#^j4Np68s+eEo@~LDS0p0;i&y2`~0cEXGJy`L*IFF zg=wwN@L}wuF8HFd@5f1oT*H!D{~Bu8Fk30MQ6FuEeFwibLW=}KluLcLV*0I90{`N` zR>)8hbR^pAk*c3Gj^W>DVvFwo@Q}S&O`mFr2-l+A@VKrZ^=9+a@W4F_K3l{K2iXXY z>28me5-du7y5O2|%Te{agcThZ#`|X-tVbYlv&_>nZf>4=!*^9>4Uc6*7OmqE!>bm_(Q$??U3GkN;yQTW~++fIf(OY3O&WhG>?s8OV;TfnlqbcI4c99L!-3 zT5L`VcC-A>#Uq9+?gOTF(R@bDfR5>4zLA9f#nKO#Hr^TCq{N!r6$nkx`4wzu$6jeV z*#L&9bQ_zTxpNx0)_sY3wZySq#^N->*GIyNc#xlz7e52Hgdyx@x zFRVmM4orO=$Z~a_@Vsz43svd#H+wJgma5LqstgV@{{uQXUw4{nb+sA3##4q`%?Bg9 zd)%bt)65?q!9-an+Hpm_N315#8^ zPwzTVCZ@}Mib16;fscuv`nQMrhzyMU(wEpSqKGI z3ZA~7gd;-Cu2ouEd(8p1=RS$1~H6R8+Z z&y3l(77i)$5)qJ)`0XtRRMh7H(elViA`oM@ct$KHI)){9i8N~yi1rq>S-`f*-)#+W z9{;jZy)!Q^#Dr17EU73_OVR5KGnhbSy41>amF@vuo4q#t1s<_ZtQz z;F9TBUV(u(X)({$)zrp!J!RTzn{t-or#)0vRgG@W$v#4?U(f?qddN&k@AIwUOu_o# z`Don8eph&ll#w#W{1P^ek(EuT*} zkWA`v%jEFP{b{gg$c6Q62eDjWQC3k|GNQ2<)m~Sb3PWu27HkphL%v z=ASpBv0Q89_+F&~`gFkS=Jee2roP^O*3{;GvU0+A z&GraZ%0ca3@=(RWsv@^_TfdJ~)E}Qi##tm-l|7 zg61HxoO2vpF}{q^FtgdiF^<^T{bjMdx{(KMsHI2YT#r;G8>rc);j3ZsE#L&Hq~h?p;eLu<2w6y z^J-4N$C000b3}a0gWDieUV?k7AD2;Oe){DTg2VQP`}Eo>U{!oMejTl%SIDS5&>Ihp zX*yUmYkuEZB>gD>opFWlHP)dreJJkRWZWQoc5{lobp}(vi-6kRSaKnc`Gx2hZm06J+(InT+- zJ(IJ5xd_>o@J8#d*d{F&J^5+3;s)e+TPmI9Y^;GGI9O(MC$%O z93Q9o6wGq5Sy8b};pq06eM;T6!Yx~HB2K2@cPK{Pm#Aw;h z^5K(z0|FshVh~KHsK=|~Rr0W{9u%s^!GSyLIFX{c$_Ky8Nfq^ZKCATfY>&0Zk3X~qqUyxxZ-1QDQyHvnK63iIh^*=`v~Ve37yq_- z8MNNinvPCr|56AI2sSxu?Vf!N)`~Ct{_bD5N3>b8G^u)~rq;K3yLI~KvSHTc7n=xY zf{sxw()TW}P)i20gLAQ9FGw%yyddIpm)FhGI=GAGj)r^9$AHN?!44I~K?_EU~O z-j(dO+q{g9c4ix8XBGDLfrOm?7o<)lxbgLX9>ddl?@#A@Dn4 zZ#OMT`ZzCAXa@a;`&FpFC8>tWRu~0Ay`dtrlxc~_m?Q6hiz#g%Li~d7?lhhwnb?Ti z#U7`{ilAr(J`)(c`*y#djgWZf73it*4pa-d30DfE1uCAADs$@c8u z851rWoyd3x6R;@vu)dxl`Ed}mG)07WYeWH}p2&kR}T zCP7fO%#6hW&$u!@mbIYTR1se}dTcIqMPHV+({|?l^xCc8t2BHWHSgS=UK+oqrK&zv z>sPaL+<^NLQZ)L2M_~r=9*ih?h7-2MjHBDFLD;{C+9np36nQj|Y1sTBYt8+I@GQR{d9`7e6yB0!?>EYo$lldeblX(3nu^rp8wg`D)2A#}PvEZI+yp`NDvSmn90 z1rrYEiNE#`j;&H8Y$Sz`2jwCEUgbF}c|&I^5-78C_&nkWgZZ#o7%0)}qCy6hri(~} zc%lIo=0|j-prn!Vf$uZ#mGYMlW}U|#Vz5FXB2K-yajP>=xLS{QYK+oB7SDv((f!+l zbvK)AYU(45jAH0f;(yh<2YJNTl&@Zm_z#zeLv`SywAB2Dg6?ZalZ*Zq#|clIk&&AX zdwZ2Q_{5h>`SBPs2A-p4j=o>NBsDM0ae&-fZ61tN%G6q1>N}cAmjcLRl&IVlWWMGFhdX=g9Z%=GVS!rL0sYY6c5jSg+c=aZCm`1W%5& zJxzo(%N(IzS0C8jz%!O*9T`G`9U)+y)EoVW7D^?~nP<|}ph-iVmNeapUpQ1PIDY4| zxsdVwbkVfQDT_MhaoT8%(n~&*LQ>gU&ip$M_9dX#F+~01y~JXbd43*_G#i+1wYbZ( z(vmuLSB+d-eueMd`+BrM@F7hTAFnyAX~ZjI}uI+*q1mGHwayT6BAHEzfYh7VjGvSQfCGijYGP{57+hSd#f|x9ejwVRq_@YDK zP^!rU#wvsZ*bDjZv`xA~_!?2)UY7fD-LN#?7wWR0!-JNXVEznDKC<<*C@q#z%_1{) z&e=I}Egd8lMryE`r&Wc$|j z-B-FkLOnf$M+W<*si&!Vy)>j58C0|1AXBCd$GftoQ!OE7Pq&^r>_>+I|9UMHC@K&t z$0Q8MXGV>AKW#&ljC#wn+enEBoWC4pi@_j>$ypwkNB=`F|U7W}|$XLwBI!NyMV@4N&`n)lG5RBXkwhCy;`@=HH*N)5ZnCw`r z8vM=Awhoq=692yZ+IMj8;kFIf_219v=&mLaU}r}8h0zI&6zm%_D{Q5?uTcDk^Dv+{ zV(`omlq9l$1M-LGLm_Sf^x~S}IwdZp;`1H%V0TAr%oK4r^UXuCcd-nU6+QDU&dS3x z2`AWw$GS?pd+UkM@3JQkuTCyDFQ{AyeE0Cdu9a@U&80DNIBRgTr-pE2Tx`=CRH3Yl zhJnL3JZ~o?zHz7+dw3TVdJ|h>@Bu)fo%>}#^W@mXBbwCc-{Ql5Z0%n{$rBzuk&;^3 zMabOmBCZh4m%UH}vnzo9xI@xdJ#zA8Qlp3eDY+tKsW4>cWfV^|M5^)jfx(|MI-d0K zX0P#a@!fvN7w!3Lc6obm=jR!i(S=WK+KL+rOb+AiG9Xr5!a{24`>pl;8D$2mWcxBv zn0Y6&a$w2bZ|3ECawhXBPZa=cGF9t3J_3mN)kQfKTR-OJb*~lKD2Ze0?wZJnA z=?MK2aTLvS-?$|GskEgDX3w(t%XnsJ>!(7^H`dVV^qE-6dKcce-*tb&8*|4F$|)WI z^zf%!bnO!+ysgWo-z>R97CAqXH5@zuyAJ$YwAh`QMk3`{M#+t^@Q^5Sp1CG|2|*o% z%L7?cIr8cniC4Q%QkgIHEz0dEvGR-LYuZ2mR{03&C4J`vV3XA^lTj++5W*y38Yw}b zX>u^Q^Ca8fQ>d}UM$uZ~o7s4xCLY^q=!7}d|M?@|}=9Wk33HJzY~ z#pYrHJ&tbkzLn^NYXAP?ui0H!IVLNBi-*TVH!l5*Z5Uh7DSAj(q&h$>D&kjiMj+;J z7k>BFEVHTY8-)A;l_g+MQjvRxl|I($IAn|>Zd}gmNJQs-#LAewm>K)y9C6fBMMq6} zdGqGb({Z-Wudi|)K(+UuFQ}Hnb)ZXL*7>oJURv94fv_GxECXuykbk)ke1)X$nnZU$ ze-34bWLD_RNK5wF*MY*X@h};FM7_Ll=8($;eenl?tI?&tFZ;yWvHuaz4LR1I+LPa* z&+Q+jOOCL?D3n}>#;Koi=Jey|wCk(w<_KdRXxNDOz$7oL?aLb5nHKDA1K!d)IAwsa zt|QxJ^I5nf%;Q$E)n);2H~A!;alPZ(e-y<5 zp_|lTHfTzJm5N(XBwK`@;Ruf>$YOfF;VM5>Ri6)}>eupdSYK!p(@h&{&cQpjuM0(B z3o3$XLBIdoG%#>1$wK}*NAL+_;4L6?a)^AC;DnL%Iba$p@W5UW*X)ID@0MI5z-or; zynMaZRa@{3*0HcPlYwMI*sfmBJs^qel~7J@-}TjGji3bGw&a_R2H?%1fetxUT=_xU zhEGBgxzB9BZrh#nMwnusf_6?4cl1oYaVk)I8x&I-^Ag-E7-Y=!X(#eKS{PO#TH~T5 ze4xLI8%Jm3s-6|hso5n2>H@c1W_RR+jPW(5aQ&O^$TaYpl4sO(@A4p5dUjoHp z6Y%`V|0S3$u)x$7OOrWl`0sWsjA_c_596UjNm>7_D)c+0Czsj|4l4{W&UO|TA2Am? z#>MIc1G;MRNZEUjdz|dU58(HI&am-7{(nTgyNQ+3uuAk&tiF>A{ygkrzkXLiLlUhD z85vNd=tReTp6-*m^;*VM^WcEGkNb}U`N%06OmKiF^eNEnL|PjZH%x6s9=45co{o!7 z5pXUq=iWR23W!5=@lkx#~qrG(WO`!EeI+gBUQw0;_XA{QjEVEtSKx| zwnPg=C}Ydpcs@I;9j-=VFI7}jPFK~5_!xJg#?eiuu;t+>^PcTDw=%kV<=i52#MRN( zYBP@#Vc&*bKjUX>F#yU<-|#Qs*T?><`p*{7oiut-+QNjgVFR=7AN^s&1($*FLCV<> z-<2bx$BpM4c~hQ|`1KSx8L4Iie6-b(eK z#_jk3P$Q%2&6S{SEpqNnbII!%n!$Obt0uZWmOJ$wkn-;Y*U5zp9k6c z_Tlw!XY~v}j5@*>qt}B9f7g(`J@e2{9pG;{IjOIhUASL*rI^5_)%u*9`}iY1iwJ$} zN9;RYoZoNH8%W-3m!@hw;uHBx0JC_>{rHcuL0!mn_MMXC<97~$VE z#`*THDcr&(9(Y(|Stc{SUdLJbYBNP6PX9A9#uAZT`Wv1;`T3Tk-T#d`b)}ORm z^0I-=$b{Y?uk+riDAM{Ia*%6dX4~^?^MhW*O+q}myy44Dpww6@c}b!wp@IItFj9qfQd0 z?q2EeXPay6g#PN3#1>F(LDyk61{aXN&Kd(7FbBk;|ERPAxt8Ib$NytqC^@r1po$To z_H+J)!TT$Rjt{XcVp7iSt`3;EH5Sou8W3a%%Oh!T_O$!&i5IV4+n8T3p9=ePg$P@5 z>|%?D74eh&7>;Rc=UYv^^qC7$3Ajkb{BeIVe2`(f#ihHiQn>pM)AkcRZF;qsnAE?# zhQFqCq2FHEJ5Dkjq8PhNq6Nf9%HkeE3%ygjtsyHl?wB0I5+S9F8;HlgTKw!xF()ZY zrkGEhE8 z*m+BSCp&p)=XxLS%ujBYnDdf2dVGe7MLQAWchu47T|~{(=iYNM_WAb5YIm{ z!u%_-M;T3FW&r?K3mS6K$xj{g%R!$DH3dV2G_R)YO4e%09AgJNb@^R{YsJa9{CAWa zUaP6Cd@8Te-oVo<(srtIdtkC`(rEQgNcd@0P@l_3;V0QlQ)|}5!iX8*OSuwiTWE5P z<0}nHwey*oDYjS6*em!WWN_qwjnXywj#-aXJhh4R*-rPm&Fo+jG76sc%-ERgP8(hC zNK4bu@JSlmnz|k~u9K|)kfqgTuX?le4W<(@Z20wozYcCl&1L!APyhWgEsCso2C?$a zG~QZ;ARwoOa40a6#rExABfXXyUzdT+!&HPZ#e#U#B#zd0_$w``fshoUU=YaFAO#VE z=5{fu6R%U-bSTCXKCBRc+ZP3C_&CM{d^lzAszUs9JwO!lQa>oREQrgdO<(vG>kzy@ zec;{X(z=?PYU(Q}@%RPn<)B8K=@ENGi7HmdPk^aO!uXSq>jJ|P{CahaNy zN)H>E>p$2XdeTkCnC#cjH&LMH1GO74a5*%-{2?Qjv2_;RG`zTF>&9$X;qf8jov9*! zLThO~!K=K%K~m)B?OVRR9hF(Rv!O-Ij-{3X} zuROPjg!tR&Y}~QnP*6p;_~Xk#DZdFLTl)8dS$TE4qD0tcM*7R1600BbKUm}&Uno>R zW4TSEwE!o=D;V^pPjgn>(7{{3F6G!?CT0RqPTK<^vbzn3N{>fz@Md>~r9VJUJX0cDn0Uqf_FBj;30m&OMiDCI5fMw|~R?m|NJeWL1^741kg5iJta$@A2&<&W>f~${o3W9gG_9hfsp0;D?y?fJsL@`lT06gVU-uJ}C=v%?x)>Q4_EQT+r859S z2Uu0-a+-vdsG0zM;46=;M|Sh8wWlz50mEDF3RH1`M*!1ivr4fxIYqA=9YSzy_(;# zVXi&o+w)(Y|I(=}YI4Lr_LYTT-(L{*ht>FEmWAqXiW5tVU36SuhAU1!6|MAf!d(p( zs#b|(RwmGC@-G4-E8cSZDK)H~$Q9<}JNlq_jFv)rsdO@WXOCb_P6 zG|GLd%DT4#l^6K{z5oGzC@5br0n_pE^l55B4ImYecd*UM{VTSyo3oOtZZZ$&DcMJR{J93cH zeZHLP=EvQlYJDLl#)faVDf}FTk9n-PY&+s9^uN=yOdrn-lX(v^%5&>NUq`EGM60Y5 zlu+$3);S=Zry4wlQ%w4H_maU_%D#zgCBK>$7QItxU+gd7Id}crNS4joB?Int@OR8) zULQY`uD<@agpo>kqrcX0>+{SJt>({dpYp%#3z@4II+*&G123NRVQ~3s;iRp0-ReZN z(XKhl))R4en;qzd_8{ZGUo^R84$vm9ez0<5RAPASUmtR%-E;EEqxqc!M^^iu-@ewveFs-Qh6oFBz!;fd6A-|FS zl*>Q;hs}?h7@8J5$zC#$NM`E*ey4tLUiY@^68Gx}6=@I_2&AGQr?;ApHP}osYLipH z={^@P3?zCcMSX~@GT4&u)R*=$>mS&EW$I63F=EZa{V$(n>wVHg`5ZeU8Z3b~RGCj7 z!*J-!&~l;V-=v=#N5U#~55DY(ygY9_u?b1V^h-uhzEWV4T&@R$bEn9)`Ik6j0aPEC z^g^RXgN}N(G>DvBueQx?10Ib?O`(DMf;txSue(D`BYv8l08WFGX4iU~q%Bm*_OklW z3O#bfoZu$L&t+n!&xBbLV5SdYv0fe?_#@L!c?;%_OkYg$$?CCxX+3{TFDs$cUl_pdlOBCWL;lE1@KT$p{v(q`(BHQWm!SCoeYB)jhm46 z%Xh6ySOcE4g&9vP&5+CAlv%FrQ6R+R>IEi<`EZGXy=dO`R&+5?ZD73kq6eD|^z~g_ zRQba6C^Ml(-^rN%`n^Q{QoKva9#@!em^};I8$D~`lhNWLuS0TguI)FToT#m= z#8rG+AQ&z`Cknn2@j+pv5AMuH{4CPok735V3SR(u8GX`N@PBHDd;ypBUi`8$TU|bW zEz69_IY!SvJ#z{^90!^nX>;4s~2)*yeEhSsQ`GWUKi3={Ii$Zej=uZVwbMQ9T&u|#SuWM>-s7OO=-8@ zlw+}}LmzkqVu(=1@oj8RA%*X?76w>kpPLA0!+AC?DdYFUH?GN^NxY?D&c$z=Ia}`o z3A_+15bxt%$ZFw7PAPH=?TQ2Q&Vy>KeMd0RiYh8fUYKM(Xe5`_9qY&;P9yj?;nsJ0$`rO0QCDMPvAUH8*ss$zj>fWxneQQ zU7j#9b{FR&JAXGV*}~%g-0kv#hRQ%iqw~azkhj(oCGjEe0vBtOwdRD5zE6IcDvEK@ zdYGi6{$J&Hvk98D*uc%Qqv!K4FpCq?M8DHY;SfRpArF_zz@57%7mIU;$Ep1gx6Sm& zA7E^_G6Agdfw>jsjp9yKV0(kWTW4B^H$g_!7@uWPnTZ)wKi4TB0xI^ z{a5`gdPSD+mP`f)DS8_1O$%bYaaT$4iVV0i#Q7be8vJ-i!qnZQH_gGT{%iU^gJ{sD zJdm)lU@KoR-q`;hH@f#m(26jhuoL_g=Wl*R6O&f3@lTP(U7CK z3{K%22T$*ruX6J!Z}lDLnkZ-fzD1;!$3nHYTV|sT=_AP=yaT!_)+qN~_%!?)SKR^z&4<=J=Y7m_T?u|*P^9a9HBi-BEG(RNSqwcttVNQVeISvoTLnZ*}x^Nht z(G#(N>j&fQK1EkAr!oqc*6fvH8zW(@> zIs4%d?-{01=Vm#?s8-0`)uK)R2KX2Q=&5459!LNFc5@|6=Tc{M*tA(09E1@Jg_aq# zoX`9q@y2xIF#!%-?r*-Xw+65rE+SYg96Rz9U0SZ7WYX zewW>^6Pr~usWP6sC*6n4mw$ING|Bc@DmDg{UaZ&H3=E2AwcBsEYWn`?km|D-HVyRpWI^c8hPzjqS(ms&Cm#s% zUlbkYWL?O#-i-rVeLI79K9%LA=_wVC`)XQyCISw?Q~b#WEfz-#?v6Nr{RIFqF)5h( zW=ZK6Eo$9rmG(qGo&nD1cCTN%s(Tht{R^sX8%i(i?(RZwP2U3QP-H)qYb+GRN&t zFe_vwj4&@KQ4aV-0eS$}z{C2`F2D(g{}-(QPEDE1w<$jL_hF`^`NnbdM%x(47t-~+ zZZTEwpwdH+Fat2~?sFh)yZ319qDs*HnP5(u`Ev2&zxleD3@~LLJY@uV_P!1PP$nP) zP~bOUeLyGf=*pXg{ob)IjqHjI3RRD4vAsYcP4|4K>oU0(u`5eHyuv9RM+8a?Mw- zcTWCf<&14UdiQ$ICh5cPamU5#AQXmswGzf4us7zb1o(}?Ib$1!7T>fT$8JIqT>I&z zX|CY>4fMh^fdv)$f@FyP@yOLQa*js!vN^G-WrX;58r*(oo${kx2mhjiXHR|oZi)1w z=6UHo)4!YqbAh3nS7Hyuz6+p;txqpx2h=sx0&dZh8-v-0r&rwhFi70$o_B!hLS?7b zwgs}=>+ZjACNm4t+Bn>zvDq4r-Mn-2^t;PA&0fCURcj@_HC=^k2U zKSizs;A8d3ns*ORVK_e7PI|KNU2oasGczn$a#GUGtE!K(3r;_`UZTGH|E<)tL6~^kKXS%^`xSEKEjw^@F0>_Q-t9slx zbBLc>@Zc1TAL0z=T14&Qutq$Yo@C2a(|bMAIM=2JRf_Tr)NHXm$Qbc)y-;f^IFBcd z$)kerz$eyje%beB+_e0lf^LX+u%^=r^gdONxP8YG?L!9SQco?AW(m!O( z7%v>1icuz`n;NtfSrBw`*V9wjRTW%6j82DqCiq6531C9vFY?tkA{aD(%1%Q*g5M{h z&KVMdc5!|Q$p|QRDR}Ptc{5x>lqfg{R$7b~eq_B^`TczK0Jz3EC5xB#!0bwP7)YvU zN~&Y7d5*rMde#b^!sy2LOWfxAAzO_IT1x@?G)D9Qglmbk$K!_HB5CbcuAQbR!+oGP6b?`**a{S?7)5jTzb`C%H`wNVUc*+|(r;Ny z8NU#V7w`X!fP?~k#Kk4m%tE}tPN0N@>A#;Q#!vnW9&jj>m5DrrXaN0LOSQ$-#=asn zAL}iTfo7f3vYR7d%W+5G$ee?j#w2v|=_zDtswNQr@O-{}y6{=J*69;OTI7rT4aq00 zSkFX4tuh8&YH}#_pptzEzT2L6FKM2o5MDj!TtQEAZ=qAf{mX4I8rBzk{01FyEp49L z%S|P)*-(cR#0|>&Cr;=XH=v-`5Q~Q>7YBr30pfY}$7HD(*RO>qQKY?Pi>u%xo<$oO z>0fX4zl8*}3Y-|#0pEnpVdDt8$dD|UT>oD8cYhVlP8*v4`)U;Y;zubn2U|1j0Zz49&ZB|$5xf~J})+|ox06b`9FL$ zo0cSEm$Irbeg#C1O#_ZE22L}3gferJ1yQNGDQ6xkJ1a)p!YYA$S3AG-EU%q?+7TawnYqw*7 za9US^HYPcFg#I*Md%}?iEo^CJ9lDx_FDlIgbDWew?b5I|A$RG>#1Q;ubxH^GM~OKW ziH_>F7_cD?IjY&8o$f=M>!&MmVkY@NB$@BlOo??ncNLeod~uWpSqg=2)>}*kE$nir4R}9 zgkbx?)5Hhk=3bK*g3BHov>UoV%yLnMroyHE@Pmwrb;_re<&>=nY4ozy1GM9ZeR4N< z`_r(j4OIoC4-hM)Hr;Pn1cywraK)f{1n(`VCsR~%JPA9RKtAI8@(!Q66X_EOoSHL2Otk3S$ zAq?M1HfcMmIfrcdn^eseP?{?f_Ho_6(x9QJDuZs_<@|IOn&y`@kD4Z(>Nf@uuT0`o z6b~{8l0aq~VHE&l8;uNiDy9Tl(NK{|XtN7=gP+eIQ2S-E2C)z!jYrM_4Rt3~8SpJ}K zngiMtqocUT;SVoInO>g=`ltqAf~|gV&h}eGbVanxzkXE_A>qLb>Iewr3z!(ph!Mdd zd1#(clf*SYY&?844!o(u>(xx3Abb4Fg7B1|0@=y8RLw_gW20jy<6eQshgV3Vt>Y#I zx98=oroJF$bih{L7+}?Zqd1Wb-(S9C8c5pi`z6P$wRe_b(>!E_>fb33+tJcl_6+8U zV{ESq8qr%@z3tw(xGJ8uXz3<^h^z`LodTP)&PEJm)yIX3w8>;zFSPD;8 zgCQQm^H#QjU}wIX1?#?aIX#K|k5G zNHsw2+eZzj^8woG8Tq<4-l{uz4xYYh*#I1R#(a0Q`>}6Eqr2+g!RE#JQL~NBK5HT# z71<_%oFai$(8br{Fh4m(Q$>KWEJj5^VU|Mn8M0F4$f90*{Mv=H%3QHRvE+rqT?DZf zZzGG%Y6^NtC!TvEd*{tShdfIr=_ZY^RkymV8gOgT!*==rSlsGOu7-nK=k5NUtg77v zo~5w}vDci^Fs8rPc~|;b<3inNx%POaN(uN~))*KSI7lb>_*8sHKV`NhS{ODLw7rMn?Yck1>+t4X-YL{=K_P ze3qz*60d>ToLSx3dmg?ugp$ z-Hb*{i0j!3&swA4{-ogvj{hK5n!bifD~;C2xI71ioz0F$w#NJ#=NjDPK6MwOnDOMI zG~*^AL~z^h^gG!5{K2I$GBVoa;(DcG*{iv^`Tp^KIKE)kcGbxEuZqsq38`Xym2u}+ zydDaxu`!Xe$>p{4vCmX~5yP&Dj}u7!?`^)UOJ(GUG+W0g*qc`^$k)@ir`BvXT3Rp! z-pV--P)&v^=pNrhgwRO^q}4@{@w4AIo{E$6yJJ`BiMFv480$IjKTY~jE8tSSUVJeF z+sxt$ff9GBPvFzH$0FBbVy8`Si+-Lh@&EH-vBC|yF>6PVIlv{;rIdCBxdq#VD#+`* z)x-qxOb6Tu+Uhm3+K}`wST@3Tb}*IC_)gG3&R%U=8sHtnZ~j`~SBbv2Spm4%zRb+r zXWQpnXwJ2}Dyv4TG1Q`!GuvAJ=se$z{cgnI+iwgi1h}}=YL*YZ4v(BaSk~VE`H?Uo zE-KO>6>2^ZLo01qXZxB2uiENVLaJP6^jRlRF!d`0<|46sblb_bs@Y?36*Kbn07gw%WX}H>0v|Hom4x zSq%%IqJ=7ytgZ-jo_U*G=xfVN^CpIAf}9BmkNh2eft{j!_kLq2wiyA0e}9PT`g89@ z8<~J`$C(y*yJlY_G?#$;ZJzg0q`nkoiy)Qwc}L>?odN-$&5^kprtfsezLQ8KCc-}n z+@{ZD*H_N5QE0WRp}DadRv?fDf!mbR_R8R3vkN^FA0HDR8xuV}45+FE9=f`~PZJZW z{zh+qJZX24Ad^R+BR_#O!vX1RRzd4i`2ONU50^GnWB=bwk$% z(GEHIC%XvTpw0_DQX1;omQyg^9B4goe=Vsbz}9j|j>r*^!X_E~bC!09H{e(3%O?8y z1TNY&cWJ}Xgum}esZFP8Tauk%iSYZqb=^&5WDp5$^-_dSj1%Fy-R9YZyd;02P3W%Y zWXpZmaAw%thxiiwvqHNerTGB&rGCp}E+4lm}_ zi%mmNyu0-C5)>&GxepD?xPccj)+~-squx7xw!FQGN9bgFx8K#oI`6*?;EttD?N-Dl zF0Te;0sO9FeA$TD7i~#U*M>6$0%?00oA+$Zgb9_8y;X|WE!O(=hp4vi3$ICw`#v1E zh&1|pI-yYi!ahN^pW~6=X$O2P?;GTAZx~Jhw+9&`0d0Y9`O3-)HwxfYuBoA-zTOr+ zZL7a(b;9e)68DpQ@CNETK>bmaFE`6hoDaSTH12zUH*O_U3_{VcvRB=O1Wx-jT4}NcjX;K9Fjk zV;CMk>uRE&n-EH>OBnb#G;!-B8QJ}byX>b3$6X@1r>cobB$0}YOz3NA!^YEbE_N)m zGd58Wc)oO}5rmez(+KQZcfTx4$IlZ;i8C?qUj@ox|7)o7@NdGU zS1stJK7Q?2+Fn=?CL=Ppm&;b!^m@Re_f;fHCkR}=9;{`1hHHDhgc@cQ^s~j$N47Jp zZ1q=@9l`R&b-O61qrvP$w2g#{fp%jDXqTB5r8Br_qP@;*_)AsO+wM%?nUzn07F}wA z;;^2ybwAEK#jRZpI0}W9{*6Q0-|gn)@#ia*@%^o}Xv38kLktji{X`QVR5J_K)+R=Y z6V`k3;e+-5!9iMz_tJ)Y$?O5jF{X zZ6JtUDbK5S7h9q269&eJsz~6M?z$c&mNxlN=2xQ#1M7)Qk~I27BG@kvy{|Q-Q1lr{ z=%q_kdlgU*0>s~G(=FpzjD2)*f$fVC#u~f#NP+_xdp4aa0+W9eewR78myOfLCj^$LyDc1MPW2@7snudi% zAIgwOBMS-Z9D%@_IM^3E_ayXbmco@H9Vu zLXIp0_l-_eHUa#UQ;Rdz3e)GSELGuz=HW9vx1JD# z!tregEDsjuQ1uxK7W9~(yyDRkf&~3oV~gFd>~pq;k4ci zo8Kv)W%c;ty@zR@^=y85qhlvnrd@`jah>J2dw$UYO4+&zxG<^KmZnLhXdU4f-kz(>Yo7x)KGxAE|thT!AUDVTMCja_$%jfoE2cImSZ z#6>dMPi6(>Y5(0PmwcS6Y-FM`JhK@nb;AQO5^M}-Zv>;|Lcs4dgm_Mvz*N-4#K{@< zu&vnGCeP!CI5=uM9G*zl!W2s(9HrO-dPvSHVFkq{g?(0!Zfk#2(^4=ZP^@>if7w7^h|zz8 zYg9J5@gk}*n2cG|nwa-Lfe~mwaQq9>eh7a5wnn~U_Rz@nV)~WpH;{0KK-QZ)=`>GO z?@>^sVyC5rPj}& zp!HlCs=f8bVqknQ(XMIR-Q7Wbbr*ECVK^av`KM|`j~*fvlfYoJk2~_Vo!@bM6jv=% z!tY`x^*X(QxsysXE+xZNYH?g|&9u(o-P~rSliIxBJz_tI1f(+pfKn!F4-Td_&XO4> z;(!<^|MQ;XIs(#JQ-oib@X1OIV_!0tyZ57c;yCD~2Gi@21qj%_Ncsm48LzjTSG@zfoLM%#gp&V>TJ!3gtgRkGi*8njB-Cl~d#f zQ9jLgzy(=|`MNN8BXHCig8Y@(+azCiUj0>H6R>`ulp#GzK4CJP!Ae8p_u^+3R!;dE znTk1*=%QAQUyZ*GyhijKg_&L)G`bFuJ}EQAO5ovaL&&Y+!4 zX1@k0^6r4M8vtq%@~Z98el6*ZFe_F0t2?WDTyTN7!(#QoSw}*s|DoY=pKhx#UCCrP zP$u;7CmBfu$II+s;oDbH*0MNJ7eJ9^vuI)*kKezvB0l&XO9=8?V&RKJTztCQ6O7wKO^t0gN)K)OL;%nuuG$D>PAu7~edeUwf}AUwsMHoV8dy)&xNFU9bq2~15v zcz@};@H{{6VTM(C#oRW_CsxS+Jo2m1#Cr=ST3V!9&$524Li}B3 zUqxV|D$HYL`>9n`L;Mj>anAa5we2hx6s%fN_Ome$tq;flr=?Lxo!K|j5rLDqN!g?_ zMH|yFW??zSDvg(84={^!2IH3Ra1Ga{TZ)%KJ8g`)-bQ5&z;^kf1XG) z58rX4e8*E{AV4b=iN?@V>Qf7k@=;l;pQqU z$2K39%p<6Od5oyPfAtO6Jk)vQVM=|PM^~%-C8k%evgrDF)g0bGM!-C<@f3mxumo2@ zEM_)`MduO{61T19FVW#9NL5e2cL&3&Zj5UT4tLKdm}tJZg&Wj&>3#dBw*cRNoN;ux zIErM=l1W^|(n#X@6m3va?Nz`ZI74E$8$Ep~c_8EwGuPMNvv%K!03ygWyn^B{S}GX; zfk$3>$)^->iGJhj2)sYBzLk4Z_k5TVHt>G_ilIcjO+4YuqR#>@tO#EhDuCwDZ88#y z>n?iM`m#F6VEZSx2!ncO>f2pr>Ay$c=5JI|p>@`t09)B>tFzPNlM7 zA`MRB0sR@xrtF(O9d@vr$?L-aez%j1d(-{vmj3hKS1*wZ#OW39*u-M-d+h2sOas&o ziM=vJ+M2vI1 zpwr5JX%vsIWpo=ISN%OOGWO!CWbyZ$N}3If1}MQ6q1!>sZ@ zQJKW$I0j1i#q`rivWPOg&Asc?n0x(JiIAC=lXe3=L2^(e;9}(>8}Gfv1i;3j(dIGh z+%mJu*GmmGQoknEs4^)Svs|xsp^y6!8Hri?{M%ax!8ya4t>c|gMeBTYZ!3OK zirq-_SK*DdnT}PK^tz|R&|X-SBOZ~xSdupPL*#Vqn8f{*pyC_i%z*v$0vK`Dy_r=@ zS9hk92>@*m_BZG2XMet5SJ!|N#tQFr47f?Fc1M#hCVR)|p&z8x3z&10MEMl~mr|5V z``}slnR@;t2_@nF#>w|#Lg$;Ci;Sxa%o^nk)i^vbGof6u@}O2e)!|fWdsXZ$5Xis) zO-P8>XgvxbJO?_p-_Q-X?7i5XCT@$D3xoQhc=KluV_8A>OD7e?f)yyiwrPHucwI)j z=Q5>-fle1IO0NT`erY>xfTc;Dqs#pgU?$2B7DM}tm}P1tFYD@4J;jb6PXGfkC1E~`ZX3Gudbb}#v1n?YJlxF zgWu?TR;|-mE+h<%1PzM0$lhbHp1&cCG{J6t!(E00k{$&3h4#<)PNrOUSE*K6e?ZiM zXV1x(u(Da!qWc$=Pwe^}S(DKK<6NZl;mb|x)<9>bzCRqJc<#xt9v96FRBn9Zp%rwa zJ^BZ=2iqWU`Z>yK_w92BS14VXhFN{^EaDv6i~o9WFnZjvDjRLhq7k!V!LNe6&FY4i z?e83@K}kQbK#*^Td?ZmKGGiBg72e$a9BJ=vF0VNoJ}%X(sGG3+)ji(dO9cH0G@wBARFlFO_6^zsjh`7;_5;$9Dbk8lIX z8_|ybCdS(eFrJWsav+dfvJ|hA(ceCN25R?Ew_6!V&OzO*Gz&ruTU&#0EVq!rmtQNDWZ}{XFtau6~HlPCe4#;^ca_zYtRTsuYHt z`MiFzX%>Yh<;k?aI(j%OwNbIP;y|?jcswtu?{jWYIcCiD!Hf8JES_eTtY@)K`DA0{ z;fwnGZ=eV_oKDdbBLssMjJE3LzdAt<ysZl^z$ zuoT8KD8;iY#({U=?ZhNo-0x10NaS5SYMe7iF#8UEST90X=*-w>=S1w9 z9UNC{S0mM~SP8IRB;iW;If!JfG3|GFO|c3oLyry4%c!K3ivaNyqcb z&mwn}Fh(imifZ>;f@x3;>&x0lEheU|=MWwu$`}C$gDQh|-`j?3iZFzq;0lSrtA#u# zVx~HK7De84B@EDei^Vpgrp_s>Ia}dZoYs)7F=F_Lw`74QV zz(s5zkpodEW@{L3fKoVP*{B!D#EMXOAlUz#Ufz(8&$EL^wa$Q+V6^vJ4sNkmoC582s;o_E+6_kX?3kH*WZQDzZt-yS*ovxS{alFysQ z%=k{npA|#{_Ni#N|09_B?G@B>3S@b+Sr=7U|)Oq+sewI9x-b(B>Ie-QJ(59-H@7 zJ2f_uwNo8E9sSwJzP0B=19?&{^4kqzb~36TE7 zv(siF)U=Rc@cBl>4VQ;drb29{Y+>P+Em1_=Itkv4{ZT<4Tk=HJGojchApxfJ=Kgem@9hP846rx1 zhC!4frX&T0UI9ze4-lurL zKj_(ZIK-lA;@)ht6xt5@SU5!`-g-PAoo%^mL?~nlIK4?cpeKf)7I* zeMCyhF~=wE|7KOJXAT-F4D-nB{B@JI%)}or4UXf4r~T9GU8|_`OXA9GJfO&MTNa7X1-Z2;D=a9z@6-J^)L0b)AnixQv+ALsLep< zxb!2)9IW^;_8HJf-w;oDRS{QQ-c&3g=t);T!Tk^CBTGtgbfX@_*+zW_g{Dl?F49-^B0`OXU7XV zO!9)s2LeeG5E>NRxH$4D$O$_9v#k9P`K+AYa3uXzXR5TRm7OMpCy7CWPSg9n{X|2o zhwXDt`OwdV>`wUKi2wK$j!VRWbF#!Fbzs=4`Di`fola#Jf+rX#taGj)BG^^@4LLbH==15A2l(@B9Z0OEc!>6MM+l?g=Nq=CO zAr^GyXLLVQQcLHn)N6DnjA}sv2~or_Wd~Y56Pp&9D2pd&u$O3OkEin8#VOR*)OcrA z^jn60ArVFe`3?{PZlC*Ey|(fvqyB&P2*!()u#iP|T>7{feQF<<2}tyMPg zcO2$|jd|)ZpUhL{$-{66o3;97A76)|)&1Sj0uv?tP6q&&O4Q zj2+f|p?Ejqdi~#+v|0f0&pS6kjK>DWI1Xv=e&AwaUtC-;v$9rhTIvWApo2f;7Y~#h z8{;YmEyu@IvM-kBaFEak7nl-961mk9zcr7kQrU&$gJm+8+XbfyD3YF*anW*YdEaUx zigAhBGZ6)rU0$8=x_uoVK?T{y`g9`a@Y3ehp1W}wI2N43&phNj)?XMq@KZmJ4K3Xa zTTfd42>Q&PM}Smm!DqCXt<~C8A86Nd-+#SjI{r2g_hE-73r-@962HwS_A^73*caXU|4UQonLF_0k=^D`YvAOatmH=yuZsMtyzun z+3*|zfnNg!cTo5l3A^V|&c+%knQ(wCy!t)-yw*o;LM5YfBSN{PE7YHj8Utwm>~RZG zX6OrBXIWy-Q!y9qe7b9cg(+j+V78~Z+#l|oj+l?{D32^KG0#f9xfW=FQBvds%au3T=S3J zyHax9oea?gZkq@ep*?p25q40kMx&R|rBMv8?o?{aS?wpNYO zL1b4ax)rs;p#iSRT~u>KWKy+NH{mv()lvhGwT-U~(Lmi0s;tiWw6eg{`Eq4!9%dR?fF`v1E0Xs*@*p$7_yXV{ZcY3*)jI9aa`M>Kqo}hsEn^A3G0QKPzR)7ZyTm zGW>xv&xOxJE_>&(6{6A0E(UMFC?G9qBlJUkS%Qd|@zUt#l(`e5hJ=i_#F+ z=$Z5PZ)@>5t`jTkhO$sUKPIy#@@aGod4SPg8H&ToUqX$uomNX4P3)FjzF64HO25RY$)m_A@`}Xyf4NWM!q>W4$`q&v zN8&Lm|3|g-JCsa^WaEYKsEWDWJW>Y>vbcFa3MfpaV0Ufthg5{){{w#l(Du(k$TO|lHHN(F{;NSV2(5Up2)q$9QlR*p zLihYG$?@rf#$(O)#(`kLJVbSjS{vPe9Y>9U|7ZtB`FX~M*R@9AX~xvm7}( zKBKB9>?;75ZQs=Uzj{$qhy0voAtEBIw_g+D6{}k?qL`;SNIwl^WoMNpG|M2CJ2x8s zhT&6gi$DYZ0RI}xALzLmHzC>5U`5VsG0Ix&6|vsI!E|_+T-&yp#!vF_2kzxq#agY& zn5z)9-jDuceSD(T8Am8a_W}``RJ0=rKlaZqLI?Ck4v*DeI}dLVK>Z&JZSVe&bnaH% zss-`gu8@wpBp%yVLRUsaT0*B|! zhFz0vFXr&PmK$<>Ln%Nv(h#z;0-a9jG(`HYK~F=a#4AUj5rURGKg9u19>#U|X}W!- z-c80gdLTTxu&~I9pIL8O>L^BC+L$#$#|d5&eEYiY>%7`(ie=d0n+X7pH~5A}6F6ZH1?(2DeeLgQ)}jVsBY3(x}YU(~1GYAhXn8>*LCnarQ>_@1SYVjg&EQO@6XEfbCpDhN?* zPF?7Rj<{VZBdK+-Kr?HHu$a{D1=FDItzgg`nTj+I+#XS^M~>SDF99za8!Si z{W0EaczPU^eEk{p^K-jirKB{AuG{1IPL(*cRKpDN_qdD#5m9>Q{km`Lb^6M?4Ko~W zT5ZK#RxmP%kicSx*utE>1o<7UD+f;@Hvl&nwe+aW8)eVYV`CzoEQcm)VgC1PGisGZ z#**n!O>HBJf3UD&osecLZC0KC?i@bl2Z+I%4cwM{%v$PurA{!bOrRn6QLky%_;vQ& z%F);sb)%(~0O@7TQeTCwUBP+~be!x7>CM}ACk(9B>s@3!PZaH#MSaqvT0y2W!Dv(n5(XtA4e0StjWN*jz2~28!wI+DSb9uZSm3GkF);Ius8ZB@km2 z3SzK@`olF7hYI09R6wi^cj7rm=$Q8b*E104$ZcwAL4NfhnlT>Xz>$CRl?Np%GBUD4 zmkrd^+-zwD!=e^Gh~y%6KUtA4o=+$%i!fjWNkfNmTI2J8$&6VJjhRt92`agMkIfy- zpoPQsEE>)%3Hb!751y3F#0?+e8Xe^t{ArP@j`%+DcEcyb6zV|_ejHj^9X6p`jEp_`8Movw9x$rlPu1Cf+z~&cNhv(}pR5PqsgVsqW8d#xKz?&wb zn)zm`{x)|}i#LXHyS^m?jwk28bmwffR$Q(G>PI>L(`e!qNm1u2x!$`qsJg>eBP`{A%+zqyo?q1cs?0w87 zxj&qlL*Jsxff5lC?i(9Pz>hBF*8N9ZjHIN#vX9iTPdr%5KdQD%JRZ5Vn*>?;{NMq2 zKA4X`F)`PG%hO9SB3!Rnj}@vpirx^CF`9r%$VCoDcP4Q&I%(Dd3X-}uL`OVuy6lq= z@AuOn;!F(N8 z*Y9;}B`FrlmZ`?AZjvBv#MItD5Bx)P~SdqV@Y)1rya z&GnY84K>Nph0X*$bZ9&VOGG}JAE1+;+?xD`!8{-CFmF*@JhYGLAz;Rm;q9fn=49D& zi^D&~7x5Fi9}!=H6S*%|3IH76VKzP-PI^-?H=^e$a+r4~i1cqAvHmTl~$ zHmasl$vuDpW?}4dNo9&TN`Lt72S)+m<**&U2QBc4vQVB80dR$2+acgS^;d$z^T=CF z(9CN0mla9~UcCV!F>x`zHwf(vH#{;r`iBi3B_-jTH`8q%Y^`Hi8Sy=rPn1c>Aax_7 zjD)zjggAX8BY&8F7!M~q`{>Xxt6%TbG@lzU@ALW9Bj^)NjWPo|ZG=yjo=u4|uNuV% zliv$AoQN@G&LKe-IX1-7VBIjM6kgrjjV6Y;s)3%XM3FM6hGehpieJ24;qktYMq zFgB2Q8ZO~#VZd?@kKrpFz!m@bq7hFfJAdiyRbF3T#YO~@d*TmxwpiR3x*GT9J()$3p;7>&xBF4Om%}ux zJe&HqdfLIhhrKFc_{-(?({&30x-ACQi2$tC{8DmJ5`HbOriNxe|B4p!JQ7t1)x;pN zq7e6XKT)XMhy&oF3wER|6mb`?tkp4LI2aSfd|=W@J8rAQ=?GO zVgy&6EVW%;p98L%dOxSPM39!Y>_Ti=(&=*Ec zHdE+k6|r}7EILfl%`jujb`6ILAH&=&-MzI8+j>;9Zf5BI3ew*TAy z%}XlxGJVXZ^SF5fibnGu?VQ4yWKd<}5ggvJ>UeXBbGqp1T_q6PoO+c!Jsr5n|EPVD z}*UF6Pv00@K3i>FY~x1^_2{{hb>xXnm)&`L^7(o$6tMJ+j{f4= zGI1Bl1hi8i&j#t;_)|FjU6U0}+EX<~wYa@dNfNYR-*CGY0Gm{xTC3hy1MgkgQ2*)g z^FQmZOV0;!197xwopOkpL>6bn<^Z4N(y?dy*z>cW6K=ql!G4vXLEcP}YxsBUtu?N~huyVl|7tJ?F`W9JohklLAM+#MzLaaIsolfE5Z6@|6>$rN;h0H8VohUmUhlQ|LmU zWh3IKncCgy+N$BkOzK=;^#8Ae*y$g=Y?I~I{GCWAS6ggGWQ3MhtTo1O?A!y3m=`hr z&l=C)wwpF4WN}Ga6;E=w7VK2<$V#f>OAk<;EX*Kw&Z=rnad7)TtS&n~ymwE+unD<{O0Zd&!0cpMe{pE1-CK7URx1}KYwIf_a zVz&I)T!PAE(Wg26=DFJS5V5@dWBHtx?ATn0In0v=0um~^`R`m@1Li?$5W;))PhDF1 z-(TH5md+kTjoRmTtAF+RN2v*jlFBK%L4FGExMOU%~g*j)L%QwbY0ob^9U z*Z_1pn&t9IJP+E|e?B?IWswe_%#rkb+Fm|wv*JyC-krh)d8W53#GWK`ngGW3#`0|H z0T+OB4SLCJ1eTDu2E8EUPkwx^#lXNQ4!n~x0W3h0Pfyo9TzJxXV@svxVLd%PKr5U# zG77$Q!|8$#+}AgjY8CfLvSj{{QL!ax1O>FXe->e9Qgy1XTG3qRa14mY$cY9nn=*~J z$s61Ri+>hA^}_Pam*3rtj{olLVDP!_R*qB83hlf*mUo3NQNVcrdad^5 zN0o%=-H_j!q2M2E>SH2}PT8Av$1a{QykR9_zmwb3+8D|k$Cb9R^y#{+HMGeWC}xf) zQqG;koq*Eb&0?cpvGNl9L`fs`yWAD!Hkk?Tu%!?y>pN(|(iW)%1VjtRIG`?2Fhb%(wifu<)6dfKZAT)LFi2f7Dk_|rnVp%L zb;_3iKM3zbPfstztX`p;?11M?q4!aHrqeW=YPI#|Xjb&?+n6mO{kIR&TY#n6a18-^bvmKR*WBH@`;W0-z?dI z`${DNwOsf>Qx|x%*Jw97#%A>|T;6xG>6?}A(HJ)=GUTUL(Xes#mHQ4gDAP7GNmLt+5L`F2Z^T8|Bcqa}T{CIN&L#udS0 zm+&43F3GLw0t?{!2jWWZCK7_DF4hx;734-T^l56|t5tI3(XC8wuFneK zB4%1LuL<3%fRsSziDw%p`;}&3V^a1{&UXLwMnKCmOp#$$k*s5KxNT4h`jrb|XnQcR zZtO^bDLKsCwv)d@100SJk|)I0v3pL3`CZ^S%e?19|6*1H(i!grobq`|UO%*=51ahV zPVheJ`JT9sdUMxkJ0Cb)47|dhnnw|(aM8R`XLi7q=;E-R>f&&EL+@7fU*ymi={vH= z(XK9+frG2u-U}+lPjN}Xt-Y^otqp0h)bI1iKZlMsVS}+=uCJ~%)Wo8}?I#wk`CsG_ zXo~6Uq?D)MMhia+jBk@1Ui%jJ?mG)z6*|6rlarGVjbj@t3E}Y+qL7eSn=T6EK8@t6 z%8<iUj2EbY7OIk@E64^ zr*%>8+*Tnao&JuesDTiJKTX?&KfQE`jyir-QWMg? zc}VWpizz~5X=Ml($q;aw@5(~&H4r>Fw4E^?QC)9(d8Aa(b!L?i6H8iY(-|wl0UB|T zgU>151bInr(Lr=0_syv~t1)CUl!X5k(a?70R97!N{p*q->9XdhfyinQ$mtiBeJIs$ zHNSh2*H@*SK*3&CyFUUPuCRG{;M0hHeD4XF?u0n6!Qp=2;gu|Yg?{R{WX|mjCFBV2 ziiZ7Ku5Z^AU8S=?YuD$&Q>rM!Tgw-S=c?ojC)>HVqvqzuMA49Ex}CRpV+l!SGS{vT z>l-^&-RJvSAJ~Ko1#G=RYb1I7+-_3-aN`^J4-@%WtMs`5OuGy?vO+~X)g+r>GlG|z z^IUqQ$|oAX9k0pId_%!bGrIrRF)f#oZE*J$qvn6$S!@CIdVG3awH)8s%S^6usitDIIF%uk|7(S+S<++IryI(}J*jASV!af=`; z(sohr=8a2i6`}oO%Tj5dtpid9vZ)MobpxTSq5GS=iLg0%xG;k7u@~N&7^<9ZsbbRz zOii9YYR0(JEVY{XJ~lzlQ9g0eNQ4CF{^F&j_|(^_oWv;oA@ppw_n{4+lEU0;sseT1 zX{D9_+91!Mg7Jgvfz~auGbNAV2@8|h#jpemZ&;!>xRo)lNB2d(B zI=D@hc`^Q$p+{ZZFL2h?$kB6gH7ZS!uxhiU}cx&qls-}uPWFuwI2CATZ#$#>^AgM*Y$*2t;WA5U}U zycRVjBQxyH(4#@)%fF$JCnb$@YBo*%HaKeQ@tz3ElCSv&(r~gk8dq(&yQ|~nXzD2E z9om`$gT9X@XPcWdjere==K*Al; zfVH)ry}e4rIaz^3$g7gEM=R81nr$@7c{cog@D6d~B>~dP_D9l3IIlJ2?h00*W3q-1 z2*r~iN)X@PDlw8sUab+|pK0w_v|sJYOlqTzysdW^RjBFpQ=MLI2q5Vo@o+_F>pHdKwRC8pq;3UTQ@jHRPASteGAy zbIdT*_H>SjvnhhUpTTNzi99s0at8uHCtNeyV-;pQY3Z2}IK`@9jOJ zdx$>xrug2%!YuP-Dp6gE;ql`Nk7aNd1 zF2vi7#`V?=wFf($`38=-RLs>Uzyv6!?!R?^^spMG8a=!6@|C8Sw`+Ij4>v;n@d|j1@|Q_4 zA}J8+9}OTrbtbdeowNBwVg)+MMZBzXIGLvV%W*y!Zc^|uj4o}1pP@`5?L40o9+Twk zynSb?k#Gh?=e#rJJrOy*C#5)_K77wUj01s^R=y2>i%Gw$bCnr#^m>z^qoHxo9i2uJ z=C(7j-f#W9`C~~eS>DJ;QOB#0<$8kUE|(`=E*JfxrUL^5^OYWS1IN45h;-TWV_?v; zHjDzMmJaZK5;>YLhD!vb&|~9KmIe%>cc`Z8Gr)k_zgVvBd&(AF!BlAK>++d55ztK2 z5?!U&(r`B~<3DXQ+1{hNJ1pcm0gp*R~}WYOj5x8He@}La$}_M)Sh@9Sy1iHh*WE$pkD0 z8y28w=EJg{0fl*bSghd%71X^V;zPn$*(>Kp5d!?&fi=G0o_w?YV+DE15qVEdfd41R zlmnhCW!Eb6{inJa1QJv(Hz2sleq3+Qo)So|vaT-(3QB8pR9iJE!fj}PbQ8f&(8FOo z3_KuUJ1?5F@9z0ag{_wy=Rys7TdLJ|E}ee=NC4RBCHNEv0WT3Rq_-LLYYM^f3D znbPmtlt*9z~6ad=eMa+^HU=a#?tsW+*EyE-6601bP`ah2ai~yFSSrAGVqu{pjCti_({$s^k=t!;8ywJuX zvx`eb_^@^a?0Hq?$+`S4)|#BMrg{3_I}=7GHN~Fd0-TfqLWx`Y(Li9^oo;a$s{4A{ zrqWFv)fp7vIbCE-O^-&~GQL}&9dkOh*-xPh6^*xy1ZhHqG z?d)Wn(urU&mFmH0)_tf)LAbv8Y{e@Q(0z^C=zb#T*NTwI(^#2y^% zZnztubq%WA5X4b@L30D`9Fuk!$S$Hw!FcHtR*oc5zy}>gqDU(C=40bX2}LrRG{hFS zt0V_j8f?%5HE?5J$Fe#4blJDE)e$Oph7e7;7>SgP!Jl1;UTf6WmDNuFt&XDn&t~1- zLC+5R;K)*1k(1oJ0Kc>SY3<9Ae%ox$$r?vr@}!GzZT-k(#fJK1();>0_hT@=HdS=v zr+W`&^TkpxYM$RyBGak)bgx*qWL8@0ZlD_sJK?rW_?xAIejc|3HS<^UTsWcl^0pX$-?1U&^5HB!?>1&fuTh_JF#@j=*N->? z6RQQd=6@{T{X9$Z@{A;r9VoAfx%R2^cB8pBBU*?hxjJu7#r-vtW_IQ+1-uF7GfrF8 z3tlcl(zhK4911eNSAF#+ex&|Klm6>hmZ0jep_SX@4lza0(>IJIyPUoroiz z0M!0HCoq)Rw54l%h4c#kI=(F<*FIch}56{$LG^*sRC+wUr|@zaJD_BngAG51eQPU%5FGkVB(~c3Pm?eu|0TN>qldaF~vY>^A z#c}6e6=DR1v6pXTuA1tC;f!{HMUR6q88*8ha5LCC+GiauBa@K)mxk+$>iAsK;!9#` zrDbdoDv+`#aGub6H;aq8#_wPhaN8ncTF`9O=FhEd@bpQr3MXuN;Aa}C8RrX|3#9mw zMGFPFc%PTk?Z)!0+40#yga7SPrR>_CR71jY=xFtb@;{Yco<7R|YgnVX^J5>}CHx40 z{q=ug=NMO(Ip?#SH25v&2#-@jF@NZV9R>2kJVx3de6Bl3&gVSSHqSPy0ivy(_eO4e zm$8Y8E#f=b(&lC$D{Gaezx`xLjMm9NKgQF&+5Feno^r`{E|r0efPGQRg!A(%r98;q zOU&+eNj|~KfIUHn=ZV2Se!=J-Ww$DG5rU#o+dUF-rd0s+M%pfqi_-5VTt&DHU}Ja3 z;?WJn%%S6jUb}N6McRrX`Z=7DR&&<~NIvEtwXnG^Wa~E=ap0@RiswHaujt*R_z;UJ zlI1_X3F2b+kb)_4G4a&mlg&`(ec~Aap?7>?PvFExS_eM%M7j|@oy2I|<3Kn)_YyXjTPl`?g|c8M;a@=vB})b~mJnea?M;VBqU%D(CTh4m7W zf5Md;EL4Ec$%GxAhpgYl%E7qx6>*yC$kCl0XPEQi1Wb1jGH;!9y*Hv*<~5kOgLZZi z{p&TL3Hm&3s0yxw16nPf_hDqACajE*ff%Y)YFMB(Hl<93MM8YUDB;~QR{a9TspsKF zGe!>%@3y>J9k)6>Bn~n=JC4n4%v9sx?ynA7?HSW88Ml2QeD{?UIAgcc14a*9D}g_f zwXPpsrsQ8a{XjF8f`aAJig8rPSt#FWXq_`d(1GnO!495PaUTvIV$Io3{~(ojzh3mX zUm$<#^G0a!3wpv`S~PX4!eW6SZ7Xpf(QQU;by!Xplf}KoW7}!yh;gYCi*`UkW{hqX z1?7bkUDG_P;H`(OjJJpD^Zj~Jb2+_zJr5onDdH!QE;~^$t}pNUAg^?+4rofSO&9i| zJ$0T;2Q*k^gwkxG6c8PcTv+NOME6M7yg$h1KGFQ@}x_R_23Pmb?@zT^Za9`#& zDl&=-94Vg85{dyhCv0n`@R%xpxSWOuoQQ`6y>UD$f(pyNA)$j0B_pXoOiJvg{9Cnn zik+d~BOheS?yJbU=6+ibUJuszu~hLIDz;#0z^=0gKn}th;|K|f2L5zkSuO9(0)v;I z=pk~r;P<_q5KVgX&uFy`_lAqFLaOO6pjLUBB`;`Ib(-)&_XA08{~YAyEv@y_YD0kb zvw(<5k9`I3C~T~WYEop6o#BW8JE{)rR@)qqUxUclKJGUMi1M49JRs2=K1tSG7X1TK zu30+R3;8^CT28%^aqE#Q%n>qf)0=*tRD$A9;Aq4l+1%K7PPp7I^RH$Zd(Qz-(2644 zC4{@&GPK=6EiGyTQiG=k0{U^I)yK+u>aySJl=6oUe@D)6qZ#irjp9e_7fz~5!!iho zT=|hBcvq(!b0|Ku<|%8h=?|!a;@I_cJ@7W~e^b1YcU$G>ell;sUHv+3jg1e#_?LBY z`4#O*F_Q5O-rL}copPTp;W%GsY>q-#489=A!ZrV5|Los(AVl2#>OHT_%=JmNMwh**L`HpOy9}K zTl%?f$-jq&A+H(k6Pkbb7knUEZ(8&D=6btU^AxHTh0;=T0QP*MPCh`bx92Pv#M3zo z!jDjM*{<1Dg8`tPB&FB*W1BDTS!!a&$3!lD(7UGFjC(PZuJ6O9y;!`KM3sE3HDOEi3EFTGF*X=)FUio9Sn+M)Cntq3 zsC2$H4@soS*}Ac&YcUS7+w%Q`YfJL*CMl|YQObQq&R!)%B7rpezf}R|>=fI*LPVvM z*}T{)b|lGfc#W@FG}fh_A?Cw<2!LLnbR`)esZs(n)s)}F3|B)b47BJ>fMWXgrV)Hz zBdOKh<+ukpq7(YQ_UZDAZsqS`P#Ci#RqRk`Foy7CHQ#`Jv)T(+chx@RsCR^9X3qxr z?{-6_>A#bB`!5)^Hh&tga^jsoLajB)YQFsMjHD0~OmNpDUoz&tKSi#KYzZ{nL-$nv zowx7MFjVEje7SmTqF*&9ND~CRv21Q9QpCq#0v8==i*!#C!RyV5DtW_j@lYs{azlqd z?WM&=9fkj#pQL0bogOJ?UteCGY)ni(=d3#spk&U0{wt@vg<^K$A@(SyJaV|0qQ`B8 z9kYPBs{`=MSC!LZuXJlNKVgW=zt=cslw}wzP4jY$b?6bGuF33qZX*`PdF2;Iyf0Z4 zP{nXJx9ITwz$kv|0^FFhz%wkr*yxZNaw!Dn z9C#NBd3FpG5S=(&9equp1b};lrg+vAa14WclPFetR!1(n9>@CpEqrw0`biwB1y;6{ zCB%V8Xh7sOExkB8Wx@DE-&M>45F3*p6wjZCInRFz5h+RfmZ0JxH9$vnMVnYn8cZ=+ z>jGZpFcZ4K(FU~jn0;w*GydPM7-G%8ScA!Ob@&#>@f3-6SDb2@Go=-gz|NEqB8B$g-$j{J_VMlEhM9r7G0VzXq_4b2j5K`Gr9-%(86m3$o z9^4TcA>Z{6gOpf-&k-3aefRjB9)f|DCqeQ_rO32D#gr}lCdjkodgfJUQ|`*+ zk}W+!TM&pzA`?g=rfWWq!JYQ2tL;1+V>F)jR(2Q8*X}P&P_qN7#!--@EQlpk`y*K5 zRP+K@$&(QCC8cKcEQ^IPcx&}(_R5$c1RrSoi)Xrn(K)aNJdB*nZFrwikS=VElchCK z^R-{oM1v+g;Z_@h|FWXMe0)fE{FwkvHUG1Ca=$4+?xWEEiQvwVQ2q@&v+kz1MvHdOYC+Jrzltf60!}@ZG5p28>U&pi z7B2XsUOeld?zH4zxMohe66*_M&vUOame6S1SA5QqCX`i4BKb#}>~vwdeZl+R{v;VI z>s16OYjs90e~fe-s1nnD{=np9i-|O#qCf3}$5rFT%t`St7QZr-%Z_bUi_2sAnheZs zm?XdV##kON^>jg~bDZM$FBc6qH!4$t>aQomxRW6Jxpq{3+!!5w5> zMwXiDRi4*wZFr+gBw^ zauFaV>e2^T+?7$mFH4r5BpBOJ5y%^$YV!4uV@cdSd ze3zl>qVW%@gLksmOQ&p+t!791CM1ovV*n?_+arY0EtUUa{@>NuhMqL&#-ZMEc} zA$s8DK)1NLo)>fI<}y|sZHS?{1r2}IZ-@OCm{dkXC_O<->(KYiEv-XC{5v|hLiZcDogNEtS16_59zIN_UV6y3gT9& zI?}IRZH^4h$utCTxXxd%`uk>?usTzI>bIWrdLN);Q}t&-4A>i9axMD!3rloSM7Nu3hB%d*0IYAPU=Y3j^3@48}px5Kyo3{HK4 zT_-ckUujjS_&O*>P(36s^eI?KFp4uD^BVyQWaYLA_B?D=u756?t=z0>;}#wRmR`Cs zG2MGK_F_VMc-m$d-z!iw|GQ|hvSToN%t8@LBJ&aRiAv+2g!6%C99EQBO`3HA!s|^4 z*b$-q-d@9@NuI>m`A6zS-8BOy@*bg-eBCB%Agqvq}A@X3$b~2)ItKFJd*ehY&z@c z$TG&Y1ozy7F*YTo@jeA&frkJLyPN-jJ1J*P5ERUtQh2*R9_W72_XyZAVxNTt-k;7j z1zXMP#WMeDM<{*5z<}!M%{f`UB8)7=br+s*xEn>BMr`_Nxe4artG8S5y}lMXaiUl$ z({@??d&R?%hRLLnwNyYdT)ndDs`R&4Ccb@8u%+N#aMV%cBs1SsQo@Ia12dc%^_Rce z+0X1KP80wx=_^OtPp|RGBRLCr+ZmS`IH$X{M5-zycg=RhkTQo?n z3oSA)A%pfatvgPCwREIWihn!v%GiaUNtu`KF)Uucj=Fo62x=jLwy?rBompnhE%O&i18 zySKL&g}jw``gFx!OH0!w-V_$J65l%8=tVEHba+Dd_%qYYyJLI{BDPu*Ur93%sD%a# zOvwy>bLrKXKC=G^WZ(`oE_n_4xSNz*C+rQxA9tM>{JPYvraZ>ncc##%m$iD^q(?ma z{F#GOV74n$eT#{}A4+DBrfW_3u1wziN_fuIc-1gpOvxysexJ4EICXW%ayNEjK7VQCzj0o8U!fgK>YRD^GN0u1H{C^A zkI^`lpClL`%NamZTp*vW)k0iNuO#`VpS=ukH`h2GKoIJK8P9)O!zElC&;=RsQRgM% zHbw*25h9DFZBj|IIB6PveYeq#%K|W}muhK%-pye$zC^KXEh1s3Oq?s*o59ZENYW}yzEsTGilVd2L;uoq3nO_W)+|B(LuO`W0zB3yt`q2(na-%QbzLXadbUNW`k+HzkI#W z#nlz>-B~&;+t>DJ+o`Uxai0GpW9Niptx>67=r=Xkh|LSpC{NITiuMRK4s$j5c##hG zYWeVp1OCu-W?(Hy127{@!CZJUQ?kXkuZjw?sg1?#dVAw{9`zkos<45(P6;>D7cBTG zWR2WDUT*^Rb^&cUE-JPUVE=)|01kOvdDD6qkDtqq*bf`9U-NmzrR5#> zUmC73)`}gpnnw{@mujOkWYwfx8WE1gSrbL*Y4W!2IZCN8vTMMz@Z5Q1!8(`0im@$CwHudf;p@F`(QeJs~sM>e(pahU$)EoNMhXm%S757qDrF-((wmW$8K>A_Gm z@i?}p-x>84u6^jhA~h{n{m`m%+yCEh`gJq(?S*2{Q|VE$gH4k7(sn5-#DGXmG1E7I1QCYi|>|J?u(~q7J~qe5$nMBeUEYV-{p0k`siK%(km|%fiY+ zE9xjmF5$9dX=_`o2ICQk#sCBppqbp)VJ)Dbs4?H2iNli<_fowmvdXu=$jKLD=uj5X zae$PZ`7{p@6Lg4=HSiyUB|SsJZJ7ep)T(5wDrr3OUJjVUQ2`CB>WhsvkrXL+fNs$0FQeNxKhqyNUE?Y@tCs&!uEqgk@Sh+^_$5 zz=r2d-CTEudA8MI$O*m%qiO+uVvBcbcjt^CPosA;RH0FCAQU4~Y)W&K|(ip1VFBpP%;$dXRTg zDr%N3v)kUTNb2T}vhD$*6)QNW{pE0000oZTa-AF}=w;t`fttkxE`UyxOPMG2r5vD@wwNWdAP@RXC zn9$b3Kn+NY0uXA_DGX~;rH>Qa+Q9N30r{2VmBq*~W1t|-i6|Z*elOKC%9d#Yo=d|E%ls|`yNGStZ~R-jM-GvI#lhg?*DJR@Vq3dxr(7?`0~ zvO80s$U7o_y!LU_7W_wao|oZd0qgea?9zYlM$(eFjt%ICN!%KFoX7XI-&L4#GBlyn;q>iZ>#BE#;6(S&mr|)Ug64&vyYN zF#@9h^T1&6Uba7qdH*=vEwdoy2(Y-zCD!-f`1p+}AtJM9c&zQau1pm?E-i{-=A8vc zD(pH7MsZ}{cTodx4nIU!sFmP=o+=9w)2^(oJz7~84UhZ_ zjB)AdadGp=g<4yE0I>6-;jusSutE4-O4n!~S}dkXu40p!wg%~7oUtUV#zXUvE82(jud_wj~|0t%r9(dVJB+zX?#@}W) zUNRd8TSZ<>4|O4@8P{?d$Jw^xt(if*7OS@}>q7u#482zb%@_gbhZX=&!p_kwQO}U7 zN4H=AX8O1Aku(){gJgNlhS}4tt+g#@v0nh1q34TMB*yy6T0?z(@;qw})D~e_z-#8| z=@~Z%3=wo**mnB8T0JQ(C3SS97_FDvQ-V@0%wET&6nkuPN7fC&~vEZ@c@5>;3pyWX*!W5hEY>r~Lo4&vrI6 z);%?qs(*i;^7zZgIhj{|g=SVd*M3yGk+YRy`t-R|^LU)hDEUj>*+^Qf|LTOBQl2+D zI!$j@T>?`+a9O`xy>RGGYe&v{u}98BNG(H|UpB_3iD?=i2ptK&#ShLA7 zjX;s#8Bc3k*HI85uT~J$f#Tnjv(P=k7X&7GShIKE z7wdlZf1-c|aJn>nq*$r3dOv;&8|gy(dvCN!UpK7%o|F7nJL>T1;~fO7X$P>NQv_gu zv*JDh`u?9g3@}x!_orLbPJ%2}<%!tz@_|qkqY$uUxkTsGZ1L=|Q@lJW2&ApWGX9YO zdvLzp3yKBYW=3C|!4JS}zRYu!0Fr;Co`!(!ht2>NC=W`OCv``4t4MI!)o>pCk|Gm8 zxaW{Gd6e%?QCz$A-=w|#j{dIn#h0=&1T;7_;;ZZM zZ(Hitp2+5go@+yVh_IHi8b;4eJ(i~W_>_>0NQr8njk>?5ZBj(5JdT3gWYHD8y?~XW zwH1Q|mS3FSUiec3BGd^aIrGg}YOpHzDw21xZW5U&lze}>@b-LuNdMk9tIdDv&XCBc zsGFOcYC~dXY>SuS4fPELroW#mC8(9igDQD>72osLUtF(GD+QiYJ#RY+-Y~u+PBxy&*~=<;E$@k3KgB_ z-)&!Pg}*N^N}N~C+;Ku&3GL|@bUuYtijy6b-nI#8YU4RwaRk6vaB*&Ljn)~EUcQu447z`Y90d-2W^;OA!$ z_3X1tH1i4UyFhIJt8Z+yAWGmgZFa3!PEm6#bA)OcBscw*GBGyM(&7iLQNlo3)O$Z! z4XJDYn1+lgjPn@jTV=~|cgSf(^su0UISVp^pFhYzi4^M{Ch(GM3cI`+I5dp0oo*c? z4DxGVE8p;7Px~YFeyBWJGIvI0awJkyH3y&hEF5Wfzvu4#{O)MbEgd>|)v>)_rmWJ< z8OY0QKh>hJ=c~J0gPp9)tMB67YQbl=P4(RFxIQI?@!I$N3&Ct2gDpjLY7ufRU#`X? z_(_$;Wkogo%(y)22&(v9eS_9&7@s1SMuX4Uq(1H0q&hISqVNN$<5cOV{hM7bb+mK2 zTR`@QDaD!xJco9LrPSV3ZKE>Fe*w^0G$5@@Vu{r&p~8?ly7)70mab!L58VISark_T zONL=-vkyNx7|uSrL(y|xv?;6p@VW)crX$JTJjBc*M;kvHE;;YonV2YcJBK}umy8X??ig(KjCYz7i zW&gfw$*_EmSF^P^4{`I^+pvSE@@YUqAf`7xoW2%g5t&>D(iS&?jI%PMuj3#k zv2F-8{99ra4y`8b*1J^6>dTzYSFFhC^MlW`jHI?l#$mqfr8&HQw%3BAYLHRh9NE8f zG}p(AqbF^(xFWtqM%C$RU&vUXM+lScWcAH)j=xCt40J5Ck38x;sPXvbM<6jwcChfp zADpqXc%YF-9qH==)aIfJJ|?p7H(h(?UwkD*nx3qFh6DMcDm$Cs2A}6H7cggvn~Upm zWMJ+0@fn>-gS!Gg!eQhJ`1!|=3>frmYtrJXF>%V1&iQnB^?W{{tAEU6beT0P$nix1 zl&r_ayEEDVNcpdidcP!cAg9U#B#8!#JxUC!WF>>{ZukNoIAVa}1rH1XE;NEo2nKhdTqZX@+uun|_$nIOFw$D@! z^d-C|a$yCyFRM%pJx_Wz7)k55b!DX{8`Ovx4gxu$*C6GU=UOSyjS5b7w28DQ+<7K~CnrhahsWopEuSEnyWfG3aYv9*dI` zymsC{V6-sa?|e7Ne6Srxp-xAHOG@g+hKcs%(1M)$&d&H8t7nUk0Dfqe#GwHhq}{n` zCeU4-1Q=stR-H3_eJZ7`<*sAgW@ctt>wJ$&T-n#eG~KsOzt$fk#nMvmjnr~ORRb}T zfQQc%j#MTm0aZTr6}4X*sV;3|+Fx4J?Re+b^EpiOC1#frMPB%__zxCbVxrAUTclb( zc?alK)p;N{Gx$kj|DsYi zH9P9j*gFnS&;p1Y5}@+kC3DN8Y+o1v!{!ot3PjhJJA3!~&MBZ&3`Ox^xSISVIFI(~ z4`%105WYuO(~t1sm#YUVML?-3LyH=H%Nab8vRkF4D!% zEj6ljFm5cn+4P$B24iQ+{BR3G47uO7#LiSH?n2`TM!g(1BAy<0STQ3Cdt;(mad5hY zhVrjGwv;(1>-;v)?PMv~U@ay1R{wEawy^JSU*6LK(}pLMG#bskjJZ&a9t(TVH>{8L zjXX9WDPJ*ovbG1MI)LfT+C5{P=4(MXxH**Fp`BNafs&QFIq7%lP1Ilg5$mNg6{Nz% zxS{xNfn}Xgw)I}l>VUe#z8eIL!G^ltQm5ofhP`8HOj-IS$s~@`Y$Q${ap~H1sUIlm zdjXltVD7)_!ebJFpg&daY#U69e?AxxAE5Dg7+ryS9}yK}ayXI~?LQqSdpFwP0fuKu zlR+;I9)jHge^}2YYMyUs)4g_NF;pX@HCUB>63iP2qMNe{hE1DW*SM2r|{YO zi*@4X@=7b>xe}`@|LG4_fe^V0#^>UGhDP&HS^(9ttfs@we=8v=uIy)#X;&{drKJz3z4hF=#!C4qGsyXG9>IJ2O1Bnz7{m$Dg?49+ z?xZBsd-PcTEByDcQ9fnN*jz&IRayh2p@+aE|NED^pn4vwi`wffd%8RSu&dL6=uno> z2_hslsD&$1Bt52`_fTxP#ymPRug+s%s020qjQLf*9$={`fUvl-Jj~aG5822cidbDj z>I*ak(0Nal&}Sz>px{3VIijJ}`$dNbYO>YOw=ZPUUkBDToez?jez9(kr^^h%Y$y_= zpnDYJ5Qx)V>!+YNp&bdNc{a%NK8dSZ?uqxXPp$R6DPKxs=qt z5;>U{EmEw86O5I8wxkNr^W>CoX7BF{`wo=rL{CbjUH0MV!=jpY!>^d2s4ch*w7zTy znR@(hEAg6tO=?bJryV0kirBWp`of{5K>TJ}-WL&da&i71p0{?8j}i~q&lVqt*G3=uoqi(wbtLUukw1q>RO9=DO%4_#dC-ru4$t@& z(ea-eR1@#rXXJZ;Jfqd&a^d6&iu}eIiO-y-k%dLF@F!_T=GfDEJ@td^zay06P~p=E zeUtM=}Vle(AS==bz;*yc)?Ul9VjDZ+kLm&oi@T~v!YpP()RxCop7bf zo_(s-s0(z1G;zqgQH2x~KHOMb>X)4x4sdGTbj?Yq&;Qa68z$Ksv>mnZ#Sz6HXR|^i zRL>&tyRn%8$ZRF=wa=y0)ONvWbw}IMlUR#-zI*+OW9tk(4{q$TcUEKn5I8NEgnNqEj;gxp!9VT`yHXxbE$s=R=0J3Wu=C!ooqcz z;?QsFwxxh>$oq#Gt%XFEu11!Z^ODp0GQky=we^afSHcb)%1dJ=bjKqjisujf>PqAK z!9nviYZ^;xq0pO}a4qfwV+F^~%ox9YI|^6#0xC*J zmzSLK5Zq>zpMaWy*YMU;Z3e)MmhMay#wCxRYH~IINtqQ$*?n$~+^4&F)KuoZ+4KLL z84gSUtq?GP8B{n4%zL|!VGi`pLyo;%rS9&!Mc6*BLx$D$JRaIn(gbT1>$-%bb(i1j#dhF8ulS_tp|7?QzZC z46p>rg%(>op;2F+>;H6jI=0OAe4Uw>k(gaJJj@mYiYa?#qeTNjW=se@j;_+R=R~Qtg_sH1NoN2}( z^QMq^2-#n=H?zO3rt6`EhA}?@nU%Q?4d`v<^h{em*0d)UQv{u!=xO%eshrM%FxhDzTBD;+G?ZII`p8G$~lMZ_PT&F#?B zF|MppT7_z$NF+gR!5)UXIL#kUJN78))w+L-b0Yz&hCDYzZ>Xb`p9Rp5@X22|L(1iI4;ehSG1@}424+R_efKr5^L8jJSS>B-4v^2&!Gafv#m!Km51_9h}v zSoGq2zcaWnKm>p_2}@(PGi3e*?s-l7a_oF_hE+D-?!DM+uHVQ;^QrE{gG|REO zMqy!JIN5(z3I>++{|rZO{bIY5q|7ZA2D+)ef-M(+HFsH?fLi_Iqp>X5)K~RAs%v&^7}w$;+|o?C?Be$lcub9s64Aa&wd&CnR#cuHBCr-W6uVL?8WDY+rxUORx# z%zlApjl>EH^DJGa>y&sx!srA~7Y<2~iIn!*$D(-|@1Xj5rkNpYkhRmPPdyAd8}ROo zwOReZ2tyu_+wt;vw{d#{5J%#B%QQxLmeZC(56ZkVOfCrgX@`?FcIJwd| zDKp>SQ+@Anw|NvOGx19Lo&p(t2rXXjuw|s~Y+o@Rw~bd^Yau%*c&D5$q%CCHgWBqc z(ws=c0YXcO)xXVt0 zgh4pLE)f`LYXm9ivqp$Tx*caEvkrT;N8Nf>w{$W?-AU_J2mSgp(>?NIjYOuHmZZzV z=Z+B+6;6W(`mXkFUM7Zh+y0>`i_`I?5~Ifot5P?Qb-ygZShQ&uttKwDYO_g9?kwGg z_XrIVeLLNMt;eJm0jNW=t_ou-K>P)Yf7)bbZUd7^h^AY$fBa;pN6LqQrmx}^9Zyy*KBv)0s?FIP(Nk#<;?n4j!VR2~ho5tJvM|feMv~*HF)b(2naC5KOdN_g==rMprjf2=LCX69}mdM~L zzP+{v;KCJ6=S}*TRs8~|d>8Z)Ii)<4R`=oiPyk4pUCHG){kPaUo?MsU6M^u%p}w@E zveL7Ti*EEJW`FIDyR%EwP1`{Vn$C_Sk+`kd|JU$S_oDC z*N|795+^bkuQIPn;;WDW+8OX&70+$(XnW3xz2xt$^ZsM}c%e1U_iOi&)qDmM(Ctvi zD2&zU66y>UEIdX(K#^?~Qz_KO89)DNF+hW4HrwAk=w8zvCjVV!HVa2MB}hX`wEMgSWr?&=3RvhY^QTc>v z;wu&h`sU*;#}`z6>T;Uqe}bdh5TH2yMeAwgd)?a4hbhc2`n1uT3)aXO#~|S@Df7En z^*bqLd=w3SQR6mSnnoxYp0iNbjMZ7qyX^hKeG5a;n&o`H*U*Wu#$V^1g<$9|4ftbW z*kWUsQP7LR!=B}j#B&D6r&&5EyZo|(k{?hAU>1RkgcwcK1`fYOUn?YbR?U%|| z?eDzvJiPsbx7$qC%}iYU{aici{%Sh3Nn?C7{vcAbHSEz>*dNU6Q@gQaCQyl7R%AVt zNJi7(Gpq|_8<+2tM$hm7bL`0Ki>LnpAuHOJ7$}TwJp$odJbk$JQ3E+9>3MogKTNng zz&mq$Rh+nqqep`EGV_hcPfK@gipMwJ2d+_@>83_5{@&n?#c@E}%V&7W6SnQ2bx!*~ zmcBYHs_*-H5Rf6np%IZ#>6Dg~Q9$XTC8fK&5ka~^NkZ;KM(s2v!w5* z$C=lt^3fL;*2zi`Dyitd?M$Wmi{ZCH@(p{^BkXt{C5Bl;59?Kxp$07f%e(*7V*dw+ zLv@>-CU9v2>ZDCF-_^{QI6NG0Rf80g#^ZLAL_m=oA94GzuFe=Nd3=c41|hK@SoIR- zxJ_GN9#&ZShL}Ofs2t%xN29(0g-+^xgn%3VKSSH)MlWNXAam{ z%YJAyJ05cYp(%JW!VEb@<%F|);=0GQitR%iSA<4Gj1YFen9 z`{1#@X`c>Wf%4ybj(ntUDc?GYx6sUe%wW(KI<^EYxo5?Nk2nfPcI-=)mL?}36h0h| zbn5+U*0BOcMGKkD{Xw}>4zU526!#ziy}6ZM7`jtgvM_+*=|-(?&x?6(71JN{{uY! z)Cx@L0jn9lpY9iw@FHd%N3kRCADCy{rpChK23vVNXC9$3suYyi`E^NVX8_mvmC!x%@k>?>OD-efB;%ppida%G3H4ql|J7&P^2&q~n`H&C|y` zSSG~7j`nuvyOzB>_+#$f*UfW6IjJ>xar_nIweq-z-(R7TKmJ6^$xG+iaIg}mxw9j* ztw#-H{=kK7ld@Oj>#f#6l zMlzS$9L=R5^+w&3=FT1=Z&a$&3+pldo4vd@|B=a{k962LaLP3ACMY0iP(n_Z9UG(; zoAZN%`Ef?d=FQOTmEFa^WtR6lHwiGtpuKMHj^>vHeYy}dztNPWrq!PH^FiO5 zrtvDVKn%v>v92sHt#^oHj&#~54hoN4q3oI0!}ryO@rR?~_3Rrqugc|9RGDwN zIDrx8Z=P}cYP}9_s&w1V;80`<__H1RgHlSPlI~1Rec>1FADyr*u1eQZ#4C9}dZs$n zizW6zjw=2SDe4IvzYquCQJ&+Mu+1d%TaG1yR9WXAKGCx2yY*l`B_yVT)0r*QBBOC* zt%_ks&X{oeF*ok@Wz+rO`#D$8&-Dqv&=dnu&b?Q$3u){Cg{nd&!Dp5d-$@g(lD4Y_RKJGPxnm$*1A)l5^-h{h)b4RR{D zqAT5CD>u;X-^8kbgwWh;mzf;SLRsw{UTcYlk0sqn=rtjyj1}=JX@2LGua(j0_iXzN zCW6crc~gZQ-_{`Vx`Q`o0OwEnUa4TcK&;}~#WR{XR52)|3iHB(VE@WsHZa~xfX7(l zc*T>X+o9k>dbY=g4ONxD-IhCX5sL7O_4V!R()`_dHI^==v)3G%WEpoE~~w75LH{&RxvH=B^lR_^=wWc2!`zjVJpMJCH9 ztv6#`R0G>+d9*y{v5rpilN)n_8XDsnnmRen)@VfWbM$%!FX)(41FFKjvJ z;ry4W`UPh6U<-a3E_gC&uh8u^ClPHZ2@5Fu@FA5zJ}hb02ewg)@9-X4r**A?L-T_l zZu+FqK$@Z37D(;Xutq^4q9kkakmL^nQ?^EAvZ<$>(D!;eC*N#-5 z)4oP;B@YOahElIv;~Fk5y{P}pLq>>Dm}f*NVv*XDO8^rGEmX*-`r-xx2)zVC@>AVg z&3F*Dj_Tf|bCWzfMbJYmY6Yr<+d;DmIAqG_6RMYCKcY7xKd9g5RaCNvclu;L>e;Nko{{TjIeToaK znTC1B{qo58dI>g>)bnfW?#YyC!C#1XNVNyiFjS#aGsBIKetPe+AM!t#^mmY9-~;UVmrXBr!Ucq`VchPaoRDvn23qCh_Xc;OVFU7|{ePK}1` zt6p3^TanNFmOOlJclWs}TRx6kl{!m52l9xU(IZK()eqmkEHUuq3SWwLi2|nIEei@t zXM#N+IZp^WqF~g_f&WFm(dg>Ad}tl;IQbkMN*kkay7}+`44Ntv20zj^if2yln1AS` z4gF(Q_12OH?H4Dh)k(4L2yojq`VjbQ*R ztg)@qB$hy!XyM4t*@Pop0SM|64vLAHrO0L?n1oY(U|&krm^0?XAv-va|C4~?MPaAO zn$naO!~D__ig(wp*Q7xRHNtU6}st9%xfPc)MAOq9~XM@<-Jb!<& z5BP6I4Y|mCX!s3%AB3sOixAx8VuTETy`DC?Nw!Q~TzAnL5wcBt+g{fzj`9AanBj0R zC@h6N^$bmunr>2fNhn#gPxKeRGeOSelOYP)$D;<>A?6u}jjp1>Po{MnvLnqU4%Ub7 zGJipMi|vSGu0sVBX>ai06mDn#oT4IXuohE))?FLSg!G4pYnOYw>>L6n)a1zZx9e!9Eb8$Ko>jI*1mG zilX5~E(m-)K!d)9QBgN-y3KPeQa&GosUK#()?L8xjPBo`8`z&)xOY;&yFOd9Lx+Ea zp~*d!i&Kb;jeR|3sr;O$NR9d@i61rzYgcC{1EHj-i`#R%Wj~l5zZQOe*86T;(XU^P zDz4i6Lm2Y&?dk*%p76taj4J&7{J8RTB6JgaG-`h9v@pc?zeA`EkWw2|OlwqIY^|@m z?oQdp_lr@l4e`KxtZHtqubuWrmGc$4I_4TYwm4XH^Wfy}fvU0%^V&^rRc6YCGcUp` z0G%Z#t8e|d(N!Eey?YMveh{CpXNU{_C*aJg)65triFI;~2YVUc4}Ndad}4GFxoB3Q z)#$Lb-0tGP5|lA*@#oKR)Cm-hWzr z&Jj7L5J(qLyO=1^X@R@cwlDL!?|azU1;e8r>^JS|vJ^}wS+(wNJrorb<f8DrA@ z4?&;UXI|nzaiW{eE3$5RoIR@qmH(LM(NJ4=Q*)cfJ=c588x7mp>IYaEh%D#V-*n7BA>>`CHBqiNnO=haH>8$PSQ8ypY?XGbo?4eJG(&I}8D0|L+ zcA*8yp@r2*#?^LQzsL)|%YV(DkZ(L%{P#yU2U-o@YE9}BNBR;puU~)4RtOUpa@zIF z?2cd}3~rlmxYU8c!&e0b1&LXIs%9&!udNwSQc{xakiE4Vs5{x5M;C>DF8=%Xpejko zJZHkJYu~$89=lSk%e$#1zC{m&5nFySq*Y&I5gYMx?aPelOr4#SlN+ z4j~@MG~uVC%T>ycY^kwG;qlD*@RFhOYk-S*GwJbFX-NPrEv+dRSulLjLtuL)n<;`U z(*FI5+u8QejUjH=lm$;5GhX>U@`(uYPOOdHx&cit04%qZuV24nZz4}FE<);@k&~0% zliiy)pJJX;2*%dHXDq74pFIEX^ZoS$MpF3-)J&?swM$LPlo(l*jGs^&R81GC7V~?~ z#HxguM_hYduXIX{n-PKM+b*2qYOhu9rn^%}DSsU2{nH1dwe|k^_2W4`IP~+6v*Tm+ zN*ga1H4`{oF9`onR8-Wf<`=+eY|-rtvq~F~Yrf-hRp=M5YQ8jT+vYY0#|-j>xjXFk zJQ*pxmlmBQiE!{~Iq(&0VbQ9d^8`xStRy=J2Q@Csw+h+tXhnthl0Rx4uKMyuR(GA4 z#DtnmY@Mx}j})5GI|T*ZcMKFv@9L`{TT?okwcWkgq>3;V?DIV0-8Z$L>Ij@U|7ry` zk);VaE*5WLSYbB~mxv~It$Q$HYzq=b=hAkDWCl|~BH*!eX^>x?WvX(g$wsc^F}n4~ z>^E+DL)n+)72iVo%OAVb6Q32#SE-lYB5*deKZlL;uNc;?8o&ARd#U8}a3K#kbKpbA ziWfCHVG+`Aq~jNlbh-c*>Wa|d%rB|r&uo}F zVTl$~4oA8fBVET-{o6Q4^3vN%RhHS16v2zkwf&}taSSd!V^a~4G&@77*%#pt4+*di zCOW9EWU;X4^*|881GDB~zwc-^48G{aX)#;9$GnpyxOJ13BcoGoK_jCmf=qquyuZ7_ zvQRO-Czd@8p*AQ}+e9X7W4LvGq_T;g7 z2MD13D#bC13RHfDsw`=zcOCYdLjvTu!5I(;c{iZ`J{$9{w>{Y)(eW~@n07n;*Q_@k z9TLx$BJ5dx_>;TL{8Rh&+2%lEmE(?6v24FBIm9P}{@b6!1@~g{P*NU)o?)QcM89@y zDCM0jWZ!l3Lf}M@ZGiv|eSzB@xU3W4?++y9j+Wa4mUVUAFLrM&hFcRWyi^JkYe)}6 zM!bOJgfa4Gncc+pXtq=Z`BTU{SdwM+`QD6dBD>!3@W{@?9EAG%zTt?VE8?x)shYHO zXafOOciF4&-dg1byLz}0T-u4I89pm)iqQCKqEG$P}sLHkdW9!3Akf%kUo;Z0i z@GybHs-Y*AcpE!kTw}h?dkLBL1~qWv&i(D9Szyfnna=pw*YpFt1mg$3|svW4<+{nR-QLTnzWzOt8&os(fT%%g%+rJhvoP zCHC<-U7*uODf_-D%#Bg1@(D@gxij=nWzazXVqWsIH}(Dv%cR$dB8DkZvsr$;LwucyPn+HeQfdaL}~T5oD;O)*gc?? z?HL-JOzJMj%)rS+uB%P3}zW_hp;vPJmDLOz}~1(_n0>&Dd} z3Q&4O_J4E_m#F@E>rARot}DHpt(E~Z5y&7>(@5c59Wx~l?ys&^ct3mv;n4YY-sVtB z!!PwaV$KILMC;tT3EdNh%}6^e=?FD?^nYHAu(ANdU%Rd8EO{et@+%4!KO z@ePfQoeVF(GDfECOjLA_WOgq&QK#bSOH&h)5|R=T5mBZst?$L9q<}r3&PXP?&wwL& zkanmMD9@PvEy7c8zFnf$IrjbUE}RdVlX_D8Vh8PHfGf;|ntpFog&>ur8 zoRs#I`qZnT8Z`gy&sEL#kBu2{uxXy|{JC>4Q2On#b*$_U|Nh3NP7}l|EJ?gNRc2@H zQ+zFu2Lb2(qAO3!Md7B+L7l_DA`Ybb{+hyer&ATiNZIRUfs2}!4=+b{o@CO>&I3NV zWVYUCr`4RnOmSRnC^RrU+>0wb;^gq~-&|Sy>E{Xp0s>D@&&-U>$&!Z(^Z22D)!z;x z$1c24YNu69f|Jmd313%VCklP2PiVRU&Y4(@oemd6ZcW(*r}QNLY2lD??J2vt`-cqb z*+;LH7(`_9(!?3Eg@~l{O2GdY%m+&@a{h#nNPWi{HR1NPp6dGjtVH}%m4J}Vl%Glr z4D0D+b6LAJ2maJh^SIN)0nQ|T=e^2F;TW08;tp`jy zh0FDo6${=htrW+$lynFbvx+kuDM%J#xSOm?`>1SW$GXwKhCRwmiQ}vc6~+A_$15-z z8qOTTIq|PHDz^QtnvZ;78ky)+g-S0E(W6SN9C1u!qiB;_yN**Udfh3$Ke-q}HrKvu zV4Blth*(GWb@Xxidd9&VQDKP^a^6TvR+3e@AXYjZb})p!{DKhv z-_~qCQ!96%JuiG&>UL(vHMoX-`8mE&=_6ETtgOry%by%VRchhk;SokQQN{nF+m;?_ z^3e8<+&_K7jLA=x&tvUlrb;ot<95$U+t+UxY}$=k3i-za6A@DWu^pt{-E<$b@cknD zw>)6aRa?B8;2I*GhJ@z_B7A4Ly_U08Z@usJwm1yyy@&Uf(%Yw}r&m^SY!hqx;uvXj zbI8JIZFN=sz@ChVV^qyqaFV!aWbd-8+gG^0o-0-0- zA+&3Je7sMYF-^c5aVxpW2O5nZqoXg>J8Jxv4~)PB>fg$Y%JTAG6cmm%$Q@roeHHQr zJ=RE(_7oHpuI4Xf##BqR8f=cD74m=4gB7OwTdCK~F)KS8K}ciOd(zm_lBSaDZX=>X zo=g?>(MnA03B_%qLOK^&*uE(50TuosK?l=2p`snslxi2PrlVPMjVP{k=coV@-HGkfI&D@>fyJb(qET~9+fXrfn%JAJ zM>B_*dm99QTm9x>C&1K^mF!^m>62WxkL~wq#G7}|w{w4!WfiK<6gmYOH%Ou|J`1B# ziYAk7lw$896w9Zqy3h-K)1_X_LqkI(NVaWQHLdpJY}^$hN?(aG5yRdE9tk*YDWht#OKZV@o{+q3v++HMyQNYOnk0{?#)tAdJ*5W z@xmlX)G7GCjdYv{!rTu8Qk)v*`^ThUXk}S?1od#%H>6>tUm)yRkrQS_H|DQjziyt# zuv}S%hbGePM1Ob*u3B&NS2T^3bRviAU7GiNoxAGQzcPO+sJ6B?_^M@_K;y_w*US<6 zsE^}jRe!#?V6QW9{dcrCRXmMZ5OtlM>Rh7KfA{L+E6FbT!FG@2fUAx9h25nr91ek6 zhnRMqSBf9$Ra%`$4sJq4NA?{R@IIUVba{R(^BfcSWekH5Q%T+LstS5yZ~evCH6&)D zhg7ze;c1y2H|Nm@7M=6ir;Fashr_x* z*6k=21QBAL?k}L&2a9R8>pWObZj8w3Q5R{e@p`@qm;h4?e~}~mUfd}#uGMg-}gSlf>5FV zYCO=_S2>xmxhN-sHuoVI_N~0o5U&G^yJ=%S6;1>%6LhKND{U@^o=hOaVU=?%EM{3}u^Y~PCgly<*# z!_t?6DEhRD6nV~d5q|}YD}5n-{+pw3EGHRI`&#R2pl`g=!0>}6m1QTaJF zv~R6cs)xDcG9LQE(|?(&m+%s99LNM`go-Qn{}}wird95Kv2fsZi30Cn51ne1qei0! zX-HK6xgRS_s}Rpr$yAL^Fo&0t=CxDMFEz7cW1PJw zDjCpp`aYwI&Msjb>?cG3nD4ax84xgpL>`vnOoILCX=OUp(E{=E=f74sHktlzriF8 zM@O?I#UvIP{L-7D`pmkozoL8vSD1yB4MOU^ex|o?r`Pd4JU!8w*Vfh&28n|+tR%!R zFfhOac@Y>LOw?{IA!aYAGet2*+NMAYGdLf$4$yX4}CA`KV@+aexuDM1uukFwqb{rRt#Yj zlI>cN(^8^cWp{R<-xUV(&J|w|7`$RSIwj^kLP=7+V8ZMP!?U)NM`8#;Hl-qY>iy;S zg%j#iv_mfKSgL0%UTC1dyu6h2h|sk?tMQGT?k>MLj7_d+AVSsdu!}-#`UQv#FtMju z$V3G(x)L(-GrPlYZmt}6M&EKWOdD}sY)=zF4w3mW`;NI-BDU%ctXljR_qD3IzbS9+ zzwma!jf{+BWpVqIvbwvS}zVuerk936lXy$?chJ`=BL!_w)TYqP~$O zQvF0}r}N!9?WP;AsgunR4{TB^4MPz49prlzLe zZ~4e+vZ}hl;j6S^JAkEbxWjY3HyC*Cv2v2r9uQG&G14d=MrMkM32RHGJpcGnIy^!y zQW6x+8~_ORY;YK?!+Gz+r9!rr($CDFdwT)MX+2jv0ydh2wrd|E_&Do;)_(~hw};E`lQP`ArZR0aA4AhMYOK2HUV90kxX<@y zpV|fJ6B6qMnh*4i?wm3AgLc9I;V~1AI?dnh65-Sn z^oJGblvsfT;#rdHr%yum=eIs`*QS+8>#;2GPWwp%PY>v zyAubDa;f`GxTg2cf3>#iYz(DxIxmD$c9oOoPVQ82Z~{6)w=GyY+as%}`ASJK#Ud{M z4&!xjR2bt-k1)S}Me)4xYBWX)hjbo?vn4$~qn(`{YES;9*uQuv5G*&@b;s^GMUwUy z1AXH~w=(lOX)&X}QXAnzL`<)l1T3G>|98zYld*F-F3C>1O5yuw-k;Wkrqq@qAPQ=<|0M4$EOJJ`C8mlC% zno9Xfag563BFhb04Hxv-M;wl>br>#`LLQ|bf|kJ832v7~kUMpUCRT8AaK@}=-Y-;_ zSeFP(ru+tJl47oGvnNxZUe(`XMfbb3=SD-!>_Et#{lZ7jg0!sKxY8M?Xts{|+5|Fq8%6W5^T{ zN^(NY9O*5*#ZZ2SwZQn?P5+AA1EJc*=J&?mb2O?f-aN$DUFhrE+m-K36jvA@d_C|^ zP%=UFDnqL7saO4Az2g=Vdd0yp+TES2`^~NYM~Ql^Nt8l*qxZqkh<>;5mIzFL__yVb z&c>|=FRggVB(I75!vTv1aD_M`ru*%UFNUCv*_yTf4nU&>N892BbFYJiZ?yqW&)!~t zuGq}V%DES`7w--~NE31%nw@R1NaSc2_i*1HHx+GV`oXp}SrOA!Q%SY7wDfDD@P`!5 z=qv)ET&UDLG(^GW5~y9`-Ec+#9(?|m>+SxH!6dcfbJmoF2Cr2sxL!uD#r^s0NTx*C zt?q!T#yL4&a*vC4WdAwxH^%Lm-gxERVMO{o%qtP1dO_2vpyb_EX44sf z+)>o;xHOy|7`TJwpK;jn97#tkN*i-_Tk8Jh3zZlG@(#l8+p}eNU$&174GsO1_9Pf! z0>fXwNmk96m5t|p&eWQ%#%ccvviZv(0{oy#8`hrq2-2Tm#TGj4B|Yc0s#Y)NlJOot zdEYSK>^f!?C$7(<(bm@Iygv?*bFmK}c13Jp)Jh3AL?P{$c<>Z{ry|X|j847zmWOvr z4wcfh@1dUH!e7pET zA82EC&aP>s;<+tnvYd*OR&c0R_&_Y@w$%E^`~K~8oqev4kBi@(|R0%x>3~b)HhAJO~uh zKfZklP4Cpx-E)GZYbv{_3b^CidU>@TK|(6L-$U4a>{xGaZvo&|l#R?!!QY3S?` zW49(mF?DQ>EjXQHW5Ge|6uDCaNa5Hx#53{WSrew{sk!uhTMp_nMpV#P%^XCbXnNYn zCy#MGo@Cctf_GW+g&=WLyTcKG$-P33(iUiXF0I4; zL+Yk#$-I1tY_XX#Q{5yL5c&5mT8xd+|2Lf}b!BHLO?z@3rU90F$Cr+@Jpo(m4+xwJ3bIQW^A{uSZA_CtQrknJwF;^zV-MC6IjuC= zOa0Pbh}MC?1MS1HShk}{gfW#LI{ESGKdz<^Ms|})ZclO7iGQ@Lmon9X zRDzvU@SE0>yG(T-7VLQmBFN$2fLHh(-aehmb5-gPA1bIA>kI}Mn5L$tR88OTP1n$} znuw=ekC509h$svMoC^79Vx^`3!F0bB7~h5y=o}p%caR*;`*{1xc$yzEZlpPlh6wD2(*`(eb$xo8*P{h)3fIu?%YrLuUr& zBOQ9CET6J~09@(_54^|Swkyv-S}UK6>N-T#rIxAko&|rWfKMhfz9#-MPy*)H2HQi0 z99D$ns1!1!@Eq*GAlfiAQCM1SU5NNtrAtQVm^vtPIz=U?Z|Z(_+6fn$b=nVUBzkgO z6fdwDuY(>6>_6X{@4qY@tUjD1BJ`~?Q@Oc{*BLaz_LGOPiy>Hl$jHj#m5`y=W%@@v z#wEPfu3qB(dl-bkL@p>ulfOj6PEE8YVNI0Yh#wrlCU7?1$i9}it$C%`D)iD?th-Cd zV?D7~%d4an%*0Ab<`BSrbn4uis5FH6W!R#gU7ENiJTlr?-x-j4y{|VtX3R#|_`T_C zgxQ~PY9JqbX-7GMO~STw_C3B&`cF05uVD|RPFmdf{<9@fa{5LoH-Zk%pcgm5V@w^z zMw`6bUJhoR!52=D>4}J9ElO~`C?W6O4D))^r8g4%;p1-CiO0eTO`mFTh8owKTlezv zbteA?IPs8)4ge?mHy%9J?{;F0BfT4c*GGu!Vh5!ugLyh`8WQG@&=Z|wSYw&dU=pW( zuYQ>Z_xMXA%;Z*LSn-p*$;X~D0zqiVmAFa}LmPhcT{l6|m~~gcro*Bt{MlKRG^3F1 z@jzH|&2!!YI;bGL^3NA9(ln6ToVk4)TX$RVWa)ymUHW~fm{zD$K)h(&!B(bL>!to_qPoJ`ss1#z^8JxP^HQm@c?u>ir z8g*pc*&m12#OFrD|Mtn<+P#vhGFORdHuTUAagt3$>PTQe2?`wMuim$&wErRIr#7TgM#kx>$554_etB`q!a zcjpNRH5k9VsCC;D-%_9v5)#5vP)Hth+Br|*uQDfGv&W!re4q*ks6**7`fjb);V^>i zkvu~q6{T{+px2(bLFQveoy!q|MWKxaR4fEQu^5LXhw2`!p-UOd-H6i0v)o(8wQrS9@98&RNv6x+a_L)vqUubIWv4Abp;Zb!=dw&EOC)|k zaj|d?NwiOI2iEP58mZ#@u@Zy|Cjg+KL)sXGrd=vPE@>!A_r;#?Ck&Eb^y}5c=O392 zSQ(Gzm9c$+hMIDLFD@=PUZ5|7loVR`idB*|X~ob5I0U-|e&Uvi=!ynnBZ&`7Q6bF4 zg$n8POHs>eL!ISVVxonG?fdTtqd#J~888!vNaiztIeg{h!gL9zjJZWU^wB^+GTl_r6#^U@JR5$rtvaUlG=bPab=YN1+60I zPQK8M2xaxr(9pZCLlQbMXB-hx7}}9hE9-^kla{QizfS{89%K9ZP~mrf zeFtPmvHaUQ>tEx%rv}cV;CUD{Vrm~I^k?r1^+SQY3xq%z<5o6JMY4?8~PbZ_rGAdrJL%}}sUf|s1wqxJGc2sXT0I zG|2U}i(+8C&6Qc_;c%Y78_paLUhC!2LOkC7c&3*~Tkz3Jv|L8tfo{?}x+WqtA>5%xM;AZ!y?@Y?nZ%((H z9d8zZnN#@Hd10rN%H8_h{Yb#P?>AHo&l%_Sq%+nZceR@sDED3w8&N|kqdGr}h|+z} zA#99us6Hp*Xio7nckLPj%Y|{){I)m{Vs?0ELDou9tSeTWyZXsb^H3}vp7h9mMFa}u zq05g{|HyziB0AW9Bc$r&bK0P{oYmKxCKC8QW&oAnKue{u!9h)RSLYLWOdq}-jw7$bK=ugLc zGhI?8V2_|WW8@nvo+F9`B)RVx?RMDkJ4wMM$l5RB)zjVKA4GQ+Cb-|(l*#Olltfff zO}_{SLnaa`M^B`ncd7c#P=SG7cCQt3uwei1vKOQZ-nSzdVb8~j-s==jR4+}gf9lOo zU+=?tahwn6C6C33+gqNAunn7W>3F`8w-7{w6wMx9_)DpR4%!->r$;9z4BLp9TE5P{ z#fL`QlOMSOn>Fpi7w6~r{a(idSA!Z(Fa54Bwm#=Nh3UyF1dLtX)Q4SA3dZ(lp^9v2 zRa-EWJ?f^T{ckIoFfw1LL?HK5M5FEof>{Vugy{t8PPH2SNDPVd~QRHl=0 zDN&UR%>6b$t86YG^(}%*0~eXA_V8Av9P4@4Nu#iUM!G_;0SEpGLC15&SgT#Erx%tl z`?MU%H$1g=+gl80DUt{8cW(c4Iqd(d1#4TeH6=KRmZ3eF*x{v+1%ahwkz;R?sPtZfDSqH0{8GARI?f1_IlU<-bDRDmnWw~Pp*_okt2Uv zL#shLotL0oN}UZShuOj|gQMAmTBQRG~s(aKP2!G^WF7n zD%aMHhfw3OXPwAdgD`TqEB74~K$t*t)AMAIeKK=BZ~2uV640Xu65aytR)I=km5Goh zZQ#o=k16eV!0*$jGJzwMLX5)p7ede@jL?|3JNj$Hi&JmsO{}fi;zQ1cw_v_2E(cS! zmp=yWr94kmPG^U9AEa(yR;m>x-!Dv;ILkqQ2^6&0G8Jg2sB^{n zZ!M}V7Wg*2ifX5eHAdF^emSMivkYP35w! zgi~+w4fT(KLLfrP$cP7C>NL1bK~b+j{b$5^|7(V37^W{(su<2H4StY#u_8TTDg{NF z$7qRR2r<5R#@{@k9OgGXHrBtms57VFfgAqD=(Ec1l|R**YpUVr!++RfjiXq;t>w(! zN6>V-Rj>a~tecLd^lo%M5JqC_9eCO*Ye9VcbOF>v<%3N!aB_NT@VWY94JGUYo`T=| zSh!T9SDj|jhql1I_D)l6-Y7mSremkmlK#IQ+-rUfkEK58(upbsl{Z$(GgxczKq z!-ZbV*5vEd(E1GvrN7v*dxI|yL;IeN&K$THA|~X;p3D;e`-56hHKq6U)vZ6%3O!N7 zJarU@AW|QkN98lY?M0N}f(4P~+C@1DyN`USKE4hu4vdf0B^7PO%~ceP zV9k|kd|9`-*G?*+#hR1@AQ_MnA?@a7ht9mIwe>A|1in*USP}h+$wlr>d%OILlzYtw)Asr&&(HU#vgLbNh(l^aE{Sn-V{{V$>8oPeLSGy>8yAZ? z=cxJ8tAo%_RSxHP!c18hzrps%vrE(-HCF<qY`QmauhW4{(PRW%To}AcJuXK7 z9W-%}IOxA%2`W^no;mUn5oq`s`U}qLK3jLs*5Tyk4`+?}`R8S%_&dxY8FJYoIo%5^4{YRj>Dz>5GO4InsvZc?v8xw}Ds{5a*U`}qx}7Z+(L zv@Ko7R$=OqWg9M2P4Aua zc@j1q0lQm+6QeNE5z3yW^szfW+gRD$bkn3&*F>eHq@>@xq-4BB?yDPn6`QQZS}plr zO*0T538qMrPb=k7#p+x_-pi65VLf0WOcWqGO*rUsv%bf%g;34aZMITA=K)kI^vfHP zqv;_k$S*y$tA>mH#d=TUn<39--E~do=g&#lVm<*wQJt!DBsdIdU(-$$YY`BxvogAU zqh{pa5S?phn*5!clLPM1Wby0&?y&0O-AT?97GiTf1-n5qWqUgu3|vZ$DyR3o(W3k* zzv)voq->!JXamZzJxWdMDlMjyd;MTR>RJ5HH5X#nNm#FZm`R z`|rv3nTf)$8Q%X33Z!Kj!|cn;XZ6U$X@hEl)2`Im1_Z z?#_(;4@>bD!^z&dN!R>N0cfc~?*KX1;n+x~`2F>4wCJ~%%lm*&lui#{=B7qe3I{!j zh|mCI4U~#f1U&)Qw;S-pi#3c_E^UF!?frBlTQeCC{C8v+`Pq|hTgmL>w3uAJaO<%R zp~6muTa{wkKzLx~VT+sI!zl3}S%c>80;)v4^WIdE>HARID2mk-5I?!a;w!6KUsUmjKefXN`>E>9XnwvA#bZ)tMA%fpd5hpoat1AR~x0f_Q zA?TvUnAP#8z1y>~$!wzNd~h=DV8J;L?qYHfAq)`{bb$pha+%3bF5B!cxaS#$(7sr- zCU6zt7p5-Lzu^IR!M*;S5vX8$4M3d(4qpamz`AN}Gq0Ju52n3)CMS&tYILTHs)1!x z92ECO7osojwYRgXMVP5`m}f6(WOV-bMZ?Z`f$f9nPr$zDzL>4m{N02R z?yz_21&;EA`G^KbfdY+qToGyVv&}9RaH?4ccrH_vDJTWfIMz*hVuYJi@ry3Wr3&kS zz|_srOuNxz(11r3ihXu*BIJA|86R%h9e!(eHuGfirByhkkY=_D@kk8daU4AmOs=Cq z0<|F3Rc&6!}#=3Of^!a+gdN-SpdHz7o<u=7pD%p2+;w%Zrw2gd`*UT=Oa7IWT;gqqVBZYPIrracp>6= zd|C5K!usC5Fd#sdHf}JPH&^t|EsEFr&VGA5yG#3tfH5KA8d!VQr`ZhHH@&!WW!zFX%v0qdy$o56k zL-GkVjD!l7dyj9Nj}8eG2~_+QgnSf?;+*XdLlEMlF`rB0H9UF*p$}%{InrGiqpOw; z+#C@S(nYZ4-xTRWYmpm3nQa0ClR4^dN@H59dy8-4>&X&_8&64t6-AfVOY1&WyBPfK#J& z;vK-?Nvu|r!HqP7H{&^cLe0I-e^b@h^la>?(XN79+2vaJM>^Nn@XlwO0-ppEmCNxkc_X6P&?*Cwj5)$Oe)%U&-#cPzZ zcG~W`0}M3LrQ;$b5a@yT#HKjzUIrl_KPghEVR-y*bNy_yUc^W`ZNrAIBcMmGrWQy> z2DCqeEg{)MJN354%T_7PRng;_spI=WWHW8?ID$-;y(?QPUQ$Y~X4+!Esu0uH#WUEc z9sD-idYqu7_utD+XQy|3|6K~9EDSCu_cTs&E4=Vc7wGlL9YcYbB%L3f8<9TN$6;5Y zCj`})eNRwZQpnF*KTh*b-Pzo1ux(ERY3$+2nPc5g#av|1EVZoWkAv-r!YcC#{LKdu z?`1t?s?hn~eB962EO~lBgz)|;IAOps!u5iXkZ_5z)N&U0@48(?>}%-C8FkD#UPl8Q z<~Kg>5Dp}Hvj>eZATDnIe#+(oGR2K>tB`Z}&6~!Xv&K)Ki&XqIS*>uWD+S1L2G@2Z zWME>riIpXM|9uDOt=CNe4IhA7ATB1xiGvCO@q$?#BV*ilTW1L9-jm%^2e75F@#di* zC7HS~u)gUC^6oge0M%SI<{z^)nIOw;*Xh>Hzp)-E2bS`I&!dRi-+%k|NTbG#;^t5n6iI}POBzK& z9$M_qy1+Rj9&sy48yD;y)=Y3Rx$$B#>^XIudI*gK_mcBfsm*QKm&i7lEMGk@PTmzRjG?AwIV|QwTRRM4Q7>aeTHBK#yBpSP0Pdp z*s&mz{d8z=c1Ww1-D=I`zz6aJ5P_JG_Aoe*T#xSuHnnKW!(Tm(k!fk{%K|B~5=j?9 zH6ftpiiLTJ*=Bh-9L1UIb+EVIYuh+)6)#DHn+sU9Qt6R^5wZ~8tWo}FahkHz+=DDBfH*lzhj*ddd_JRLi3hl{xr&+TZ_&x%ogIBHW$qk%6*cc0pB_ z0PsTndYVYXZsH{R8ING_GR3A#q64qH-WQ!Los;vkoBqIFg*l9j=hkzuZ$!UqZ@NJz zTcQ4e@i*#{U74bHl0u92yw$w>)E@4eyGeB?UzcX**GqI-Hkd0f+CVkH7P$J!7(Gon z-zd(L@wTQs?Cs9%ewb=9@6M!?oh(3r(hdzx?!bd9;B1lr`~XHsYV)rri0$!(YR@_} zJ@9!AX%gmBCg*jtj_a`5K7bd-CU6`lv(-D8iGcfwaC4w$x&04;M7Y$Vy6`$59%#36 zFYd_gCs_B>Vfq3c)}a&;5v$t|-rnBd(@%s+d|-Cu2YY*#?EeD1Pl|!VDPvrgYZ}!8 z4u_P8Xybdp7$Tw01+W;F=*KZa;?jTst6?AEvz>85wB-id#gDI}^Zqr=I_)xP)x^17j9*uax^~y^kc)O+ys`<#n>#GX9PjyMyuhBAQ5?$^0<6gt%t7iuDMdqhByOJC zrIdlZJ32Mpql4;5T+-F6jfwZP4lYh7L;FvWaD2o!j+cD#!r|>*tspCVRro>vRzfCU zk?k|K7x3u<77i8PGelx{H-2MKP2OEOvSxOgy}8?BB`e!spg!7rmsxh}_E&Y?GJ}wxW>88ezxUU~ z_}N)4eSd-W%ih>zi0*Ha`pb_zIL}JMmmh9?Mk+&KR(lmkDvZAG@6u$qbtz}zr9|$< z2X8!7Y};1!DZGT%Xr%O^7gu~)1WVd4j+3T~=f4FJx#ao0KsH_N-k$CeLmBaYrR2#( zlDZ8CvG3d=Xex2x0hhN;v!snoElH()U^O|X;t)H5kMNbrvWD7?{cX2VrW^khK42X5 z;l&7a3a@IfLG|MxSin-h#^g8-yk!ynv096mU>6~RjeQ)YQ^^FxL+und`mrF_BeGh7h`7> zCaM68F0YBn%g(BP9?Om^W7_G8QPF+HWyx30A4MP|8grlcum^4poFf#cPEC-H>HpGu zQ{u+9rK#`vI0p^}y3d?H5i7|4AvOo>mq!Y<2g9w!DF1m%)sC}|;qgt6pTD91+vpud za2|v0mrJ8~r)wOgM53EIK_7Fj0Vn{a9}TZ<(m|2@Ep||F6r|AGFAeFrH$CS$Eq&D= zU$kInyL0MU3+ZsKT7jiRkG>i(QA?Ss)HDIqMxh{M61zXw@N|r9D?pc ze-DdWsZaB>JZA=TwB1^|YSy?vr^(HQ=Xnb`N#uqEU#xf$=tO^ znKlg!TWn_{Hlj9B;5O53 z%hR#=7jgl{#wOqov>Hq~0Yl+X4W{6+`ar&>$Nrwe=EaNY&F(_xTRU@)DP|SCVA#W` z`!Gu(KZ72%N}UQA{hllkwARV#H$xztjMgrr1d3ft*p@x@*FN>GjeD65BptT0vZ9IT zkLp9`Zb60m#MPnH`f&SK;Z;1=SCZM;<|h}3E)o*z2wSTys$_r$K_Nk$Q*do+Y`7PJ zAic=%wAUAbA*MbXpUtT2x=Wyty8GDOy?#9>7FsedtY1PrRD855GzqJFDpjv1(PhrK z_;(d{`?WcP`GW=O3x`>+W&;c0X_$6vzJ85%+%MY=Pj(xp5ilS7F*^EkVXejm=M?mZ z<^oHxoC&qxbl`2c$17XApKA9yCH0LD*(V0{w?n?eBwLAHnzOnW7?FiJzlCUcqgZa9 zK8`zOYFEoqU5fAZA|fV)BqvJLII?%qbtx7p0~9kQ%zpOMs`=8@swT<;oiMZEYIA1| zm!Ny3v}6Z11$2kCjB>d6j?Q&?kQaYeD0Ajn0~=~WbkcjLw~_CeL>$f!U7dsW^`)!-3<|4V`+Ls2hMLbu28MP>ZBacFefM6q4bPfZqGfv%FS4@|L z+a4W?-`2T_a0+kO>1P^~V%B!q{kpy{daBt^qS?r|IyXLXhwfeSN&BLBZ8TNHjgg&Q z+r7!KkWS2I>%`IWCg~&TEl5~Plc}H2Z2T;5Drdzjgj<&rxuft{2e-*@j&_T1bBkx4 zk2B+D50+H(!B))6-Q~e6Gk26HObpKTJj!jkHN3g>Gdl?j1+Ts-`7ETcRVBLtcpN|MdUG#WdL03tnkf?VaW25z1FW9 zHz*U^GFd<5`GGg4@+|G%>%4@1r?rXwMekaK_qD2hH3E{$mpIQ<<{+yG)qt&JGo#?TF@GKrlo- zO6|~wL_dN-QrOG0`JTYZIxp)(gYEaNV8q+0iS5ih*=exbTDL8nxpm6o`A0E<&uH~) z2IJP&vz z85xyVjxR9g!V=Q-SpAh?mD^i5(MS{Y=c}fBQ?=)K`*l`%^xnD*M*~B?_|&MCZad1c zDtgRzZmLOl66sYtudeF*c*B%3L!WKlK90)|)^9zGCLqQ}hzEN7^_=UAw{sB!VJ`wP zuv`5m5FS9j3M)L>GNXOHpnE;FmdkN$ln@yyA4jgAEO@Qg@!)WC)O%t4JrpbS4#M;| zUP+e~#TN?jfCaVxDGib3jCju+!=L@wIXa z8>6Mr)R+CaT&JyO0>o5DoiHr0{Z#6_n$7zHY16)Z)e}FZ{q10-hht8Y-mTz1#`?;iK6>k~T)CmA)LAyXsG1mXoPCrUk9QY7iUP#`uv4n0|@p9rf^hxkaO zj)TDjef|aAA!1NbCTI<;)P26_WaL#h+uFK@cIG&VuAbkHmAf5F=o@& z@2Z4;*R3VnV>mA(ggxfP+rLjz0S5)+m={4SQRsCxwXY?po};=ZRl{R_zVg}Juif9a zid=_jM8)#(%4vhp%67YIr1dej2_{>w^`99u5qkEWLw`<=JoZ%78i*<5t~-0b7hS!M z4I)S?lMWV&-QGf^^oQ)W`Idd+xLSs76MZdBnQLd|Z1gjPw_45#gk6be6Owj&;_0ok z%gepGna$wa=-l8|&(jn#U6Y|){;FLyFgE;jyrG%RHvkB0=gwyj42h$tF4JS& z2&&uFrV&=$9YbDRuO#1KohF{@6xr_5OC1Yy-r#R157t4xZl}O1iVEA_d;{A64S}^^{aQ}b={jHmOFbaLJyEJfWJ?!~a-S*%4inF9p zT9%CePoNt(Z9bk#v%sxmJN;|IJ^kpPljP+CZry+W@4r}>W%mCZ?;HKUqUP)Wzno<} zN2#3e+FKlsc442twqK|^_R5m|WU}lv`pc5>{<#slt4{kSKZS1n&-E2tlOd2fD78EP z`JG5;05+p*KK1Vb=?hz3ZRMMbToC6-?6NF&b{; zJk6g+kOjYI;vO&%6cqYM6X>(qu87ze0KAjg<%zhTU zUv7AuD&!+WP~kypopZP1%TsiG<2_RE{h%*(qM5;Di3GqgxJ z4uXe>lR#KMVYiSw^V~eynEr4&|8MfOJS2l=3#DGqZkAxaCR>0 zwoR(?sp*@$GV78?h3@6!{K9D^2Ih=KQfPQrmZ26AMkk?|9|_nZJ{}$(!98hL zSAS@_Eu62`nc!HZYyI1Zh?e*PT%nd0b+hozoA(L^|7iymp;clrKRXKmtW*t<(XtyGTa#1YFJ3Pn*4%XY6U{iLtm(vgAb{wsL050Erwlt zz?ct`=HWAPu^i?lT9+@p9rAj^qS)wsOIX-!v;GkB;fd$&M5M2#X8fZ^tpLcMAtS_V zfldL^1gdN?t;VEAZhk7P?;hr=vA5Wui~vO>N%>kdnJX@2!6in{j~A>ih&%p})D^+~ zcE)@ncwF)>lPafw$+iN4fHj&ZZDOmCL60Pq_ik?tySiTGt0t%fU-6S%8lGvPuRSyT zt1g(>iS90tALe`X=+VWCL8L*x3x4qK3MY5vc5nn|%HOh!=aXh-%@*&*$HNyKcx^%ar27^H4^qV-HkGF@ryUP2h3_i^#&!1apB@`4yv^N3! zfGVsRETK@^(B_N{*xVFQ7+nn~oSG6%7GygLAAa>tj%ocB-mU)Q;R_GXc|E}JoKJI_<|rDFSvS0hSN<27z&_VGP%l+i13Z`zNl9bB{*OkC|%!YCyi zKP~yH3K$T%IM!=dY;@UTu%UiqTdDKu(Z9*ox+usifzL9YQrW%eUh_gLqVwF^coAmAXtx==GiYo z#lWJzJP7NjG)O&eI~>8HC*Si+pThuQb`MY$?kBrKjvg1hiS7iduq;9Lp~Qk*i6kN_ zOEHdscyDj-0^+pR(Xy`A1J)H|!9*eJi7kd(nV1I>4qC~IStlN=^squL+7INY3vmea z4GuaD=8r4J6ap(?IYxW9SYF+aAX4I1Z^2!y% z^_Vgbp1?s!BPG?nW<2duKsW>UU-?E|lel`B)i}50+3`T$uV5VfSBGxPYspnv@`);p z94l@8IjbY3YYd!EOIFr!JGqn^s4)x`Q7!q1x*V(@-@%%mfe9$#Z)?~iVyf=V| zN+(aQKRP-Zq$`pRN4at_Y_S|vwA9oINlEoKNvQ?vJLXazr%k_ohlbwW@b4w6aot_t zUg)l&@hCISjtmS86tVM!iDOb!(STV^6tFyYIeR1B|Ftujze@j130&#lWSpIiXY-CO zFC|NPy~+FnT?;#th1P#hc-)Zn(EcRj_xbZ@IAfg&Sh{LyX-!Duu9N+f0fzXniQddEZi}UJF~1$;R$4R zw?T+F2HdM|T9Kvjca0c%tV=XPCnw9(XkvPaf$avm_*N2b{fh5y-6?Km=?}t>9S7IO zLnyf_fk`{Wn@&p{pu;s<@^b%IL2XxCSH4cR;b>`|NI1>jVI)1X5?GJG|C^(nUa68t zAx5oJ;M&@VXDFlgvy* zoG(c)E_`Zmyp_?8?nA}g9h-L@yF4BwqCegJdb@6vBlb!|000v=%ltw}u8E2pHhzoj$)Gk42{5T?cPq(xfKx>k|eS5d`(2{g6yHCbls2?JEAvP~6Zh2rlQVA=!(yDZ7b(!IGT}?Cqmi;|YU|G; zzLx(a=hpq+&IG|c^hYd;@)>5uXl`|&p>$d~$@m9V(7Z+ZA~wV$&f$C5%}7RWZf-BH z)b(2)5lB({zdXzY4Rwbm2j2?B99++Uf2q!`tHv_R7aywfYg?Wk577*EOQ`XHV-)m~ zuPbQ-mErEATFL0>EK0sYx63eUAyVg4sOv(>?B{d$1D=!tv+qTAW(mZEgu%hVhQ*cm zHa73#3LX|GK+q-|Ml+DDi}_VJGyQkD%*{7w5Et9Kkl~#in_4bB2By%)wbv`dRrGh9 z#n>zs)6mA$B@G7pXAAxz3yLHd$3A^8%S!t-9UT?hqD+OyvFtC4hGC94J#R57nB+6` zsr~`}C(DxrG&x;mSfU^tzH^kLn)xa?7=E8E^%LauQ`?DE_LlQ%C09DGYLO}6;p3@& z)z~~e!RLz)aJ!C36Mh55S*K-qbc5F6sKd^fcJ(v8v#jQBrF3QW95u_WP;A#>ZA1I1d4fsiW#1Qn#Kz9fvF%?T zKb=z3!EhR(8{n2v%$l(z{(Hhg5b)cu#PhIeA=`XWDx5~}`LAEK9&VR%?4TuOM1FE+- zYiq^p)DG3+T}Ou*3DOSUB4HRyw3wn=&sW1$t;Wyvb@WGvk-J`2D1Hm$^uz%B93-Wg_%S|ctsAo#;XYGJv}h?D}PRgHfO+~i^RRy@Gzt2X;v2~!@ED5*D)kW z6O_2tEtkB!f^%V~&US9Z2M?$mD5nb5c8mV6c^Va>w>fo>%n}5G(~7A(K@>S!_Oi;k z$yUm%u05{c*XJIimhgyU6K8pOc^Mg>tSn}8SI!A&^gU8+o($-YUV*#4Le{b}1pOAb zySE`4mt!(O{WJizh+H?_xV290SgFy2*crNsh|R4XxyO$eL=@$rCJVSEHu7crY*h<- zdh|9W_d_NXdh2zt_=5%D{GWOGdfX?YTeQf$? z5CdC|mMR_E^t8}>mLCi<-Ke{!%qlLPYk2Uk;QZkqCJ`LKn4x$exWKC~i4E{?Ov34Qvx@m_|^CpFgY z)VkjV)~OXC>bBrK6K=$N$`Xu?=q0<~D}X>TepdPVo?ddS$Z5UaZ+mSlC|jejK9s6x zV(#mu{|#l~ND(L`y|S=Li71e6Y^f=ZT zNn=_z+!oDxjh2=c!yw2iqRAG875WnDnMq0F8@*V-ZVp-C=FrTvH#0MPr32b7t1?ql zQ#V&vwcIDt*1sP-KwpV#$$6HYCeKzyb2TbC`9_Rfz06%OfV#{0lsl`!NhDm)T7a5e zE%)Oy7T9PhNL5G~*%%o=$;Gspn=b-e8Qcz+xUSGr(!@o6`0~dL8~Zrw{dQ8rOZO6fB9tk?;oS{Wt=S7)59&m-%pb>7Je$U(&nVypyBG(nf+C% zFKQqkQ%lUdUt}^hWzepkYlu2ZPoWjh#gM0#XCW&q8?i<|w7Ta~*Rg+TqX4PLC~>2~q{twYpP!FR?gfAn0RCb;%URgq z<+t%VPmzX(ChqQ>9rs{&WFmd^ z{5kAGU0rR&M1(A=U;9ol`xz>lYHG1@ag;4Kn(>RZo>)kqonirNdin&Do}M1(lmaB( z+SK9|a`L>vl4)p-Gp)*Q97J zs@vaVWIes>+Ni|gGZ`e$xpf_i6TP7oi&UC*oFDM$Bpy)G)HC7|F!E~FOz`||w+2Vy zpPH8ehnM26iMhD;9+4AP=;&O&ZA~Se(&Vr`-=<5ELW%T2i6Uh3-| zq_Zyz;v2)HJO08SJ;UCUx@?0`zXvx;8gFbc>`jHHQjUeW@R-ie%3fZYP3|N7)Dj_x zKwbxq5+`*iE8(2jLANNSVMGwK+zAV7D3<5S>ekAx#%Zd4a^UnE++!s+MJy@U7r}7a zNTaq%(Yt65E4xjeTUIYq6fiS_obl|0lf#o%m~zxxnwvV4w?hKrvW8!I7FY8dqWoWI zR*ft67MDjCGY~)`28mmt!?njzGP(*rf{@2J2pz7tA=Hbq(WbfIw8YE^4Yn99OTmi` zfi>UhU)*imuKu8Hqbf+ic;g-9oJvM+l3tJ@qo5FVofRT6G|Ey)eRQNn>E^=#=2(I^ z2Y!CMv2DgkL|i*lxF3bs!w{o8jfbtyDRGc1$p&8Jd|t3&7%Am;14=tj5aIoSuUUgD zP8)dspH#BJWwNF_cIoTYrkLJEV^p(8fKl1#3qC5qR>rj(I6b@xfT}V=$j$})+}N%^ zlub8i&K02(&1*G&_t18qyx^H&qC&2qVGU`48{}5pU(LQ2{wK@g&P~7w7Ty*ZvrX#d z+8=CWw$eW!MU%$jZ;-?l*E@yeZkl#L$^7C8ZEwQ%Zn~mkNKa7+Jd}!pR_ldtwRGI% z8&O;*_paP%4jy${#W=2zOz7#YQM#m%5Nu#9egRl$$G&B0`srO8ntk8vlbhiPAMYzN z(*-}J;TXE}j3s7GcdOZ9t!9kTe!^_v1TU%JZROB@%1k*(96?*}suqO!*jRovaWXU; zU0hA|OB`$oF%9Ncw(G5l4EOTR|aqpk6K#$5ygg;*MXK!ey%#18g3 z*zB2NCd61Prl{q6McJxf3{_s!Loq&-P*&Z#)==Fa#1NP!v(j_+ck_BCi(F_Fo-~l3 zrmuajy7(~r36B6Lmr>@k9B|l#HJ{L`or9WhQ(r#VUt|OH5JR^!6EBe+`bF>*MY9G` z60US)dj<-pkYBL*!@{BXFmkNI0bOk7xm@&(By;{=>zK*0Dx0gm{QM>B?u?KXI`bKda^ZOy5KC-i}<@Vv&5+ zrp-*{ANvr+i=(9Xya~U=g&&8mfA)bCPef1aiL`rMwe?XKL|w`^{|MyuE;?FjQX)a+ zgpCW)GKY4>+OjqC!Qml1BoUC7)$_kATL;|O$2(_yhR+VaxCn|c-Ci?*Fy#SwrZFVb z8sRxewTZ#Otj8^mgcU^IW6XcSYmuG380veGgp*mXoxvAA!P#XAL>1QeCQ3DO0RfDQ zk5_(m!9|ko2!u2PW_>Yi{y~LIzE3iq1rL6#Zj6-VXU9yOXe6Y8`~VWS-|dLjq%Qg( zUDyH7?u>Ev3y8IIqI$v_Go6tq<6Hq)Hip1_*|D9?J&r51M0eI24&cH#C|>Sb#5C1_ zM1&?()|$JUaO%@EyLh*Ll_!6(yJlR7!sGTk1Y15&N^I}*nj*kGO6$U2X|l^k*i`3X z{7MiUw7>j!mr+E`f5iYpr#UA#1#ChB#+r%OLi2CD{l34DJ3KrJ3}E8tXJ-{DaV=?s zP6*}+rK8t`Zlu>Yw8e2;;fRq#Ajo83JA41M2acncIJ0^!8X1l!-U^4$u=&Q+ff2>M zf)DBAwm-Af_cbLt=?KcWEWhjQvylWpYVZrG7BvCI0I4*x?I7g$;gvs5Mk|g1Wo7El zW+Iyo(nv17S~a+Qz@*)tu4I@5V1$H(eMGnjm`TCdWK*e=AtQ%-;h?HJmw^DTs)HgY zwZO^j@OuPtPfo!Eh~Fh$tWCGat>&w4fKW4Y5xha54G;UWz3RjJulSZ!hl6a&fCNryea|((m4Hc#57I2eDt;Lx4**Sa=d4 zun>qx_oSa;VW5EP;u=`s?B>>$uy?XpMCrILF|=1RPj-JiG?W^1q9prPIZrLi)Yx(P z_>|qV{Z{X*YNWWhs+nXi-jAa(}TpB41vQ`d_YTNzVx0FisN9IR5S?vz5_isrf z-LSr;dfk@Gff2?;G=82fyy{iyBsDPF&Mv`Uq?epfl<85>Ymuk+GWGN>KJkI``sodV zPYL}wE5lv~LvJkMxqlPy{=L(7dztd2bZNvdeOl`l3da{rA~MUw^jshKcPFQ=QpLx! zW-kH?L|@#?A4I~wue(nho|<+=wHuT95nbpVdZ)j;Gz=b$gQ0BmB2TI<&9h-U$`Z@* z@AqCmf2C8cl}9ac+R}=SFz@+t8ck(aW{I9zSXh27DiWS3U=Z#6aG~pLwJX^zS*-M$ zghAWS@k9n6D3D88?R*1OE)x^e^5tf~I(eZ)&bk8hhxm7vPD5V0JXH!gCJ1jDA?pb0 zv-F?Oqub|Typu(X;{{!ObZ*&I=v=GZO_6jl1g|)#uzPy>+}*3ykM|yD1REghLH2Q@ zMX~KyTPADUls)*Es`nqzBM@|rlC=Cbaw;mp0RdbR5=E*Z&F~bUc?b%pr%wsl6f;Dv zFRUHF)65tD>{DdO)zX^P6{sg#{X~U~uea`IZQN*h5_Fxz>o~NqeU5arrqiTd9UU6S z)A2?(ac^~KWAr$_UG22cU`CB#s=>F>27T67Vu$H%aag(PfED7_ zEu@ff236fp{qU8cwVJzbg4c#h%zsTP$%dYuYBegH3lkhglj92RB?%YLl;IU+o(k8( z^oMeqq~mk#DB0b?d~~t#;d+Q?yr)_B{0x5S?U!S(hkgawYywUH;}7pqCqr6AQ}>P% zHO_<{GD|06F5MuF&C#TgERquQ{^Oi0GgkK|CTchL+f7MgxX8c1n$y}maNq58mPQh< zV=zN+@R>!Ld=(>!DPA8$E0#N++(JBW{Ibr+U*u%1U1vI)T34V`?lE`@5*=v*ye)|b$fl8~ zZi`DKFy^COP_tbBQt$3^bL)JA_FaVH-OmqEMm*FCPDXU@5{5c5hPDw_$p{3vR6{E5 zociCph$hDxd}t0^BBASZ>K~~?AOOzwO>-4ZL?fdN6P#AaLip|-Tyv+Lm5sdb`kMSBM*QgI7hqh+l_7GbM0CjNv@JM?d`q6aZMF2 zPJj0BQP4dVUh&+QS$l)muS>5HY-ib9R#MpT-{5!K_#vs1?Q8IXZd_C~N3Hc$=^cUs zbh!gC7rHv=-(urH_)_vUCV=Ex_1UCyRRiO@hg*)gO{gl3QiBFj+xV4@La#=jW4ogy zdA66sxi%^)ZJ%y1Q*>~Mb@dX=L}lR1^Q{Aa{4Lg)K1^#Un3)|s7tkO zpARcr8^S?+WL zULa}!Wh#{qWZGsxAl#b~*?wVf{pt66NQI}UOB`Ua9r=UqjxhKun3p*EYVS>|d+whv zOr))DoQdF25}{Q@{Mcc=T56>;vp5A~Fj8W5g-+`fcoGOi;KzQ8mt$Y_dSx%)B)W>6 z9Q>?&5^ymH;SJqHn#Zc7mJ&a)#k@E8TTq88_vlTS{NbHgmR6usQ|o+6iWSaRsSior_b(J?p(c3>Kpk&SNHrj&y$Fh3Qxh7bEZ*JuKczV z;f-HNk`Zc*$@d7ocYOm}=I_t90Z@LWZ2=FAtLsvqMGPv-{Px#iav7)sf8e{|o%XT4 zshRnvqibCl1A)d5lp)y3W>&#s%z^M;IE07{8=(=KVV>~G?0lCqLRG5meuadxnB%ID zHV7}!RrHBYJ8pW0kslILSP6!@yYY=WzUP9OWM}`eoFX1VJXGnq$lgI~QCYfSW7io- zg~9Ngm1PQh^ZY6w;!5%8%A3AE8N~*AvLQDb{gkWl^goeS_P~QnQ>TR{?@;G@mPC#4?Vc4C* zC5R9R=uaUbk*=6G;KT5o<2jLpgacd2mVrAd;>V&b-i0H~k@I%-9p%6o=RDcf_4Q@q zqB&d5JKl8%>;G3bm3jFPq-J83+{zzGL5S6ImG38rK*~{MdFneN%?|lLrR28%5gTSRz zY3A^SrgCb@gjf<8mY7HMspc0ALq%fKsV>M67_5nVA*Ewy8ad5Go!DyK-%V_(dU5D| z|Eg2*-LrOr)W7u)O$^&y5g`_0(uLUQO{N*ar-NE#{g~86>~5PlZvCi-89*n;N=Oqe zc2fez%`*0B;`rFvj|UQ!?ouyehhG6eelzIN)n?)rgm4m9_A}el9`Xzo#tm%mNSQAn z97Cr0jxof`5GbSW5-bnqQPI*CTMpK4rUzUT*;5MG%WJ~({CyQG@7{Hr0tbL3UI5@A z-YcGja9T0eOd>26-LJ;5GBr*22<*;Y0o0y&_qO=9hL3=6;EYJ2Gr{#>nX^3|zet&9 zDHpe~?$2qqPyTWC-9#%%y#DK~p&`OelB~l8<{}q$tUCmZxOiB>G$Dtknrb?64{s6- zHC28ly$DI$x7(EYfxYaozyvRieX?BR2nCnuzrGSeCUCyoC-?mZJ{!;qBfR05lGUYy zIy>pBEP3hb=7x=Mudd;_ka()0$bZOQA(vhJffI$3vcQW#%|K#FN}qoJ0?H`i6M(V+ z{mbY}+hT;ECC=(J+27oxK#S`qpp3Ggy-!KmoaqxmhaVV@{VHf0lsdDpx3C!DpC^Dr zx1S906=1sn5`^Opkmz!R3+vSK;8bR3qR6FQYF{hC&tG)Km4n2v9E{IKj@N0(J@)5U z&EQCNUu*x$u}VylVvzFp7qCzd*1cYoQ?Zo;T`oT`igIVH zzkk4!o^*MW_6>9t?Jp3B3^~n0nZKF4>?z7)Sx;H!EI?LyFR*VI8M74uSw2i|Io5?xQn6sPzRfhW8${Kt`QcGa*eRJpG){oC$#i zTg-1V2hM2|EiKvYTbF;P-`cE>mxe}?eDR!ui8T%(nbXV4e|VXdQlGNS5Ix@%JValvUQFg+*nTRV;_I9b!3`!gB2gAZ!+1~m zVW_%7pRCARwRIt6-{R57Uj*d&rtHhFTFAFLhnF}Hi&+jNli|P4+ zK<{X0Ddocl@fSy9qPKGbV!7X@7ET_6=k$sUXs~3QmpVW|_Z1s4QayNF-;@LC06CFHTIP9Y6)yxJDH~l@1tFd3XQ$l#Uir%Anz{T z2_3j5DegIKzp8b+&tf-~rmVtAb7Of+(~j~@oEP1H3mDRx2t`5-oneb% ze;%4Am{?A6$I9fq8u{0@5RR1Uc`BQuHc^26iU(Wk``C#JqtUQLrxhvZT-n{WTSZNa z_LcjvScU>uZan*LV!RZ$z+VNjFgTVxV{6T4dhd zMfLO+HbOXspE}IYLa=JwOdv#}htbz^wD@>>`44rN$@URB$|!|0TCOPq9Y)7*5IjEh z1*s-E@41pnQ+mu}Lw2tAOaEi+G&?UBdYq${uTd(W%{l7Woyf0R&qLeY8-c%cj#-aQ zO4@6P)`Y{aUb+44t%21rFAjV}v32kF7e%rj_{7(Km&JiA!fE57yL(S#;SB_WW4UMf z@><0OECfY}>UE>0)Bvx;;nt@Mt~+#nizkAzQ7&^!yY!ChW90@O2=9hP9_r4u8jaZ7 zsL*M$Ac&pXGX`nKl*`7a1QPQUvx=18x}s*Ro88g*3hj_@`KAzTyrB9UJ3v9ZA&z7est=tNv9^j3m?a^ zQMq8cO0T(@D5c({yUh1LenXZdkZ6+e8onl1;X61f&6~9qfv&&^#(I?3P|bG#OChK$ zj^)|81*bKK(Wx<3AMfd6pdAXRnMP_HYEbz(&)VvQa+7x3r{4xd#IdWJj6}>Zp9((> zrG)Detwn2PKf^}U-)&zV-&?R|_mr~Vq4&?cOuze=lYiGM$A~)W=r5dUj#5Ot(nMP#d0@{jnx){d{|f{9W__ zwy9BN`v)=|5wv2O6)Fd14RsIYIY=-x)$*^jig33J^v4U8P}ZMQw%8Q#|A5Hhc43GzL( zn^CJ8hbqtm8>i5-?Z>n#M>PHj%BBcq4E*L$t?|^qD zU#H5`7)=5D+~vt+E-0G$fcgluKg=J*W+a*;CLW6~@$9o~`Z&KD54iGUj1E_c&ho%a zt3HbYDUpC=)K_?IeXQ}WX&Q?mKQ#b3?Mx>pVA4Ht~MD%(#`yf$&>*fknp`+23zx$fgr<7AJ7PQG) zhj>K@W-#m4t{LTr{lNuFMtzGzL1(p;)YNbW0%TgiW>ESqd9`0JXihb>GVOmA9p&@_ z#$u8?My|0=GX70gsoPzGZCT8uc|ak9NOzfLZGtzQzVmvvALt#r^O(Cu)o#}R7vKd` z^kk$uXH{r_d$j*b8Him8LNSKPbW<^TB(QRJxWe^c|^O6Gs=(f?;`3{-Tg zlBdd&v8L--W69t5H5=x#3=9owGXAG{Zp>jOCbs1!VYOHvc2q zCx467%2PX+#KkZWh={myY#?xAWIK66e?FfTQOo=DTR=f3caU+tmx6#%CUD_U{glFw zpW$B$<)X@0RS4X2pQC1yXeBNH3%mE6H~)$v=iV()C8Lbq{`_HAt(AU@eCsv<0CaJh z%=Y@}O!u+x7VvZqKByKKT_w)e$$Z8J3ZPyqd<4G=;LN-7l5C;b7z-N{n`iJaQ}loS z?P@us3|R0!dV0iYIZPj1jMq7BW%j5RuR-8~YUHuJ+|ocvQEDo>_5>gF+9-dQ0R>Ln zv%L;`>#cQ;oCxZl+g(*`R(n3z#uFtr+j1VA4U18@7s0qef5L6?EC}8p#+@(|unQw6eSx*i_Y`>SG+$x++Ygqj-c9qfmnt7{2^fWWrP7DLS#$RI$iKzb3B z1p2B1?0T;eh<}D8H_c&rAX_zOzBBQisZw(O;5X^ykrEcd@rtW<8$W)^sj>;Z9N3u; z`voQ?im#;7xV)asM|9EztBztVHo*Qj>e#MsHHN%>f#Z9Kt=Xb#K0P*@s+9l7sIUAP zjvB-d%*+k}P*;bpO>S5OL+h*2%1&tcc->ld{_ifm{jye#YzZ{?ooc9+A&^*bjJ?X! zZV=nT5x{!^iNG?7ZJ>?NB(}r8fOwA3Ag(%_5*G_jyodq-|An>B!jk*fnl9^k9Mu=~ zQl>nlmx?_-^EzhqRSZbCh-rfsi)Ev$D5EghUckkzF_|}F9v{3~vu||F%d!fEfV?h6 zhl~4J*f9ZPNK;SlxoUW{u}Ok74osrDKjQsGfQz5S|mE`b}Zgf zppIeHU9#$sh?n2%V`;zJ^l@y|HSfcC`}9iEtXFJk;+jr|FazRQ!ReuU;)i zl8O8O{E)_y*t9j_##!aCJW{HQpZ;g4;%N&#vxir%=MCue8P~q*SOo`vVOp5PE1%h* z0ave~K7xQzp?8{-TjtlCEzoRJ7MhgTrU7#GIHL!oDyJ_dutS=FkUC}V+lT^B`1-G@ zbWB+RG%wfNT~dvpKMOGU@bTlvnU*t`gN>qY! z_Sf$f=7LfHTv9ZTZ<5BfzqlO_c88oNY|!h7HR{o!`exGf?JM+N;Ivds6=9z{~Vb4BmEDbwWu{-3J>g>3{T) zO*Tef`CHK1;QG7Y0xwEN_=>(CAD_5+jlJwedyTF-2Fpatn3oLMLs!m1m5MB_nN8!n zEscsPwW7gByyexywo_8n)HjhaRVozXyZ3yL&O<(7 zc{Du5;3sL;-ALBoh06mm%nF@fS&qtne|MDU|6w-AY~B5aKz?m0L+yGBUMTIUXNU7_ zX`a$0%aE^xyeD697rHS!(;!Z=kQ|P zgnHoCZ=yCqyC|pGUuMFGINW|@TjI}+UpE;vw2JLv<=Ka^wI>TovpslM*d>D5RedZc z3o3ohPzEH=hwnE=pEvx<=;qU4xCe`cIi*Ivw@@xpIail)_(r{8Lt@+&1|gD`IgXa$vI^dTGB?8DHY8tknf1#XkxKG~q#}L&3}53uObT`?=9$@2{)(}LRDO{ut5owMnK5DU{`a}= zqWRL2XEh05k_F!wJjr~MSk11@c|WSk+(ZSkJ*C-DGrFN7F# zV7-jw#-%F@NC(=uHwFwq^w3qL?TpP>Zeq;0Z@~HYUG9va&UHy}za>CgF1FMtO&s#e z`(i)ka$~i#QT+JyR9QhG#QU=3-PF@&E~CF&8$m>|F?m`)3r4J77XEG2?j;o$Z#u$0 z@^;&!9l9qXAcz9+t^5gkSjMBtC2G@xVNXSKCvd1_1~>pona0G)FWFmtee5Z!7c)KP zPeU1>(r4(6^*sNPXAL6ODEZ$XE(WyOS(2wrndz{6F^S7PIrcW)yCDbL?7An!UnW!Q z(lfXB(xxpa?y-;Ayq><3&T&9UIGg+4lpL7YN=rWqYm|M29nJ%yb-Ns=3!w;H$c6(p z^~;jbYo4qpp~zs^mz3Dpay!3&FxoA73l0_ zbxTZ@6P1;hQ9gUO>LQFlHT36cumO$@U%%iU|Hu9-Ght1B3(>a$A9WrQy#BTBSLT-H zTz!b6Dne(9)|QY{=gu?Z8BexuKWMf&+fU7jCU_Y;G4v&OHrNUcqx6H{uR6bxYC*x> zUsRD6b|SK&B6WhT<{KFWR+DAK?pI8JkK-{=r5udh$I@PeMH z0t&K%l5>UeY`jpYz5Yc9>KF;OUN2sS2fl*Zu7|U=eS* z*e=xen(%|5cfip2vho*Xxxz77WcC*P9J*tEgXPwE64w@t2V!PwU9Zn2nAJF5AL2l2 z{3Fz1$VgM?rVbFFXYyF`0({*@rv%u;Eg`Qz>RY{ zELcL_cwJoGIMPUp%w9|ffq<-xi|ZIe*lrvdolhk0bri-2Wanku{?nNI?BCsSf}Yy= znAaXh!r|~Er-r573*cChEd8*6+q$_hLRWbxUH)%nuilKOb<*ZXPaLEhAF=F@ysdB~ z-{6lq{`!)|lcg&MogKn7k+foU`h`_;cvO8U)h%mI!5`weNHoGMr-1#FA9TqIr!})xfkjj` zmI-eH`0-sXC&cFs!Eu48BRuVL#r^pYWD63lzD*L^-$7kIe9Pu#$}z^?i*!jxT@(gO%DpECzNJ-aA7huGYj$w5auA4ov3-5Iyh zDWVj2LUo&Y!Kiwj-nC)j0Rt2~wpW*|-gJYrQJk5bop1Td6(s1?X|cqcuRWat;8b6O zhQraHyiT?6+DnRKH8tpqj3j)n-iriciplXkU-)Z#9=rXTSEg!v0dfdn%wqo(9Jizx5QL!okwEe`e$h$MtN9hOB{ zA|&+5VAu8$9FsUO8B(TP;7t}52u1QdClru|4nc)=Th~{AXSLI$eQO%)PTIR!*;;wO zB@GQ9Z`hKi3}7DVgsh6DihoES8%VT`+DPlvl}ixu`Su?()!;1&P9T3)vZa;z{FaAr zbU2SBuBoC5AMPxnUZH$I9*QVW>?+b(JOoP~2KXMLOF-yV{rwxY4OJXk>VV{`v%;iQcq z^MG0*Btl6=g(!&*2cYezoHu4FZP{XbMk&t#OSCzK+ijr<1ElkS*5~o@xvH|VsN1g0 z48LvN8RcY#?#MHndoVuJsxC6f*D3-XSCIOW6_bz~t;1|NV1zKT{jk)}FYfn&M@+{U zj)}!~J^CokT|QhYQpoR$RK(It3qgnpm5Ogm+<%0P7I9bT8rYIg{B7G<1w|%L52keR zKAvn#vmbEOuG@7)S63>X@7I`^|43*67a0I^JyQnL0lRf14{$z8jqc?Fds?|};ah^ayfGVKusHQ|COZb{3@;A9U8(muwFVa`|GU}C zVCKCbViOMhA8GPIiWo2zgTLm^s#?iyZU41%g=C*Nw4slG!9K(#SW4ObM2lq{_PsxpV*R7_s}>O0K3+KU)Fl3UpN;*wi~`bv<2eZ!r3%u+G(LS zWbP8}&8DCIN_$-DEa9&lTRE}w9yX=>VbO6lMY>i$BX75k*=EGINQqK`^Dca-;pid; zqJ>-|PU|PuKNe>4Ks&Iv7B^oVS3mBZuCi`)o=E+#wY54kgHsU=LwuN z8hK`&_yPgs8C!$6rk!AKFa46u2gAep?)M|`FcKfz`>OZ1b!ALRpJ7iAe0dO@_3pxxfxp3;8 z+U$%U*v^!mobTm=3d=Oy=dN?!;A5wD0UjhRF>}XJEtW5$N@Zl!AXggi$-aXl=fLkZ zC$86jIrU}@l$3rZ)OlApx~q*}boE_yT1#7fer^`qcfcb0xqpAE+;TA0eLdv@5BI&# zUn=|p(!y@W_4Qrghyu7fB_;_7lm zO-@EeFr_Fa=1BpU7)j^Lqb4r=aFwzIlJPlI`t_(alh@ ztRE2txdjzpLBPge4?8;<4-YvV9TOzd&ThUzsJv4xQN+0;9Hja#@sfva8jza;sq5t- zA*T9Uz3J^V-|0GA5GyhnPBDVbRLtR*+0=AbSiO)aWP0VkP%P-iZcr?6`ocgNOu;qk z8LZ4+Pv0g8SSDmO*J@PNziR9X5zQwOwo~9Pn)IqL=_OQB-kg*#PCcDf5ux=|dRxwu zA=&T~m4n5T`);Lm`Puc|P5PMJ0(m=?%hxv`{asPRT6apCVE$sD{y-&37^HmJA`q>e z&dw_C-876<@{jN`Yq{4ee8 z11CZi6@)%E{|Te5O;Lp){JT0kmz`=wIR%&x0cjTkV!McSzuOM_5nz^N{v|a$<_9 z(b2fCUpJ3DeD%t8iYK^Udl+kLYnw`E%Ev$;^7u6Pcm(<_UGP zMdwxLw-52B_uRO4`!*Z|8LK>yAer4oc%B!Waj4^jN22r}8AR!L~- z)NWqhS7nR%vkY{Up(5M&`yBbzP#*=TI`Qv!{86-Ds;o~2Pd56+91h=jyb|k-9DgDXAAI zu|Kx)SPtxd?^b;9;KPoQ1)89+9WiH!?`kbsczVZV(JG!4gvP*M|``qBrLm^tP zY1>OGY6*8j6jhUuK19D+AwYVL?IN@^LSC0`_iAm)keb*@;wZT3P$9>+WJw9$rAgOLaD? z$f{dstUib}Hh4cL5NYJ%lT|IE$r6TLbqJ}v+#{kqOxvGD_XTsv+)UEXItk{vhN)i`faPJxRBh;WYH~TozN;1?$Kt`wca! zsbU%}>P`$O1o0K7t&XYAw|Sbvfjd%{wh>6zq)nd;==CISt4{)#5eW36Cw$~2PGNWJNA$(wPaV`h>| z1oD+S92I-#nZIc>dvvIu$}f_?(#TtVUtpEjQwZr;*Dk0M}-evBY7%%`y(Hd=($^g9Cbo^ za`)7XA@u)(jVnr2V3_gdVzdMh$$0a4bD>C)AK3A|YbNA?jA;Loa%7kBgH9s0O&LK0-|%U_i1P4#`>v8&0s?^pA2XUeQ3(Xxg_-I6prTvRcGP`Mk9 z!J_&8X@*bFF*!1SR#yl9;|t~95g`91s%px&<57V95(rJlM@I|#DFS0{^M#A#Tf6f$ zqB@Dl1Z3jZT3T9?lUHbXm!|K{b@>yOfz#2`)6=fndnjE2;{V&kI@jLn-CGmg^Hpi- zsLohn6V#Xm2P-RBWIhIK{}FuJ{{b*8a72ytbmCJ|GHC*)(SFh(^h6hU(1C2nusA9r z;sL5F?6rnQUId-%tXxc%aa-{CgaDs_z>Z7Z>MCPW|Ng;7LgJU-Ck<{&kdQnR@Qw&+ z69QUM6fDCgL=}yKappC`^gnvb@L9L}KAG#Ok2g3o zJ;jLn-;$O87*mxvI#{i2=aP^*G-D7*p93ig+}+(_A7QKJXvQ$w z;v2=AgI2dEWBpu_tC~ywoL%+I_@6qfrC&Uz9h2|)ZLhS{iE~C2p`RIlHa3cJb1&A} z6f>a!3%om)3D8Go?5d@~#HYS<1+>Xkb3o6hcFqp^ePm0|WB7x*dJv-`GQ<%5jOcKp zGxfYpLQn>QnU_b28!S)j`+``edWQGi=8F_n%krs;^tqKI!4yt_R|9rD&ICq9{v?C= zwW_Kr5K^CHU)Rc@1WT^@!KfI+NQeY9sIj;IK8>TJH2AsoP0iYRQUxDrRHrEb^Uy~1 zTR3|&f+#A8W9@4mo?)Ez!4dp{`58Y$EF?QioutzCT*9hvY3-7gxczvVXGJmMD;#Wi zyI#u%=q)HqJI1fcR^r3;Z;Y-dQZ{iVWYvjf)#C&LA(L}?1#b$W620)uNOi40` zjKTqgf*l+`Iiq^Yx=a>HbSa5Ru%T0+?b!*2i0t*pj}m4Y9aplOXPul4eeY^`Ora1U z6gdC8p+w3h2mKHn+;p<-w{P4auP@p!w3la z5J=zgPz%4mT)6++3_h>c z%RopMtD}qXdAM-Ad367eC)=UYamMNDW;_U;dL)!*TTl`dw8O*dbnh?K<-D*Qyfb3| zJE+|#0%7XjJ#pRQ;^G-kP#=OHU}6^NS4XZ~I<9n-h#Ki=01D}MaIVXh3fkqLNtX)Z zY+NqyZy!4O`;K_i8pEE&8iL^wo5WG!OlQ`Ms0D0=uSu?))`1rq9SC29d zN7QUGGlAQwjm&^gLX4q2FE5Ofe=Gg@amY-CF5B+pX4T`*c5`B2d)b)jg<$^`Q| za70H)Hi`$UsB7K-c=Ob>#!oB6@H0>E_bZEwY}1GU@n0mGZBQmmNPBwb2is1zcRzzT zSfts{`ipFJIV?V{)gS20I-NuuG~H(RuKE5Qn+OaF;%N=bU}R!?RX9GJIkMkyTO_hy zYqPcd+uE8Tq4~(d>eXbw!2An6LnbJ4HX5PA!2#$u#{)_B^G-9iF#pld4B|J3E1R2} z56A{yYJZUof&F5o6>`dAR5a}j_dHs-Zvr*Je?&y$e^1M8ikf<+=Tkp_@!33FDx9hE z_RHbh#${o}J|eNmx+FpZ1WapxC*_X5sPhQ=FLc*16a{JJs~;Q`DsrCvVqp__5Y_A%3$X=FW>-ZZolOQ170DOBKw6kAru1a`7oNQruIL7z%a|9MV3n;@oJ)tcKprThA&Z2kS(odIjj9Lxs~_GYRFP52Yy<0&KLDEBaIx0>ci z>OIPgmeW)Gym87DsLF(@y&1l8)7$62?Ys8s9opF#+aLT~peo~S=z#|cB1|MS3K9el z+QgS=O<$2AeahEgOjHg~eULgeVV)hq_Vjr*+-vD}oTn2&FY5<~pddkAm*0fOf+%Q( z3?9M#rG^IE32`CtXes)S5G>);BOYhYoC0msTRS`7yDiHHvDU4Do&@3!Msi-kV{aeZ zRnOTmqgGc}$6u~lT9`%BKFiB1V`USm|Nh+(RDYs_GkfRbb1pZiBbC zzm=NGTbgeGBH7OoCI*mGLr#Q+mel{8`)6(eJ1Z-weXVS5IJoi;Jw(e2KvfoA+QtKC zl_tQ1fq)EI|uhP39vYo|pcqe(U@p%Wq119vzpY2Vc!yodDJkqV8@Tk{*@ z%R)XzM%@ecLzm^uhoTAgK@w);PhwVW_uhIxKuN}E%c`FVgZMo@7<#ol79<2+{vs3$ zx#*+>MFdzj+NMcDiNBz9T{M>iH-BTus?SM%D)tq9QM_L?#* zP)_4Ee(&Zs5WY2%t&&1XK`}fz3I#Qu3wTC)U+k%=sDSg!3k?kuYh8*qZZIMbr6nez zCNAiY+;&0lbzqA9yy0nNgfn=;RIaN|Vz{WnLUExEPJR z?n^ws+s$P+5%cJnKu89+&-N(TZy^Z0GdxWuHR0405<)`H!^Nd;O(w$doo%YHPf9yf zl*djdGU|O?Pm+broqPw5N=@bLCrhMk=56>WA4q}n{a$fIw^g5n_=35Yjl4m;+jV&fv~5ol4+>I`i)Vi2WR30)EaRF; zU0rM2^6czvXrN^I)qUJ~?c&K9_r&-8STirmguzBa0#qjkQfQpvVbAVy;NhHZ0^V~*);(0tOEuW`af zG}lvRW=h#4Sd|N%(GNPi)SQ#`+wx0%I=XRpO6nhbJTsEk&X{$2&V(xilXRkOOV+z} z&gp#s4S-SNVxgkP-mVF%Itga?ef1rdCA-^edPp9G5ER_}$(-hK6Plx!l+-USuJ_na zTl>tbv3Kp*gE3f2E>m5d_+wi)H*Ooe(U1TEs#gdkoTs;}jD6?Kt9m8~wkkNV<|GBx zjc4w8HEB0nCgHPh5&t4hhZeRA_!gs2fgc02ljw`GtSC**6Kx>|rbT#^-HmjJuIx z|K@9O%f9^I0n;Dm#*Szwx52rtEcQ$l*gUh{+k+vICB?<|-u2oxM00=DhPquCwY5d| z3p3rlIyov&Qr*0PelKe!UT;&DH}{?#8P4u~nn5TzIjhqPCq)DszX~=cl(}W%+Y?N# zkj`Dv4=;_1iWt72!(7BbPUo@2+iJ3#XLhGbfJRciN<}90_qYDV{cUWp=>xvfW|3s=7PX03 zO=8&;k#KBpCkvzc@D|qi$FlL};Yas|p5;Z~9XIxG-Dgii=9VU#oCkdfBv=%m9$t;A zgk@auarjSIStdmm8*sjqHf88|rL7&VXkgGt3UtqhiqjL*X)aCGk;m&a)w|^Ao6sM^ z_sMeCez=GZ1|bCU!tnNwh}wbI;gtYqvX|+fMV>ox1e88pd(y}vR2T%_Msgw*OieRq z_QvtQfHO>0mD?Ux)<`L)Fii`Mk^6fbRa)QXrrXem+k#ml3%+jxVXUg|U6WahpO0$O zY)xd*T%@U0xs6?s&Z^c&+hW!ihIuE|Z*=##9y%GxZiIlN@qkii&JX@rZlCF_tq~?P z2v8%JzX1u=M4dhtZQGr+b(ZG^TM+*#GQ;?k*d+d^58SO&!AWM60#yAeI%D{UxEu56 zV2~@AY+D+nvER78mVG`=pmQh2%I7KbD~=_T^dlx}f-epPZW@{W4tgPBgx>G?x|7IE zmvjj2XkP{zYQR@(;TKt$7a%}Y_ulKiJ@d`4?ccvhL`=B8k&D^C8(^lw{PY<%jaND` zX zl~_)QwlTj}pB4aP4P)%yuZ=BgG@19QkGwq{t4r7&(1B8q;CR1V zXirD<;5PKbaARdd{k9cmx~1?O61~M=7>)=m8=(=mt{GfB+#}$$#B8D(*~ONk zUa_t;Z>kqQ>e2n3fAK~U4R@DIo8^j@@(^5Cu8(a!m-5DT7#M!V{gu?RCtPD{ZP9ms3kFD0Fw?O?X0xeX?_fNN`@5dz1K~1_VqX35 zJ4_fdWW-j?>Aj7u=HKT1+ohfd1ip1wMi!Y|9gS6j8FbnNy9IROKP2S1rP#^*wobqK zOhryx%x4|+==tPGB!>xQ7QL01+g{tQtZ9img_fX0nB!dTi-t5tmpDg=R66c%&i#wa9!-f;duDfa=y-Mury5>O9JzA zn6~Y$5HdW9CH?!4nVg=FVTiO+AC*DT7I*!>n`AZH6iS z{(0E)vDFW@+*J9CYc4n{MXkoN>{nIhYYu}*C3RxK#1MU5UC=6sz-p}0x&M9NprfJF zWIQs?oaVW7lyk59;uWrNirBvfQ~(-l*s49Yf#6!Y zlWi;Fg)Qco44Bn=GZm>75C~k+seUZ)_W91q@X7SD)Qd{DR1&nUgayd#m(ffI4MqgCJMAEFmMBh)*KCmH}>7+ zd(WlWl)XNMeUYeX4AYKnb?=WPh+5nCSRNFE$j@N@%9j)~m-Jcq6R(^_#v{O{2NBt$gmK|ktE(7R#zGb4Vkh6JeSU6;q~NoW*WHzTyF%%@ggkx&x~cO~yT z0e#c;s100`YMn<}z66DT{LtIDrLp9j>u%-*jBedGQY!%ZV!94osfULz3ai{qHF-PB z_&;VgG#sbh`>w6s-xz3@z@h#996!uSu(2JPGM7%TKSJPOnNXw!>A834h+M%rrB2xs zT>8}nRixg3FXYXeG@bnF4ug_GOKzJ1-mzVYL)Xtf8EfR$ZYG%d>ly`%Uz*wL-*J0i z@n#!uhQPNt?@?d~ZUg!JPD{dM?$GPt@H006$1f-4KRM%mWXAgU>@&NQ%>OR zW%O5H|Km*4J#548-X~_4%Q&~{Qh~ma{r=X{>$uYSS5D-U83%s>H~S#SwhP~)M5~1} zh&+n#xAVJS;o7g;V)(4wT=H*>7?D}%Gs%JC} z)hiEL2tWPS(t3ThMFxB)WO}ye0*1cBgM$}wQX+|vU3JLk`uarcv_DBc=8Z9K?}mK- zB$6EvK}!Rf@p1h2j)yTJ5QR<`l}^FN;PKUedr%j7`>T{f93~_Cr??-UAfHEbV$Vq?v}pb>n3g7@;a#v&*qPhigB%kOth%ir|f55#IbmKtJx zs<0tLTTW{Dj3Z=`I%Wiorw<_zh`Fq`Hv6Mf=q}{3E*9jFdXBmIP~lNv^vdM^of%wj zzn?;_fdO+aMwoNj?@u(GW)S8e*PRF+Lg{Rax%)xDIgz)`HKh%Mv~w?-^^hQ82rScr z{(bHg-?23+5GijKRm@^FeA-+vee7mxQ#rMmpW1rr`!LvedawWb={QHS@5%n@GgfuD zj93+J{la0eb0mMP#FM8$0e!&2!D3KnHhe5F&&k4ie0FiYnn^J4bv>soQSkn9=^+&r zC1^MYiomYJ>F<;EeVC)v{&X=RWV^NbeW=VFO+XZ2ULyY zpE_th`hJb`T0+Hn>J#`{!ur1sM5jKM)rO%AH87XjqQu23oZE1?8dH*!<6;v%4;uYA zp0K~Qv7r*PWAE5*PQ9Kfybu?*8GlgR*z}rMRySr~>31+JPqkZsGmv-Zq-h3SD7Qz$ zKE;tRaC|#B2JbqyC`G?fLjSx5fjm9E*M}mN6S!`&(C)FJh)7dxgusHsSjvT1-FD8I zZ<9DykfAzpecO_0sUS7}UYox$c|mIKe5WIOFUZ%FgjA9UV#Y@PnWZ_!66;ozxayIZ z3k$Rg&;LRGleGi2L4IX7Aqh2?MT0o7&oMJgLK$UcA!Y0%5$b3t8Es6X=60px;lEcl zuEK+d!*I-KorTQY5v%f70BdD<;8tHL%UQ<$DId7Rkuo0qz2`{zCaf3JR5Agi_79wmWIBHj8Ry;i)eE9Icc|>+L zX?*4A=x8K`*z->;?=hrd!aK?MA<<^;kBxZbZC#I4$%zT^6b@AK7*HHBXRm$_*{pYf zOfm;}8tLJ+3JhtZ|A}46z&q}b|2-xU#JdY3P#I6=y*D^;xN6;cvW`A~UJ80Be}A%2 z{MxU7?ZD@9YG}3nuq68G3d`0?hxh}S9=6H1*@o+L>qNmM<{?O|MVbJhtCeDUSkxv*Vk|Es$g_|?vbD`Ht|I4UgT^7>lr z_)r?esr}4J<4p`NEVKn|_qvnFnIh1QML0^^)h&4iI(o^i3oQ8NOzf!PR-B#3Yl@-# z?esv}cbQ({CsIyo5x2hSXEy)3!Oar)1Yo@7R)5fZFrSyVy}O%Se^7VQe1FlbfA4gX zL+4Yr8uO#Szn>?_6yQjCpO)`8U7s_j`JS&Trn)HpKtTN&>ou2|%~ubEqbsF|amm>% z{RUyC+RWrtba%!*xw59g{qw$NKh*g~zT8-1p-V#HQTJ;>XZ7N9B^$i4^AFP|T}w7b zRIm|1;;lVK2lig8qYR;w4Nk*~Ct?hyn?v70ZyF#%P;X=%52=|IW84UronE1#EM2=K zp(SCSnVfIh7sK6kW^DJU&!bP+(L8+NLDNy|S~u_81)gQEZeWOh|8eb$gUPD1iR(8p za&GOU5tx<+;7_Ka^yZVAQDTcn3b@F7ueCTb^xm!SB6Z(T+mUxaWfbdHhPa9K^asO?^H@KtJzdFL%D)r|Xxw z%XzuH1rt@ z`=%z-jRA1g2217T4I#-f(9kfEAwxfFqYbQ9yGGOe=IeY`dVowQNS|fnon~MP2|!NY+synPNv)m>{%0b` z8{N+##{LKkf2Vln1=EFb2%4YU7vy~Hte761W&+Lt-%39LXbJ9EqB;asH!K}u@ zTL2p$$x-{HqMXR>ds8IlO&*LudAu$v_knl8ySH&uQvVpL8`YkWqh7cQ)~!&a{ANNt zVRP!9Ze($1U%J5^=-_WJPk-47Z-8y3J|E;;e25zafy6RLjx@hUETE#!I~Rr6j`v%XH+Z4}p_l{UN1dt@FV5 zaH=%CU~#cU#J-V-kFlnzXz4ae9DFHfCnvYPcoQfTrnQZym-h)v&nFlXv|k6gMt58t zrGsG*(U=Oeqh+qfB`?Z^m zz?ir(ITjFbtiLKBSsUFsgQ15E&EHf#WJ&5&2ht54@=V5g6l-4!Y(^M`!9xhd}@=u352tH4g6 z?V8G+NbJ^)#~}BI%cw@(W>5%x8K_D5KusgRMShc>o&YDxjK~Ef)S3mf!MvfZFvb=7QbCiVovor3aU2BMw5^Qow8piU~Y^7se4xi zdhW`}f=wm4R()S9#JoSUgrTEhlG`Ze9Ip0>V^%=cC5IY$zGOTpW8XRz)-44e7j$zx zckxrYD|Ta)3_Xq(JpcafHS#vU*cSsGNgZme%;BYy6VZ<_4Fk^D?$O!NzT<|OEeGM< zjb6DvIzD5YGv5{VLn7+7scdyvLmWx3||KA@bGd z;<B5)$WkXszV^q?=yR;Le5uZN?hj`Cw{v}@(EmiyllLm zR{lm0MW3FA1L5C{U8DIj>L_2Q(9YUgNWCJ;*t&ciEwjGpfTzQ&@ouqI8jPpgJDX3B z^TRTzg92&0YAC_D6k8ZUm7ViAW$+##5m~2xhCSp8?Ydv&49Za#qZ@5RtCj1Ut zKV|?)%N7KpUu~OU{@aA7e7Cl^^!{0{$(Yq)qQu`Tx~hh)&#&c*GN0af(&L)&K02TE zS@UaNXqer9XG#$neA|pIwHp6Ah#}^-zxA4T{3KbytCY(iDnRnf=+_5Clz(vak68s9 zHw`{MMBsLk7S4WgyMO9zR+9e{&vhy(^2*>;1MEE8FDuL>3>VwlkSoEsOekEcZ(WYB z)g8OXZR=CLw?A8JGKt?_28p;GfWuiJEOq0JsO*^p45kKaF34L8!)UU_9|29jv7c3@pr^ma^kO4$sbxE-prVyBw4KB>k)8zQI7y=gsXc zEYQGJk{&8XJ5e%u6vj%^JBNT?6+L>!(}7;x%O*7X)>a`j=}B*@q1uZJi!fF-no7R% zH};+?sV#W^MEIyz>Y$M%#VbFy+l$t|^C%OBBoDCfW@9d+EPvCmdkUFuEw~>3*CF24 zB9DdmHBYt|_Ah2OaPDL4p^dB)|O}He|BNlpt`(gvYb(tz_6SQtx(pp}-{H zz}ZI3(aUrs_fCz<^bbCFVKOE}lFIc@6B8;oZ<^<|_wl(wEgoty^d}i@_tf;H-tW@) zWVCy@ZIdZ`56gj4Rai5hYEB!3(@Dx|zgI?!9U;vo+eL?X=x@%@!2k7(%ioBqk9`wM z43E6{m4y|OzWrHgf)1)2v4Ts_F*ku>1u8mX*Z)w9?YI(X5Ye;ETk|!N>iM(9((v6J zI&VOUy(pF6*Z;dR4f-ukzw@3&iM>y2PFv`n0Uc&_VhBVMDpn@KBx>DPqU+5&?&{ei zhVVC@WazBUZdb!5prvQ~@0MJc2B?2Tf!>R7z>~1^5B$tPSbKbad~Z{& z$6*0<>=16-W7N;sj_OK%4vSbfDzC(UiYHerv^cYiP2+Osu=4~hi$@JbHkP{Bt)e+> z`2&S^PiPYbea6PCJyjVEG~wy@aDRB0vO=OeqvfZ$F^fNG_G<0oprDEI!TOk(DIM8x zMYAl##X2!Tffgd0N}69;!Ra=NNAqG!3iikElb)`V=Odm^%+XjEG#8*^2cR!U{hSk zfMAyhbU>$~)rn$?8@kC!6`|j4y8pGboiWf}lz!v$=$k)%=Bl#hH9ShA)QOFtX44uY zBhj{qr1g?A+%kSbZq<Hz#t#nwrItDuV@4m%lhEi)dD>@ zl8c{-b#)@J0-CDT{9D%IArRruIoGS0&}2@6plG-0UoBg z&JWPE{x#m^m{j!3SAy&!YT*8;Xqt~N`^lX{r7VBF3V;xLp*dy*2lCMyqogcV2Nzh5 z>jGXB0c9*VHtm-em11*KLp>BSfsI#h;3#f-qXrA3uCI*ES(_c<^g0{Bj^jgycj*nZ zr|qkjwA2eTRLinv5Jqe&58*FYI5b!g$mjhSRm{W^_SJd`EM{^HsC5On`DIghq2C#6 zKX$6%p3}u?x#@w z43MG1SeZK`#lF=S{w$Ouu5;WHL_?X@WLHg}1{#2FI~ZtWGK_;(CA=fd(DHeR>Wrh= z;||t;DUITiSst^53@as!z?4x&uXZJ4U<`#K#=Ogd7s_6o^0LuVJS|yh)@cmHx)FPT z41s0!2B`%?t?&Q$uT%+aif@I}f_hS2%P62Uk1XP7*xEbtF3aS{L!=6{C9;jJ*yz;b z)f?qcm8qtecXYFN2#55Ry#PqElcp#Ss3A4!mfRU<%-i2hW~A@;N@xvmx=Uy( z^~|Po(OrZvNFC-k7(4W;m9Xu?yhu~)LEv5U5E?S~xl{WnVNv$^_{}4^#jEzyJ4MXW zTnn{qKaDscJ<8*R-u{#ySLyGym(?SHhgPN@!pR#FZ^R4a|%=Des<-=%L+0JH^bvG}jVbryZ zM3o%`G~v}s{LExt?A963@rLYu>3S)$3P;g&8eV)Tp5FEuNl)*hu%@st+%Uw%ADfN#2 zj7{^r>JABU(B1$36J#c5)i6jeXCRk(aoozqW~icIeWgg9C2N1c(p<9NpfBoD0|^+kwWVtrJQ^uCwX{Jf65Ep}9(!kNocUh_$blWDvRW-o ztaoCfIwK=y!`noW_g}AtnXQ^nFJQLPTsHO8XsKjqWhwv=j@t~f_LO2sasFbgQCN<8|M#Cc?We~PPkBq z!H~M~*a$FEDl9DgS0?va@FH^?8$kw4ROiaU0kA!(0(sDRRMcar6gap1pu(SKrFu{v za5{X04<5t$V$rbx4{7Dx$RO%+hfm+iEvXG4Ka{^sZ^GI6K0ZO%t;wDrMK)PcHBX0d%6`r>8<6a4IgorbHnGrE=G3qK^6K(3F5#sMJGZ*_ zm`UlZ?+n1Vw=TKd9QYOYB@3C(&(=bJ0hMu~Vfi}H_=bmtf$4?cM#=@XYG*fQfF>6{ z0#ioaH9$Omb7(he{3-i&lE4lT6oNZA#Y#!Z`w7{>$Zwh8AdHru6(yc>GJbYoMCs=y z0EKW<;IXkTMZT=YC>Yw54dP)!apR8sd&Fvi*f(YSII?rSyf6f&rG^s)Qj$9qpW9GC z+~VzPSO@0t5@XmlEfk>}51avcc`$l)Vi|QtzTF(JWPm0|LsLp%zXLMAnE!~VwyAiZ z@^aOhZ4IQdYOgvv%nbWlRXq#5v)Flmc@cKEJx0L~@r zYimA7tw~(XXRQ@KH6bXj=HtAKCw_kWi5=P5!1-}dyEgz@ChvhglFsk=XIfesyHbt> zRhMM(q1?6xiySdaz(0`xyHdnqDa#Vs!0?fOX3P zkzgc8Wm~wZw*QmHMvi%Dk+_Ge`hVSreVppbXPI$NS_f+fIjPS2WZSfrzpC;y=jEYb zs23Yc7$l~W4^y9PvsYH@OG`^LsU(ApN-HGcn3rnqz36` zL_npb8wL=S66x-cllCIY!6x*Tf7MZ5hwugi&&KzRD`C zsyZ89W^h|{!D{0Ky<(sl5L6M(91y{!i91?bGgo3pS?ud+5bihbc9N3I!$wlYHCHGQ zp1!=k)KFD5mJ#$70Sp^eC?TZTu%dbFYC;mj?3uKpf^FY_e^e)zthe&PNdW4S*^+8t z0X+&!6{|IuWwIDP-L-JGqjwgjL7|*&8S(#jR1Wcnm@L61P5sSt->EA3gXzQt2;x|G z$`Ii57$JlvkJD%;h!B=y4`ou=65(NkX589vynoJ;kNsa~ouhpS@j{rQ;u;!ot9`dd^Jq?MC~OXV)62koLlM={|rC z8Enx&h@?ki%Hj%pdh6s@j@X?1y?pl#*2<}yUs#$^>FM2jqbIBWK)^qTfZjh8b&>cW z3-QhO_YqUzkA;*?Cgjed+wkfcJ?5L3oxgv_W@aWYpGdRteW=o;hcK06cgIZItavEG zWDt<8JRNDpo30XSM4rDA=Y!bK!zbLW} zkNV;|Iyyiye9Dr?)qD5@S8QCL=aac7(xUel;Py0%o~yH8%l%X!uO#ocdHDj+)$7i8 z^r~HH+R8+Rtiu%Ok@&$XRR}s;H+(X3IIEnvp-sAL%Lj=dsPaqC!_B-e$&-0B7wXv3rB~S-}sjzck{T zn4vcprCDh71BHbIqG!6Qrbtp?SUc^ih#9f9-L-E#we$9#`&GL7wy$@uX+M>t>1Mr; zqp9rT0*Ii4`8l?HRA|knJdXkDJGg7rNDDOV0dDimjH((hIj*RRbjo%-J<_gP*!5s3 zq+~d1@qQ^z*zSIFRq1?RT)_9{_ut|sF*p}U!s3A~c4Esp=Bb{;Wf>AFtp#p!_g-7E zhpSy69d>*o_ z#33FMs!|f~Hi&43oPaC=QdL(?Yy0J_=RLV;5mdtK_E93)dA(J;v;1~*#KP_e`Sy(q zGc&bDt4y}H;)wp@-YH3RxUm+yac@Y@&ikJA$3F6!YS*4$grR7~Q@k7Bk6!sKmHeIw ztSQic`k>T}glz^;;9WYQa9Bob>k3%BhQvL?%o4y5&Wh!h9$8#}x0j5QBb+k0upl40 zEmzSKT;sF>L^ki1o0>Ye{S*O*n^Kdm3S?qr((_Qqk40L-aQE zt$MLmTiMR(lmXRa>ay)Fy1w4tp%;yrd4ukfd3go>10270w(}q1gi*VCIXQ6$fYAD% zY6CyFb5S>7ci`vc=f-tfYR-6oMGzsckRo7I4`xlLQ+n6iavTOOyOl03E+7&|-330p z-N@PdqYEOX54*!d2S1*|UdLFQj`C=!Z723hw+yq$b$#Na4gP(#{x<3lwXnA$I8jDV zt$sQjY(+gexY|6(xaQ_Zg`)fVuwSX@2_&IhA}y_~Qn_qBt$IT`!5DS=1f=ac-#lQgcq$C|xwc8H^wV)>0k$P%;JbP?w!RtP-+24P%iZ9$Dx5~T8^OdH$ zv!qxpM6RWJmONEhuz94~zzzX1rm*m_9!M=nSH|YOcC4T~KQceX`FDXpfhn~AO{?*n z7=6#(-SC2);jc>@sooffi?u0n2!h?9>f)!y!h5yY$=$5Ha!FmqjUOXfK0ZE;ck6XP z$d>B(l|{|;kLU+lh1fMYhgJ{0uXo)7O^iu7^iQ^A=?uKuTz2{LJ8VEyO4R*eSkGL49#2wOe*?erEuG9=B0^9NxdecSo9J2rYADzwQyPmz=j*`TdU|?c_ zZ(_qq&SyL0eLA7haDEre^zB1mTp?|gZ0?xL95BiU1Z27`dfl)5l+%g>Q_B0pA+i~5 zoxgtpjNr|eFZsT@KJMpxVeGXZKgv4~9*U$W?#wgGoR+YEDgJk!=HuljBOzP#*sw)~ z&fD>Knr?sZq2jZ%`R_kT=n*I~H-*JK{V%(hM7JC4mMp=+So?B*0zVl7lGB-a9GR1^XF_HYcUh>i^Px_C4Dml zwCy-Ti;>~BAFT~@YNu}<_W^f)Q`o=xd7f|N#^s-E+pFv@z9`3zJ; zmKDs~bs_5yTE!V!+r(}*zk!ex0)wJqVb_sLfvMm{K;GC&{(|Rw)u>Of=ViGKmH*{4 z?SY2D5{I!=Q7;oL0+9JXJv~J(Nd@E2?w$fVAYfFyd#cr;sTK>;#nf_2SQz{~eE2kp z1<&w7R{HGB6{Ocw+hR;he^60U3PidI(%{#fDrrbFAb~@;oe&3tsa4}88WScdI*E5s zO^BFDq!MWF_?(?>vA0>c=PZg1&zWB!<`%9g$|(3H&}V!iKAVdR-$9`V!w~U(B%scf zj_myRs#0&6_pGfS1-JS5o~W0w&~eJWiJ{B{*5#MRoH-6YhEMzRaTt2xu=bZlK~>u+ z6p{?9`RC@RXPBo%z)Oi{lG!i;M_OFNvm=^El8Hi(#9$CkA=~!+;wBYcZBio ze~EiWoDI+~$Bo+&h0eB8TQE{3MKP>n?)UF*$KpkXHiDJOz5igkaI(`?r+o5ju|_c_ zHllmELVbcPmr>!~_t??d*a*?oAThChq#b_O;v1cG?l<5hf`Iv#-^%azIuRh>DHF$; zC@_5k`Ij_#pY7J``1$wabEcUqRjn`LEUsrU1dyP`f#LFt#sFzQChK$mPq#isZ>zIF zu`BaBi(wb`VSk;|f8^Zr!@{33^Vreg?MP(sXMMMUH9xPL%k*^r$;qnw`U7WE>5GtM zDKrSMCHM7$2x8L2tj=3ypUy9tDlVraB(Y%QC6QU4iv6i5zfV9VX0sI|lO-FWTjKn| zMd38?e6iZW=lx9gIGautpz6wTG`f%HRRnc*9vwZBgh5BHjzmB@Ha0CKh4dFG^jR*2msqt`v)D70IKz&(OkPJt00<%A)>~A+pZewoM1pm7!f8Y;a zJz}EXP-2S5U8M-SEyBo&7yf56$Bam5z&mAS2av(eO?AhGlsHZ8Ny$c9&ePQ$e8NWZBr#VXZ}bg z9x5xS3VD@uHud7M^-tbPqFQw$D59Gl`Oho~P|cc}&sxY4NKC`A*P%yDn}5iukr2t( zkYCF)EIA4Zzvt$_J1v}$Z<5y)ch8MOXm`?ym;~GQCPqEanZb&{>79ZF!a_rPDFug;uB=v3w(V1tFzgC z;m>bQ@B}2B4{-|dIAuVSvoumeLu1jUi^jn3dLGa}xy#FNF$3wLvQf0F9;vIBz-1V@ zTH%0}KT)+391C1yFbqg@G=r>}7FUu2u2ar37xG<5GOH>k%5XREvZ$c&{E4e}6(!;1!7r=KAE_73 zxCD`?0`j&S!ScqFTc7YvlA-D_!T^EJiQoDSEG*AlUZ3vuFD#_r8A@Q?&{T5W-8QXl zB}^Zda-Bvf?5fY=42jYb(R_xP{xBH0bZdV=1HWBo6SgqmbQxQ>{mXZA(}UHA*5+US z2A2K|)yD;`HT`+aOpoCYdrTzyjL8!wP=l^@9ov%5Pkz!+mO?zCA^EKDy|?b%Hzm>X zjg2&lENu9=V7`I1Jh;F5Y%Upv&_kMyOI6j?*Von$(ZTx)fz-L~N0Uq&^WE!HScM8n zLE*t;g7$mK;3tf|{@~(pbu34*r?=Nwi_}CO1Xgipk|Pvz8JJ61L1$ww2;QaB#L+CK zVgpb_-Fu=O!+<1h>-wkvX5hF}7#fnc#o^3W5*45&`F(l2UZ@!mpuPRm# z7(d#aPkabNRL|1T(1_X(?%ZAz0K!D+YUi)HqWQXW=N?MXoO<@U#U#eGF2ArMZn?&{ z(!Yrtx{Xh*^vgKMkT0(cop8H)s6rO^#GelSJvF-0wQYTuO2dkg>;8I_s)NxCScqxx zH!>Ku%2=Zmsg}XjdD0xHAPS2AI#%PqZ=Kfm7px97U5E$jV?hoMOTWiztp8d6`7;L@ zgmK?#@*zr=Ks|I@5d$)@N0G$&taniY^aGlp=&Z1XbIG>S8Jvk-)GNY&Tm=Pz^Q05F zoCzr#sOVhpRxfhl2Ew+<-%M>eYr`Hw-l@fIcyLKPMOwz!GkqgOxT_!j^T%RHQMG;_hPu26%3CoA>z6$A|yDW9ya{B8fEmY3-Gjz9fL_h4w^ zjl=U}^_v@W@zez@2nw}hfj{|I9;=++DS3%Ti4*}P55mU5bE%sbQl9$UbAw^hU3wU= zXg$atTc9{HH9mB7$*ZmIXX1L8SJH>B60Ex4`v+I(%E&udg?ogmFg`@Y09Box%d zV4(h9Pp8j8*Hv+0o0sZEzBSm^#;W7*`B{1Xe18`3Sz?L&H8evjjAk=HVOuHXP!e5f z!FBoj@9JV23sOM@6r;)eiUF!Pr_p7BiS&<~&CV;&D1678#0D@*7)Zkat)Rl9A~4D9 z>guwpbj==0e*7-Q&s#>h>CIoAM!Vaqy1n|VfF_T-C~lw*aG4XNGp~taK+L!@)uzW}l9AgZx@D{BINCxkK;?SMfN)P5fD$4QT}zyF?B5Bx2xc` zEB^6fg^?@;_X1Qh&Rf;lak_8!m}#oP3b8{W&V9v^;2>w=s(eEJN$#w>G`7Heo;ytpWV!ks~0&Y9>wG9@o!2t z3pdB94PJHieBcE}gJz&mzP^nBwW3pM{Byz_hk(0-;LCh}N=I&|peW~85~Y58@rR(x4ef`Z_2#DfkWNPI;l{SBe5YP9Qb%(Wp0aT zStAub*Z5k|l_BXkOnLBkW)ncjYN)8%vqZ}$@}Hu1nMXrtHn1=(AK_qxq4hnIs8GMJ zp&CXL7od0?{+5dCg{`!clboX6UA}#Na48`6smS80sl~!#l8Mt&69U!bh1UKMAwcz* zYz(nXG>OMMQvp!BW>Vg>8EyKpOhTe-H3X%z{{e71_#k7PvPJW>> zM`AtWI0gjPN?$cUIsW^7N+Rd(>p?!KQB(Lib_;rOSQMoPr*&3lxBd|im^%I#@T*x{ zA0HqbVxN1Z3f*k=&BgO-cuYz8c_cU`B zpa!tehkuV#lTxA+8Y_(k;2=S4%}B@!KkfA^&_ln{lBS_wuN_noATHxlEbbE~2`xo! z2O?HT_2NDrs(m%9?7yS^%@?};x8h_Ac-%Jb4-tlj>C-l6zve4lP9a~}d$fM$l?QN< z$~X+AHdHV80`yN_UY>5nL1tRDjp(uQ^+u3kzmu`I>UPFX8i~wN%)9aj$afu99<~b= z%5qvkr=$LM*448qip-E^OA+Pe`fC0N%5*6F8?%z* z?-ZiWC?yIp)~8P|PS@?uXB6}EAiYI|nO1C%EyLUb2^8{|IOl`Zb*!wGAN5oo z4q@Ngh~T3?V;NlvF#;N|o{C`Cf&LMOj4~@5TarslKOnx!rwKhII@-4GJN3Nvu%ksn z`as(`MJO@_}AN$ex-tm!{oUt+Hr$t?Hc?}$u?^A{Xk^D(={I}o16QddN z-H7zbmA>|mJI{IsY3_Nto!$)EsMItJiXYxLG$@ZZKZL6icPuV8{r8_BpX-5&lDOBR z&-sxze~=&SiIxjNSd!uR^*FCui9V5)D?P?>vZk8G@VGkQ^%S5QHJ94&)R*uOYO&1W zn!~@ft=%eJ6;3md zf=W=&CK^1yO1Sx!neR%y`srFE*Jy^9Q2&JO&@n)~8vw;K$E zfMfeXG((Wv-LYIeyT}?oU@B=`ubfx{gTlTf6i&`9vUtf1Yt{2Q^Shbmr|sbeuCJt` zrfo1Bm_IoN_DEB$oa~C5|GMas#mq8MD}yX<&#WXyC>02(_;zB#;;9APEoaYY9zJ}C z+m?J!Oe&M|pb+a17<~g?)F3dpd$xxVf(!;|W7C=XVW>t!%g@zx64EwdKCD%58wA2r zurlEpN^R8p73N)$8!7R=AP{>(8Na;1fAj_9Xl*wYhVV^Tp#YW`=6mCC%2qx1{rmTe zt0Vpt#f?i=?oTjdEiU9mQWoM-<9wuMXISdTb2yUF-CD-yHag%d*O%DYNZb&Mc~MNy zW&*{|n-`thaZtpjG=c-JspfY|6s{Idh@4TSL%dc~11z1duuQfK4K)(P>nxwpZ6cxg zWh6{pUG0A3C%?Y;-Iw%cf4}?v)5Mu8FiA=FY2gVnN=!=o#r5hR` zLGJY$2E+1D#^cx0pPBA{?dlcIhG|4POQK6iYF(`z?@fGp1xNx>aj^&piwHK2Gh+27||*aFJ7HCUx8OT@2 z?)vPyH5{=5!=Qz>?9bDrs+^y|SMx2w7f(ch8VH6Os9BB<3eU(ER|J_Er$!IHvvD`w>uu^-AnSP=ThEU9P0@8$E!^WN* zyZ-k6zS~5sMt9kqEkpTh<)6dNmeqem^c~;9P8Jfh6rh9xhweRS)m?u&3n!eM#t!{IzSYFVaE zEofs0VX)Q%8#&+4?9D4_ zhK`O7Dj_MUtnNA}bv{4h5iKr*Cl5nJ&3=37?Jh6hA4;cAN=hF9WyppLFx()4Cj>C9{rMstrCQ>>br*V!KL=Bb+6#M&B3g4Xm# zO%PpQs8~0@;l)zh{rPEgs5|y2*1vhyRa2GHgVb8(9Cg+FdBv2kvG9PedjAOol0QI8 zLgH|5V6Ss`6h_@wq+d=5)`gp^Yr0&elyM)8uf!8w^uI@EwBNn^XtSUCg@|eHb~v!c z;}S#*Kt7V^XWF(A5*E37g!8DVW*y~V(j|I*@{Ns+jlH_AE+~y&wN%LNV(u#EQPdx# zzKAV8p`%<;=**4sn=$x~#^p`ulk0@QfZMF2zNnYsZVLKVg-@z&XYW?G(|{gu*fp_z znL+{&K*^rbQJNInS@pA;JL_vaEz^;NqM?x_5m2?E3uX-p6Is=#ou~eDDS3v+-B7@m z$S5f@Z%yBsN;}E1KqO+aljm-pf9oX3i_#3e5(OVtpXi5HWKicTNH@6r101(8Uy_XS z$$}*vh)XpmQV2USzh8v=CyG_YS7bg0-6dCh*Cbt|++|T4jg2=7det-$fOCu2sGMB0?Co!F$r`{E4<}igPx8v42SUE;;L~@kASu$M-Sz zl}$Fb+wf%_>+2z)Uw#N>7Us>2SxNObTPeswjiih2G|}uQuRCP(_V#oN$I?zq$Ao}K z&TV)-fIX4ldhrg7b<9oahGxt6hkuWJfcV!3JOQ_4BATCiIOk_^?^X9rJlLB`J}%3U z={pX%oZp@9#l@Ld&rZxzxx9c9N<*JuLi|azTz?Z+hjrdGp_xG7kRN%WFXTf1?CG!} z2@wELbXI@^3hx4q-ff2v#_N_bdOBb>E^rNxFV>q%R$>om3AHt$ZXNXOar0VyS0KXYu63GePUfa0BK}oW3bd5T&i)-MisN`SlB46|z<5gjbcmM$u>x*BS4f?riNBa{4 zKl2uHQK%l%nGy#TC#U00?2o2ix0`;G{4o z%G(czguajsX5_0@=dV&fPTm~w`T_&KI45fbIj>t_n+C_HlQU8mJaj0c|JSn9%SWy~ zC{~1Yhz9G@RLtd6fgYK#`$yyVD_|l9LS6vi1YmhSDk?->4k=U*rfvAZ8FSchST5$z zp*sX3G8C4%`BC7X4f-GHNOg|(zVyvt&}3)7@vEi!%ddagVUOisRMA8viUx6Sec5iv zve6I2yaDtGRoG*--4mLpNL>NHv(LvCZjBQ_I0j(JhnDB0SDF7Y@{I zOw7y=v9SCuM!H1y**<5%5vVR20q5@qx@CSVP!-MZFWwX^&?kDs+Tv_=+Cn|M)6p79 ziC>22Ht-s4xx9&A*s-#MV9-a(P#2#QOrMXMOnC7fo6}_)?{l2woXXf)TH1X0pho<& z3J|At?3X=?bZCLIYw3dUI5fYEahl=4lLMRA@88^bt+Q%7qjXguu1h~T>SNG9ciu%} zK)?;dQC}oyciqt}92xVJMovlGeY1rA01XX|;eAk=@0LG({S}HO7++zqukU{Quf4Hx zCWlt7YVK5_?&<0>gDk1O^YWjbZ%$DgzD+Awn5D?C=@l32t7dzoRQ98I$wA&UPK7W-{c2YD z0-J`{qaE-3k})US@4!z&okCfuKC8@aC=i!ndIn1&f&V7atJkf-nq?q{91*4VIASH?bf!( zh>+41lOsCf#0sPH4d_cBV;*3E5|#B!{O%bYCD?6c4{*bM2E*dO^iOx9FB0~E8k*sPD?JDJ;TO8 z9x$nF7oLAI=sKp6^xb$g^e+trx23mc3jlrAZX$AoYV%5CVE~UTDo=a`HnwF;WwRnTPw5MbK*DWTXdJCp2d}Ljdjl7 zNA)Y|59l4jjWxSDwR-~BZ&<4<&H7W6{-OtzH@SZsqpGO5Us%{){JExVVP>YGu3mz> z;Vd|BcfL8oU~T=^mQRO_HUTE_#+6w*%IZTg3#$K*M+O0s8u^jcKw0DWxHc6@qWj5( zdL-Afm~X--F2xX9wZ-=zTq?+&ne6jPP-_xjG6B-PwG}}TRQTV2Mn8zK-+|6l*xR>q z`W~kf&Hl}Clj`wfr@-MSG&9pFpFBJ>NlC_^otO9P8HdmPr$F#o>74EoHX^&uv?lV`S+N-3U!bM39$V?Qh)oz*@`Bc}_<)FJ13DfQh%N%@!=-&Fc z@bR{CwJU-*L7}6AvAD6Sek%SDct}~f>p7RB?`N!El!!meWRG{<|1K1JjDOo*aZe8 zrO|QYRmU>9+r79zAlGVEJVnq$eO>ZoBl5ZdG4uY-y1id(!^L#Uln#Iubl>(|8*>D~~A! z(40Gm;ndvg#cqqIQ5*MytY!k8NVAACRwofG7(!&~WBNNtrr7Xn=wU7MoD|q|X39~S z<`LLRZ^|@y=o{@fP&e!$1%(QstS+{Ps+0$A9Ow{?W;?fEDohHVM=c8r)Eoh5Um-9r zFYfwKTri5)nWQ<>qJ?NeY8^B~*0)v0wOYxk@PcTB-6kg|9tFJ9)&`xK(+ji>pJ9*y zHM4$$G&79DVZeuHhJ*+)?-CWf40s(efp^p|0n31 zsZZ-{8`&JXt&`tFyBT6O{_42rGaq1l&>)6SOPZq0UzAGEeN-7YRqrKvrQ;!k_Q zMbDIV*mzART4rIrln$^J&Z|HJdJ!6+nESWYG7Zv9@2Yy#e{z$8LY*XZQy@rKI!F?- zVmOur(azV*SYZ+}u@8LO0&ku?>K)VC*GYx^Z)WrDTar#6#G7 z2B<(GkfBtRe?7d_9=un(HDYn^Gx8Znz2IW`>Ti+HS^fweM#KB_%nX zubR`(Rua$3gnE{b*3S)<{NEK8Q;d$9Q`4%KotG?`>@k`by+LbhwgivC^>YX1UFXmw}-h?)5c0>{$^{Vhh>OOIV5I!&VS*}YYX~k zW-~J*S@gc1f5>fKe>x}(Ziqwozl7ho>h!w!RZ27Oc<$k|%Hc96FT9L^;nF|$c@XJ$ zT*N5EzE`0^VKkxKe3QRrtaQj zv3`xkctbz&c0f3wr^`!@7M6+HCedKU!NZrAS3EjBHUrnaX?BIPAk>RX9Yki^}zdpZ+5_fN2w ztQapc-|T?pf?kTADI`O7iGw~19RkuNxGx`>!seNY*F-Dy#{eGH7Z{dA8%2YQy+{X;b5XmOJ1o-!S1AnftMc4y|CJ2qpi$D)NdoPK>MECcJDjv$SayNV3^U%_({xP zofm!9P96(XQUi;xpj}65r!tihYYu_$t*QFl)i=4R{2l@GUnyZ*pOs_b8EN3cimkoQ z?!^ad>+8cVx_oP^-aZxrSvoswYZIljny1K3=as|NnxVT}I-n;dRoSqy6kqHdbATM@ zL&=g^8lX$BY3M7+QC-fTi;s^_J_?Y4W{@;PAb_66p=zf@kS5eB^6a;|^3cJ9vNy|u z8UGigNqJtl>tSTH+(E`sso5WOx4T?*=9)X^;n}}PD4lY*`@Bhr17_S~f=lz=c}v0T z{`jNL2Qm9I_%_|33{mSrePK?f`)cSP7Q4bBq1VI|?pl7uMGpyMY$ktS#v?Gs#Bb>A zj|4?;mx(pi1hRfQ?yu)jkTL3c9!K-vwrWG$A1t0nK9! zke?ab5<+K7n#V^Qo=Fl;R%BBL$7vJ_FqOfWqfFcTlqw1h4UQtUs?th25jsxWli-kq zNQP9_&45@l{v(BEjPryJXl@HRPQ4vifYJ!OrJ$c6o)gEw91KccT7AwJzJ32uVm9lV z)$$|e<;^^BX%!U#Blt|lXD&h+fMYMMuSXC+OrfV1Pi0|w)Pgkn+G6;zMi;a4$jp-BdC_1eu1?ajLgls``!h+SM^V_mEV)NN00Ilg zQTGwr`$QJXUr?e>S{^Vsi>?QlQ8P0|joWXPhZ&hVcjxQerhmqV%gZT*L@oaMFb1sk zE<#|PW^J2ux!xV&Qcm()DEqLQb9ua>ki=J5l6P=C)WoC1>bBsJR2;S6t0r!4YJRug z>lcpQ70U1}XJ5e}7&{1dmLVbK^?+2vOo66SnuAhlvi7gA64<@enl!TM7egeP4g$)u zsQ=jpXCyjgO7x4w-K7QW`v5%_WX{iu_2uvD!ui0u`X>WmSXRB*=)B z6v)e7epDf;4+-CkzSnV3*vWiTV{j|ad3HqmWFfG~0Q5P4Y(=;gINgMN`@k zFvfsVMuYvD>ysLv>RGUY85+{VrIm>h-64d}0n9!IbPo_ee*Dtr_d6}+LGGo0N?#W< z(}LUU_h(b~TsQmjLwDOb_gr#)O@~hA)3pd z`lf;aLXY7jB&B!Nm>4Mh-rf^pxRausi8A){YuZV+$)5l%450dudTGD0QuAlLf_D+W^qjb zTRHr-lKIbY%VLeA6{J!3M{RRd1`Q;U4!6@LiAl)z!Rs)b-pyt5>H)Op!>tcCoP710 z(_NFmnue+c22s!k@JbnJ@qWfamAxFsp-SfZzdX};LMwTaLg(<(SJ0aQ9M-`23JfKY4h_{m+%h0k0jjf|=#5tcq=I ziuuUda80i!U%JO_Bo$T;*j8e5WmzTmVn}Y>iau2OME|&in%`2|OezE2G;45FO-VA^ zY*fbWe{8(AU;;Wmp6NBmhZq<<)jUxYqE=uCYF?hud9V=%d$j}*12b=}0!CMKaPlDo z+v42+m$(D(KhA|ynNxAoVO)DV#5*z{ z1?+S1&aXseO}=j1+&By~ zD$LAto>h`6Zp;@uPJhy;P1Jroo5wDf;}3Zj`dIMUQRP$|KR@Viw;>Syn5I~4LY~6f zCo)9j&jkk-LvZZ_^HBoPi{@w(*?C%mK5_^>ZXkUCU___Jo5{c)fLXavTH_ zL*0VYoOJh<$f^l0eRdS14ns(?osQjQbT-^1ir97vr?Zh11R_EfOlhM<%vKvY`botb zEVpBFA@H&)-kz8lGlhNn?`B#sWEi{dK?j)u=-~8BuO9pR9wynb_JIWznJ8MjbiKW0^y28sbF0t&6T=as^UF3z5GK z81PA|zvniQ2_pPw0Uc_yQ%6jFHPxGe@6^<)$9yCGe6Mcd*{5IX!C^|H=msF;37yixr{m`Rg$A));)D&*po3dFKu|SUu33#Wj9ha1pJNCD?mn8_FDlRqEh?9Kt z#Fq>n-aN50K*_^0Kuuv<{NYtSzbnBZ-~aEEm}fs9&UlmNeWkUt)Uxk)u>62v4&cxf zIo@ZRY-=IMJd?? z3@sr~s_$AJJwZZ}`|7!tBRWG1k7-FhO}h=G<>s8c6qYr6gI-u*cb z&&B=SxudD+pE5JDp#*LgP+yUgqh35_gdmUGy*U9T88DextvKPXhtC=wq2&?bF{%lU zEWZgv5WUTJ!cYvpR{YT}(fP$zWt6m1`)|^W^+SCu_)|UQFSxF1bzc=ZTWV@Nj@Ji4 zYi2@1Ld>6$Z6?>)hjlREWuTN2xb_HMWY zWXNcE`P?x4R7mfY`TBy-H4Fhm$SAJ@%z9|JNL=cm!yI#(_vGB@`**`T&H}d&ZpOyA zAs8TXvmG){2+X*6coE>&*4DOBtg&l!?4e{c^X$c~RTdhAxo`gd$Y=2rnh`}&(SP-& zl}vn&?!MDppTzN_3Gm4Nd({&A|&x{w4i;4s?h4U-^YF%LA5{y68Br2lzAl$N>gt)ZcWo{x{Ra`fz4)$}fDSd^!^ z4_6+y6l`dcW{|4>V+@zbu<}&6K;pzb^S^-^2q0iU5$&Tiz5)7%l|G~Y@~yb&IF?V( ztT9u~v@pO`wY|9q820pD+_xXueG_J?d|qNKA^7hhew_gU=Y1p(#Cl)8=C(>wp|-67^&u`_t^iJ zA5Mxeh|Ai)YovuebnXbv@|C#$zS}C)aO=-!A=g$i$k_=&fC?ZLu2-(Kcwjx$tkn%lBd z#+K0|A|aSO<<6TWnJ_?ea(+^`#>?cSzhmOM`t<4!fUy2e49Ph3H%-pIeEBjs*h3*^ z^BZUqsUBw@-+EsArX(*CE~FiuZY`e4{UcO>`#u9=1@rUMKk+mp*gjb?)Ib*zIaFo< znjBB{RB8WZq`nJ3q8nuD*4Eb#MbctZ)(&4gCTyGZLs{D(m zv$pYgO6Yjt(7(Ocr1&j!r6{IFFcOIeo>3d+wH#-LIP>J$!Ai z!e9{iFlh@4HRIqC0LwIS`u+-MgoTBra>ibpGYV$>WASzOKDBhp`_cXH9D}6F4zFk0 zk9Mu893u}f$1Zahi4f$HQ`+ChzI(Ac9_{RKETXpxlKwo#hne(mMJTImY!qMNn#Asg zx(C7n1d6QoY%oyu)=LRv|Nw-}pNqtprdK{)%sV431 zY+H=%W9j8is$#E|o?PI|zibo>+Ej!3vx4}wefQ||oC672J)E}N&UEgHPk?5!Ed92r zSrQ%lAA)0Blr_v|AX201kDp^31_@ES1O$RS`0l^Uzses9N*usvteHy$nk)5-HNegO z`(pC)ehynR>dDm=LE=jeFqp38)CEo!8TQ#KrnM`tuu0#nUwU7St}Oe3s;3)SQaUEO zTNqo%UzXWu{q`}e^$6i5qdaz)6-{JeL9+2O;P+ob2GEwWWh&EncV!*a-oeDkw0Y{O z73a10V@DVShF_4Wn}$>HS)W#&U$y`5tpk4G&?w3paTC!86!xiXqnH_>5j!hNw1qQ# zcDHf)%G$@<8`MJy$MgOiB!d4S`89bHT9s*apKWRFA=>8TbK*SD4OTX(icBA#jNr_? zh0k1{mg1vi*h&}e+`NxE_$MYRmyI%}^c(tZUizGDX#_`QQ$@T=n&)$y4DZ0%7-NJG zgJ#OLrS$ZRi(iG}_6fp%3ykr)JC3yr*GqwObM>h;$)91pb{fC){u|7xtu^t|N-4^x z{chMA`tOf#>@{4@RWO};WmVNrT)N4b$+LxPI*>OEOZwA5?+;sxr&W|oT(`z{thK>i zy={B)PF(~T5pVk?8P%pmm<@Lts0!A)zGMpc%50r9J9_LpcEwB{KI=7wX){=y%0Oy33G9Yn9}w5prAKjK6J04HW4dcM8)O38QT}9 zUu$ag-WVO?|LU3;9BQ-Em(syCnh45K`?19j7}eca)O8zew4!Vuz%jUN`gb}`DX>Ir z*+RBjSNbtthTBxf+8a>u`k=9FUs+}NLe;>yjp`3ol4^rJ)k>|SRj^?zv^J>V0qPGB zx3g>Y7CxE}zc|K|Q)GYfLci2_?JHc$Qu$<28cOJ{r#>PUw`|fH!&pP$oQ?!j-M?I#$e^0y-a=_6l)VB%`Z z^>>|*SLRS?bB~O^Z~OaY;QTc&xHl-AEgVn5PKRBhW~*y47@Vaks((GysUNo^u3c zi9n>ar6m#q#^xQ`HH~kGn6oWRYv=c9f5lB8rn`HbD9Z`>$xOKr8XDnaSH~0IHEeXK zxEx5Evk7v(APP_cJ&(DaK+wrIz#+Z~c-6u1g~0Ay>Gh!!b%y&~gE%W9q@yeMSN6DF zHBJD12)aO2%-35g3={pKg;22M!h(-Ne&^8f z9AKk%c6G?e%IbMsnvs^vsMX!22n-YYlRp(sc9A^}?0Sm8JZl+q8OpH!N4vyi-+i2& zi2@Ak&!0bEUB{A<#OrgK^!gciGd<(v6nb%#w67t0-+B)s0yvQ-l`rqc{lN`Dps1$i zGFp~Si7LVBuv9*UkCB0!g+tuH`ols40RdcHZNcTM+i<`MX!~}ymASt_X>qFK z+%Ha5O$G|EqH8|qbKK8fICg)98~7a;PCkgiS4oWoDpXb86VPxW(->!%-!Pq2HwVnw z)1m^7KYErl)KmJLGN+)h9Lg-jn~oj_4T6S_J>g+=`0Ghl#;At zA1e%3ocJjaH+l-YA%9zC^lu;ikKu&*3;{*vWn^px$6AZsZw@p!`+vqUAJTKbY{l6K zr%B0~H(W?guy42qHGC(hr7%RjUkmeu4?Tq3Bz5E7^IQ*}0bgD=y1e{6rRy?L4KTdE zx3Bc3CWfweWrN>2)CF`YbfL7JEM=>aWnj2VA!)-lXv@KYPs!B?zvjd_(Yt##9|G%k zROTjC-(Os@vt5bM(CKZ39Elw9K{5AYX&!(b0E6 zrKX31J6m)#AxFdICzP?c_+pB%TS7p<7TWY06fMMLHVg&#kLDhY^sADD31k9*I{%3) zXrSj#kej>UG?eu=Ju+Y2o--*NRPm{A zW<}wdj{vVdkGAyd^d;3EOLl50Cpn{Zi=Kv}oCDL6=cXtdSCDM|D(%lVT zL_k15x=T{JJD%dnUR)l|Is5Fr?sc#G(^z3%|1V;_mxhd#&-LxzC-I2Aa`I5v zFQ}=D3HY#pc94d+@^9*r_n=fUQzJ<+aPH&RGO7a(tgoXlI8!A(hB9>GWv3J$1W=(} z)n)ZPk!h{SR5gl{MSU~^GmYi55X36)u{%-l%@4Lnn6pUc@WW~-7?9mtthoW^(X!&= zKb9$%`%+L-3XhgH=U(oIZ_*JaTgvz1u3--iIW7iq`NgRwTU^+3YKBTJL}4%(!q<;I zpzS32LgLzY?>ZZZr2B0*lRqOWRXQ%+*SFoCCR8=$ogJt=#8^%}bHM(HO(||MEO{Wa@EQ3UYT}v0eREh@vVTY& zKcAS=`S;XsU4*FQ_>0v^9Tlej#baqp22=BvI^(m=oUc!-Ob<%Ee`nF6F9SiM3!Rzy zi9-A@Cw+Bw-T@x*4skzlqPwXPh*tUh{BR*X)kWf8IvYM-L?o2z*1(Q#z|pWdr@(|_ z1B|2&fYyW)gMzK`>Flo#G7n2&lcvOQyN$eNf+3Lo0<>AD zw}U;EkoRUb4i?m2!hol(klxM8!6uod6`@gjhqUk1#F)AQ|#s!pFs>-_vW3LLi6LD+_It z8&~0sntAfDq1G30Gyy)+@2qYe_@6;L7r5$Oedb)C8XuW$cWaZsXXMYH+V^itO6z?! zG?+9rBoVOpHalP0_x@7;D5v=6^?$!F^QPYW9aE{9L-4-bys5(ixBT71WaFchn;%c> z*511vL>-HA^>~NCd3ZpG!8l=;?i`>2@bU4{3A;xdF8|}-4mfrv&73eBkyB9^Oug-? z3MTIXsgd&MAVY6W*g(!sV%Xj7(-6ogb8XTGeh>?ajZF?c&!7|wi|OAvz#(_Iauu72 z3kPY^_63T+KX|v%L5v&b{w0>9K$ed@Gu*?^*`E6vv|@Lrl3vvZE2u>Ad%BvA6dT|U zdpQj$ROD=Ssav5uw9xz3+YAQ@*W?41bo7Tp;x}OgXGc|)zbbm7MHvHtnNsq$(SD{I zN2Nm#3PHLavB{~m;Uc6Mq7NrR5=JCI-oC8><>1q==lmc=kcfl=f-2ps;_Z;|?0K$* z>L$%F2j?@t(@)_D1Y*zq2F$l-CS~Ha6d)Hr^lE4f8RODR)~s!6XnzVS+_~?6U1__s zor*)|f|0r=y11%quc_%m6C6*q+%#r!i`m*hbP+Z|7~@llteN}h;S5K z1`gN>Zu?bEVctR@VI~=zDcRW--KGG!+7Mtm{%(6AMy;^QedA2e)k@Fxb~ z8W3s~X2aRBo^K=apsJa;;B&5}If+X}BH?z7{x3yuw$iI*!kU)b*K#|;gx>FX0yEn; zhd9*B#z!;22l=&!HEvj}D`@3qrp7sf*fSu9o`+GF zdo<0{^#Pbq1DH&_ZT-7B-3SNDRd0_Qbuvebw#I}^IDyA4$JrcI78HaG9zWxYHnw1T za%f#+kyr(}C=U)YFtU_nQW7$om$<_x+#?~HrTR5?y;*gN!Aa2X6Qk%}M|^%FB1y}D&5>9Ls1T62so~G3--rhY|4RHt zCq~7blz>B|0`Ir3h+Z!@ZQw{llJk*EA{-L^!%{yh+;{(qdNJ&|Z|^c7si>$ae*EJK zRn3-$VBl5H*n-)M^ssO!{2uEWf4IBIb}@zF?)hhPFssz8OO=LG&G>Yvc>c!aLSp_Z z1jbe^!SI;H!uI;*^Uf@tPG#r-4xUbUn4y=C@$t{*)4PnJ-zV}@z(M^oGA8EkGC>8T zPkTLt5YT&FN~@7rMV|r#GsV8tWSCb%1@E@s+fr{mB32OGXYi@h={`9wew!wF{YC+3 zoW~hWHFg3wWrFkQm16I-`Q=H-8ZeQD33QNhb&Sm1pg^N z2Cmsw55J9bo(62~cgvXt_mxY2Eel2DUa{g8ZO7psrPKm~rNLoo$#2RI%vurP;#xX5 z1l(4IcU&ItfWr@UOb;i1EoeyQp5UEE(I?-n>&a>`1fzRA)ICRuGf@z@`CtK(TuUyWWQwx7i8K(U`@_ZuyM&pbo zz(33+?(1k)LY~R%i=@A=s*4NLtVy<881bMDkI^8}s76bqR#J^(JY7|hK4KI*AeYiA`oD$uy`2T z799j%w|Ys@sY-554?%?vM6Lhr6{=BrbwkJea2?ukDe%j02(lH0!%}NEHLt{GQIIL9 zxBaV=4&my2cYr1Mn#2!{PcAs3Xuyh4JkDI!^Rn4(=kE57L6*73<$P~Rt3;To91SSZ zT`7s{gu!zH2T93oM$0jH{jFG?xTc~{@qeMUdh(MM{D2IpcPfOq-*KlNUiQk>G zP8pfg(sfe9L#{p(Kx;$VEXoIcs_MVk9Xn8I05(!p@e!F~OCN7q44sjp*Fn*E!8jC# z2AVZzFC`HqO#ROhmXIV zAt9psUA=cdY$$`P{+@p#gqDPUHc4oub9s{Xq`qSH^@fQ0thekb8Asa1!PLQEieajd z6XKobq(c-E;#K@w zb@iLr)qn@HZo%w!G+b%#{q$lqF*=Wg#>P2^Y@vgegRi+?V`(MnD5pwaUf2V}#O`mq z<<5|B4E!z0;|^Ikur#LPm{_zr@x9scJZ~HsHq9>gGN_g~0Zb#7m-mg2YGVaU9V0yED zRx$_C?VYcS^9dRI{rUzs)0MSNJQ5bDt;t8^+1b`af}N)(6NqAEcn~zah$p|lMNA#A z=;rT7_QiYcRyU36iz;VyIm}vv`M(;yvAgtoF|mWy=EQ0cJHpi!B}}bVn|@mROh~^v z37%{h@f{5@xbtCamY&;IXO{3qVb`t&k|Fs_Gf`{9yyuiIt7Wf3}hnP{eQ^AjK+h@uyjo2s?=Kz4QrvGBN)Da+im@b}9 z$AMv5{{D1Tja~RTIGbdGv3s!B-kwb@o$pD0X;G4fTKzh}K_d)Cn%_0?R%=l&n*UyE zA}h_0{Sy43f9iCyN8Br*7!eM`7Q=)mdVV%Z^;q(?`s*+N4<4iUD^x5vr|E4!ALV7! zswk-y-EX;>+&2(Kff)K&y~|thZ?6)ByRC-3%26#h;EUm>c&sNkbe&|kpBc%IR|V+J ze_b`YUElH}JjvkSii^Ac9-o}$SZ6ygpyq6!$(~cNYu_oV(3FN;-cF404$mlO8GCW( z4e`3@{MBHTVV^9Dy!q4tQsgAP!B(f`9S7T? zW|RfU0ke6~#bF4HI6A7zFL+NQ7WbVR_o-P(6C)lS0{Uk&hcn+ea&aES)iXH1(lg|m8d&= z-o009_6!FS7@?*gJ3m;0s#kBOU9QPHX9R~?SFaJ19?X4{+jejv9GE!ECF_Z1&@`tp z^O9&atN5j%=vnf7WZO~~X)!Z83UU%J2_$d6Pc2nw43IIs#8m+!LlM=Hi zKscx*JvaThW}EQaY-!}|#Yiq;UHc9g+)OoAM#rVGHMmZ+p!`3d-adJI|=4rXzbX z*^S8)m?IG!A@H_vSyJ+$eUKsy1!9?o0S7}{(QXffN?i~b^b_4H^^fH_^sjSNS+v6T zb&GI3qq=W9=PFF2<)kk?ho&q9M4zrD2`>8W<-Kolw|_V)FzPt(AZX7zk@nO2j3}~V z7$=#bAX*`wV&55PfOSoFEyo!Dz$v(}wdYyeSQf~P#NS(yH4BL(!-tQWKWlZN&+V&E zu);MW===rmQm8*3HG4*AaAR?IhEvFvl)F>%=g*(;0*RjL()=P&_){p2;fC>=q!#x$ zdwb|UX@()LA3rN6qgM>j5lnJ`GDMKbiF^)dRUiFNu0(R&F+}wSCZ(Zq>xTfakMOaE zs`q0?4i!btpC;w&oSo0m9Y=1p~OmDnl;(|AbY69^a0!OsE4VxkEb zXmZ<^pwK^Xbf#DW+Zh3yK^~>!NtJ!h*A&vf78~^s@whO;P34MKYqRYeTB@r@*PX$j zT7SPnc-P92CyAU>%R+N%I7}h_@c=elHpS4sK@WI(=9C~HG-dUS74S7ZsgkluKqm54 z*WWob-|xY9Od4fjyg>$(FzJe2`-I;iW}#!Z7gZIp-kX-c#crVHQ@_Ly|YOvoLSpqk2u;cK8 zN9aqW-E6JbdjACYWc_uGr53C=%9coSzX2JX8$)+18|e<)vvoH6Me`Ny*H0N3mKS~Y z`O-NCQXZPjHkCs3J4d_?8l#k`bs>`h`Bw)MKq6K=NWe1fXszQh#d)$O{sJU6a} zA(WC!)tojCQ(I|wV7*gOiH{(#V4V=|fKBfo&h1r$d~)_u7gVxoyLboFdS}ghUOKNA z`ga2~k>Hcnzk8b6F38oo|60$ZOm+wr?kYqh7)yx<@!HGNdWlO-M@`KnPt?<>gQy9O zN*25Sk#G{AJ6)|&5`>1?di_W7=~t`eznj|x}r+BA$OU(}+7~E<3ljqOhD0$LKL(zq8&e5Op6Z*c!v-UrK z6I3a1t^9v)4wWBCi`C9~;m?Tw{)5POQ|bs`Jqi_!rNf7$Iu4}H9c1QOQ7ycdwGxto zwi#ATSo5b30(T7*{&9+$meis~<5e}I;u~36WOnuvh@T}yY)%cstm2t-6(xUi)$i7d z#120YK&9C?StZIEGe+9Nlj$V?!w$nBETKo_TD8tlO;%0y@pr(K&|s5GwNMjuxXoaN z{}Bg=iCXMf6YlVoq5(zNhuGM~N)xlbzq=Xp8DxX$7Vq9wvD?5xO=QAd_h#1{EC$kk zbiXq`V?m43tc=s(&cy}muE4AxoZ0aCc@@jcb*{T*UA{t}dY^EjL zy@Tm73l*_aU?H6f4dOA|Vru0E4f9&kJ6%6i?fm5D=*Y;m?wGEE)$4FbnAw2J8ph+E zunXJFHfM4Ne!{)krbaO3i3X9X4|BnO)A_$;Naa^r<8skn`P�xRzv^%Lqf@q})yx zXk9pL4mo7OhPiOv{uc1T{?S5ZUxZ1Dy5uj|2V8&F$z_RdHu=G%ULYpA6Kbbdf4V)l znV;SxL_1_}AR2HrA(-n)$F0Kkf`&e0{)xj4)x;`UY5Upd88SpEYk`MDG_6n?Z`z0a z$s@Y62Sk!sXPM~rj~^-TX=K>7j6k&miRi%&8(n=C-$k}q_b?LbLXqQtPM)z)OF;41 zlIk6WriO+ZNx=P?ATn;m*mJ8B^MZEz`2bM};79`o`cE!ga~(Vz_FmIyon+orcJx&Y z=NubSW!R{49`5&sr$3&)U#DN+adO6kpi@rdo$hieOpv7LTUA|8Ia@%{f99}wzuup{ zg)10*@j07wiBz@|gDtkXo%b6|#a&<^RTD_nwHZeEJU+y%OjGgsvtB*}hg=ghIE3M; z2rH)m732$xwYKG9_(7|P!+ktRunk9|x8L>PoEgYruhUCG`D03T@3frL1z@PsFy(Lg zd(%yNd=c7eFJmdHY-i|qrb=%%S}GWs7?sIM4#B+40h7}Cbq_x=d;68>R820*_(47? zLg?$&X_gYVU-(UT9m}v#e?*u_l-z@n)DM`(hh2$6vAb7t8{9+YC=;kdQ+{p?rqD@+ zk8}}Rqk?DTe%XZA&)@%@Rd)iG4chbHm0Wm=goGdY#ee@y2z;DyI2?p|;dWhLU1du6rENZ{=v{XfruuLn0we36S+D;-{Uv$soV_9K zcH5~5sE?oYKcOgnwAf`8%G|TT|Is+vX0;q}MX6RU2b$~OICYoY`<3!x0Y#&aKHHe1 ziK3^KzPNBO@@MS*r1kxf|8r^>wm)_#!b&@X_x8Cx6qon5k zh(p9MhY$DE+=^M{9Y`JW*q$Z?cfUUe?OA`L9>77Ec?DIJmdfI)YibgbktJs)m{sY< zkS!Ib`oiEdYM;3raS>2qS`-ttvnF@HdgbEY(6%gOjv#$@`mmm#{N7F+jusSxxJ5<% zqeo(^w@OaYPpNd45r&PhJt`8b7p%5NW7|MG7 zuxn5PAikeN1}z}^C*}njm;s!*u5W6Fpa#N1p1jmt{8Vli&S7xz%#=d)c@rG~b6KYm z5mDIjX`j;=*pP5UNY`bOqfKs{66fv1DFD6bQ*tj<|1=Q4H{dqvg5QYP@J^N~Lq3{v zv`LJg9HLr7h*`8;!Nwqq8wJTVJLZK+NtqocwcwsIN3fO&4OX3z+z}$cRu!Y5$^WE*A;!!rSl({QBIXZ| zASV=fzZ#DP3XpD!B2}J#e)gCx<_y7+de}RoZ_ZxTM~i8euK2`Z-#0@LP>a4*fK*4b zTKYFij|w$5^ZH`Qn37mf{Anb079AB4$50s@=XrA%^uZ1bKJiHMdNUq}^eQn77mnN% zE}7WgSv9LlMG1VG(AR4XoZl9({$a_5DjCd%xxH00@y~won~$Q8gd2pBzct=~%9*{O zSV27V<=M4m-1-TyYwt;p5|J7udqtiNd6UHs-?73T+6 z^_)&>HLv^f788!7771412}?0=`mPP8!O5I$5*W<|B=k^QH8rdOo?wP=dG8^pxEN}Z z?CZFB>mY^@O__-`?-*A%- zge@=2QJDm~3_*wXu9fkztOp-d949fc^t@a{jhP3^XGi2kkaS; zs2}pN+WjXBr%%^$V5SVnz;Okhf&s8FC!@bdY-J&gSY=CKE99Y(HH*Z7HULk;YQ_uaqffl9jCN^QmH!7 zisp+A{^8ZYv{K#H@B6CWofZ*1C~6>jp!U*)rD`Dr;;nK|ObH=BTkbR@wKb$+zv zbLp`=4b0k_O6p!&?l!$%N#Wx{`afa&K2=-;~{O z2~&Cmols%$M%QRy{-)%h35&wtH$`6KmfRCSO|*w}Qg=8xBX$M8?Ea8upxVy+P%QXr zVrjE>)a#?wH{c-@i*MQ)B2>9aMSIfS#gJbxClS3ZzwBHUOE_*viE<&4e6yIXWb&$>r~)1fr=r6+Z+3< zOF7T5;2;vM?-eu%wrpeKk%teESh%5>?mr0Hx+~-d;=kSqeJ#pDGX5y0T6o`B!m^~N z`Ag4tTUg)7jp2KmuCKXD4I%MIj_W_K-?fB^4CQaZ5a^4ZVlF9z8f3(ix0V~6np_iA zHWKMOZOjn~TyMU= zzQi`|oLfcqkAKHNXT99Pj(`miK?zAgUl9`bf&nEWkL*$~VgdqmPXgEgBgTjT5I5wC z!;{4hmhjw8&**`ha>(G#@=r<1F%Rvdm4!2PJfMwI+=i4AI`yXbs~e$AJm4U>*eGz$ zK@sj4t)hQ&Jl<8Bvl3hLOavVkyV7qP8k#q=r+Y4(T3BTRg zIustrQY6|?|KwR5?T;ib_!H(fcyFT;vKM;l=$AvAmfjU|+(2uBUM^5PTbV^n0`~fU zwIMka=fZ)&ZYreVl1K^7t=)kRQ7FIopXMH)9H&TaI%y1^r5F8H%X=>^6GIDoP1&jY zMig}YZ~8Qysv6Ju7-nrx=K@TLL#%nwg!7ybTqd#-Hx&!P z+1*5R5u|mU%_>A1ID50K0Xy*b{#ac#b=>62k0k$hB_#dbz&9G4WBlOWy^4P@u9zfN znVf*k?x0GensfN}?@=4ha?ygsyeBWuUz}0=_{{E_d7c&s`|k2*rPhwZhEQ6GL?hUZ z(wqt*myq6FKk)38xT9}^G@lt_FwEHv1|H(f5lzuN;Y>s3h6a^s{Cgg|DDsS-s8bj&ANl*cfVK9BU8;al6KxuOGBVf{X`e^RpZys zMPRutM$hZ9q=TSIBcuyx!=kl*656c^3uLOy(~;mX7}|CPY_FXbUQvea88+uL>>NvM_ICWkL%0LKY2to5k)d%`bxR z9OIOPpF`mlyF3`Z(+4_#~<^OejXK382j@G2lKEoD+Lz z4rF->&LJn;%oI+qwKtPNY4{Ug&|H;c!Rzh6lKJ2D=o2Aflg8{KjbooYcnUQzHO?ph zLWxjIi>uIH%((DA))O|J_I{P>Zv{h*{J4u}xfsTqR=Cmef&rQvosp<4A7c7l@23Lo zi~zc-W*f(<2x(Hb!mtwIBeZ<1D;9oK&_vXiTOayf{ciOJ4Ns7z!XKKmofM)gh-IDb z=sf^`v25*-AN9Y(DtLht6+>VV+TH6c!p)NJ{(Gez{i70$6KWoY7^@bj@o(zY#66l}C$HO4EHQaJ@LCyXLF zw?`WKFD4ijByv-RnWXZ{dzA#pHd!+SRyqzJMU=~L{4`dwe-(8Lghr}a@F`RcW2!)R zs%w;sq9Xpk7y5||*PqMaHoe*T$eqd}{C77m*TvJwB6yaRhEoQ@##i7mHC)$Nm?gg6P&fFS=B zM+oGtMhyriN>@rI{pT?#F02&J7`iTBJ%e{b#k<=a}x7=CD-cMdpk<3QnJsJOweR}a;jNnT?pC&Ja~ zv0|v$HPJ93keC)FE_lQ?D1JsOgP_ms->=8T`g`dZO;)M8=3n}#`Vb|?;0{-ZmS3(lLpa_p!+0>MM8 z(h@WT@Pj815fkgxU7HSV4vRnNySpo=aAx-R58$t5HE-^>wBPLm0nEYsA@gFt>0z;7 zVXj}Jq)Zcd;URIl3e)ZjGFySaJe0(myvg6&LspdeV`?bAH$SCIZ|FC8 z)A<=sTm1}43eBbZ$S-yF-r&Cz{V?KkAmo`nQ7klfoJSzMep@}J;1ZG;;>(oh}) z5`j9k1rEh{3E#Mu@;k}U;ePpQEQuKS!s%Y+6F=lp26aK*PFgm3j1fA5#w{$cv)+xm-&1gX6dy(hEm*21Sz zHp8a;D8LR41@iQ($%NJADswtq6FR{|(%%sXONDKJg7Iy}mOG(5eNEN$6%7=;)3EQ3 ztu=>lJF^Bm?OMoaXYm+HCnjux{?tb_7qC_pYGkT`l1n&#-pmGeG-xmOvm!nFEf*t8 zRa>X4XR+%xR_*{PuBN|dc1@a?@mjn|Wf`w=amv7kKFkELoIZXWCRQ1Oc}hHg;2S=H zF?<~<{26J<=P7QOT=%n@{jjzDbp$N9vYx1(ADdw<=1CxEw4;I^4~R$AJ0abEH4cZAHqzyQAz$cJNP zE1d06U_dxaOAlT9!TpGNC-;`kWyG;Sru^Ia#kkl(4keij-^Sm2<){6es8SP8Uce?A zw+n(h_5;V&uCi5!?fNAH1FIsReSQ*tYvZONFaWpVitXa|4>PgP%{l{Qd7k z2*j41g{!Np@3q@o6*rYX4*TZGCtM1H={r6u?jBdW)$JgtQ*K;~f54JQ0eXFOokiw5 z^m0S@V;LkvAQqDk$$_E^X(ARGQ%_?(X7$^{DcWzd4IX#yh1;#KAIErkW3ykC6;@K>{sFWI z2+F@G-2!FW2mLw`!HYzOar?vb9<)vhtPBqE3gfAxRCwmVyXO|@<@+Tl@VDY`q)I3r zG@a(&`Gq!$^naJ4rv~yKiZp{U13F|&+!!=m89DYcr;Kp1v00usboa>Sy*F<=?AKEn z_WWVV2VDIvaJcl|TzyU&XeW;2Kyz9%cbdiS!?;;~sjEe5*Y{{WgCir^yWO_!20(_X zxg^_U3Nve7l@xTti(B+zdL^1xUdiX%CJl*~SxgC0TkDW~h>H1MS!IfDv_zwm?c2`O zh}=}biOkC4{Dm_-l1;~hS6Z}FR{mc)G#!V;OfxXX`;jDuygd3!XF9}M04 zrJuMyQ`hot@qSWS%uhQu@L9`rKx8noj+9}QrZ5WnPicF5aP_!?goMJfPS;kz2Z10i znW(n7DNp7|1CQ{FO2vLxIR3TUi~>Z;40G-Uvp}r33+-V6V9+9H_p(Z$}+guQRHFMeBs9`ZBcL%Gzr;$WE|Lc%HXJ zLExHi%?djLqEg4@cA%YZnn68oAz-_(!NFSvGP>W<#QeuiUzb%5kM*^UPz=nZ6s@i7 z*}e1cxr`9V0`0#Q`6z;-dzE#*fxmf5Rg()=7A6xbjpWlgRxcnnO^^i>ItDa(H!@GqohQR7ScA399DuhlPuib;lotSuS5fS+-#Wg(?T75ou zOx^`k7%;}t!p{M=QFOmMmmRu`HB-{)84DZRu*|%zs&yL$llaFC@*)33WCa;?pmIih zh)sLABd=)VJBdc; zV8(v!Ix`BCaqL|qAAA&OrJ(O{eUI+>6wMvSxZ6k@Fei1$ETL7oSP~m?=Y#6xwe|6BT>jig#+d$*W(8JJ{&Q~7l#Ua@8;PG$%jg%9Zf7HU4iGU%s;R_d03zjr_IDLe6RV6`Eiki;|8oGr z{E8{}jFGt#TrN!u*+ch-75p{@iMcn zTH96f?&1GZW>w?s#4Y4f1VS3}*r(`6v_6XwAifp8F?aOs{ej{TAo#&z2hL9gyhjkz zPk)Z-GFytrh>3A7vK-fvKv%k?C6577y|8fH*%_<;q<5pjM9DN}v#FKq?IAdn_E5lU zoyO|DBssDk7HGmu6ciL3(mW3m#^~tWx?qt1m|fb14OA~Xrm`wVNw2%UHrF#FAy9&E zH1YEdYwpWmBl)YJl@!;*j;ECUUz0f$Leug-I|Mo2TDQ6@2~C^2_DA9 z>iuT>(1MK{6>@?Z4m~q7^$7ZYio5dpF%N$z&L4jpT2ZS|SpKz6eVa5G%Z4QCJge{* z=c~q|;l&HGYJ^?$XQ@K@Y6oA>-QS#;@19SH@w_LKU&v3%4WC%9&j?^-r} z#1itpV0*i65|od(FvPCADB`C%4+>=7zTuxYF`@69RodKY5X6y&89Eh`F4CFGPoWS? z8;DCi=de~KXLLxGT_W}z0S#oaUryaU3uufy@k$8gJTSs$2T}E5_S2Okb`?MS2CZiK z?q#iu=k}~7iZv`Pw*;5`*#?|}VJ|xR^~bM({Igm%S1Uk;XEQoUN=EW_dUs@G1Q23s zb^U}48PKQ3%g=1ciBF%okgk)^(U1F?x{qMhjBFOq(=#wo=#~15&&Fss*_9jfeU`;3 zpUWExKtPc3y84M-azz&6bUDFMfqpdtzL8+(bc7oeH$Rn4$(6R8y65iUu+aG;V+a;t|5} zK-|NH=3-&qD=<#N&zpcsh=Ga8})d z&S%(qEC=B61FKt~#HOu}ML)GH*&fNjc=+{J2hb6PuNKpW3goST%&V=&BATpTx%X;d zF5EW#%z|T3Oa`5_fn+qNNtnlS&c%o+t0W~h6#JgnGOefDwq@-bZOer+h2Vy)br10v z^YE3ay}z`H`_QwJ86q;O<7wiWo+ani#`Kq@q$%@~S4Ts%bNAJ@-MlJdJe2|2UwhR- z238?;7@cU=^>UlXmc{l76e9I;YK{YXL9=L>^n}WSd~2bk*ounm(fO>=Y!M{`vN{(6 z@hs=A#|r+xEc0FlCYnE)g%d zI9^9S%9z=VOmPA)u?_c~y-?FeL;pPcqUprjx_4~)AEhAH4(#8Gt-a*rY+vJ|aIuP5 zuNO~rc1p7k*v?dg%FQk-PW8~1?5FdW3MnIPGtRrnE(q?=h_%W}fFR%fljM$f*+>P9?z zpoE!1AO@ly(^WPHA8K7SG>G*0z}3uTJF`f3R2s}pBI9mvzYpp)(2IN1FeYXu?3{aM z2e|j_s&wd9UQVIhc`u3F=aZWBf|Yx6LGpC7nryEs{wBjVeEC?;G*++3-nJG zoaYP@HDXeQ8wILVs1#-)osOqlM_!V@vH`i5RbIYKH&e`u=WZ)2Q&g{3$=-~Q!r!2N z_rLOmA<+Zy!4&n}iksN(!ui8raYe`^%8KJ*AMuyB)p*V*!?O8%5)G2CqF1m=Mms!f z&M3_k5BHrAioRgUAk0+hq%pG&UIm4NG5IN@fb`qnGW5RZeIs9c-{8~vUSvLSXg99S zRkUM7POZE4RkvE58G*auA239tq(RQ94-h}z!{Q!zlwy_(&1b+_@BLdM*#gTC#9%_C zVA4vu&F5o2J!15}`f2_46(=-uU}9>z#COor+^&ouwyiE25> z)ERD6WE~Ay9Cv+PaZ4L+qaP`QG!HMiLZU!cPmKhe@JN4-yDA4Xbc`|La(-JRc;ucGNG{6lT9%*!L{l7$A|wC=j7!t6DqM zVIj>dKdhkALKq^}V(3IDEYSGZq3NqR{1pSAF(=B<(chKYzu0?uU|6{f1K0x;C~({^ zDRjyo4AO5&boe5Wun1s#{P@*O2{p&Ke#8Efsa7#NZmrzn<#`pxDrp7@GD3<%(D?Lk z=FWoLRq%`gAKtk1pF$B7RBfMjY$RYV0LQ?R|K>tqV4!-zF+e5iIdl}TR0lY6AszRc z&mfSc0-TPks{$~WE|{8e##2()Y$$3REVt;%X9hB^ zw#Sb0uhEYXWHHM9Q`p^jN=P#vzg@7Alg4KTov!E z%9h{5bq7)PhB`Wc*qvHp+qMrjLB-{`8IpBGr3t zXXJk;9w50yHb?}4pxsz^6yP(c`9u>P#twCIg#=5QHc9kO|huAgj;gcTWj7?-&FbHob{H81_G z<&D2!CC=d&Gv1Rk1}9dr=kjmt?aS$iM)%ycLPOLvNFflyE<|EN!hlmo`>Rz)AVINJ ze1yfs*YX384hxF%KsZW( z_{nufO&x)-B9Q_!joYJT0|41G|Fq~#Jb~Vz{m-y88m1WF)_wZ4Lhpa-0|~- zBALlq{;bbcR1#u;pK&n+L7j8C&lYkUUZ5ReD(b^q5PY2ujAWG(%uU9j3kD97hT)gGxMwF_pd_GGp6V6Ew|b%P z8k&tLayoUK#QNInk#!W@{=MQsGN5yK&V3&r_cYseiI#9><*ERu{rbGO`ZzLm>!l*&Nei03%ruSt*PkczXJ2BiBYD!hmU?~9w8l0L$6xuCGyh3zXqvA8Q5PC9A3nrJ zMxT1Ou@CIcw4|&y;?vG%{}TC}l1%`4^~#cp0#@m<^j+n!jFeNJ%saJ248Yq0)xr>Z1s=ACgH_No@ zs0awh&B5Q^3}s6Fi*#g(yCwU3Yo!dtpo?=vcIp}pH=_l)u2XkwH*_9JByTs&E2sP! zWcuCE;M!|=b8j?}n1YnN#?@o$eP0xv(?Wn>qblvzepm9UVo!1-LjsQd)OtD*105c* z+DbYke!v;gk@Iql+NVE>^QV5%ix;pGsz0f874v%Dd^W=m{<30v2SM<^R3lAN(2|!6 zqP>`@SDWiEp{R~SDC6%_5k;8AqnB_biJVT`yRPWor)UuSbjO;G?B_j~GX(`6`_p8Q z-xVsu>uggHh-HQ(c3v`QF9tJ2=H3&TMZ=Ddj*5=IkjagJ({o3HJ&eKiGF-*G{m|*h zA_uVBZ@eaR=6-k|LE&KGHTt8k=gT49@cf{o7^z%h7#bXDB9zj5KNjg>Pkm(Ge-zVO zb513P^OT(178%!N)#C9B$G&3qBE~2^rhPW)-Pu=FYph`I_av;!Yjn4_4QqX7wx6%o zdtJ1<)-fl-A;!U^WxY5aXc6ZDjp*QaeWNr*|F5Z=xP$4p^G+_btToqkC4NaLR@`%I z>)Xu%jj-vf%~-n&dZF6OO$%WB9hNwwD4hKzpQCCW#A+At?0JPCke6iSHjaQMR@d%l z{r8BZ^k-mGoS7sD{&Sag-y@fF-7UX(FN|{{^tEYGJyD%yVubuE#o>Oam&$cX>3Q3A zw}E{{hy^Ff3NE~7y~;zGYC1>bp`qOm27#EgJ$HY@vWa4odckdR*EB}*XX)s}hxF_; zCaq>zFU;3VP0J!&^l$r$#1SEN{$NjJKh#%dtFK5A7z&yee{)sM0BY^I@7?Y2n7~+R-mJ%c?g?+2|jL+XGDTO_$ZYMj+Mz%66>vJY`52Lj8e~j%+r%; zYR(cC2QagNpJ_uSWK46n+z$sM3t@E!`(-S0xH$J5F$}0k2>P5#nvVCa)OBQN>&#~E z{lh<95P|}8#JyM_B}er|^x}K}*mGj=%vvagF+y_&4SnY#m@Ot+JkMT|B}m3NYlLPA z7qW(;cX4T?2M=FGyzu}}$JlfKzn6+$^e!K@_NUA>&R)TCa1KyH{~uZB9Zz-q$NQtg zDddzb84*JE3LPOzc6L_w-g~bTWk>c587EuzN))2)PN9~YZWMWAkQy<8|KEEJOjR9YomR3o;~QwM zW_8lTXtM91!47jiz8SqW)}*QOMM1}%k`S*XZ?EVrD{1#BJ!K`HvFF;{Mr4mS{94wd zb7rR7Dcf?#rhxAw%!D$#aHf0jh#20s_p2GDKmYRP*W2Ash$z8KxpP-@d?S-~8?%}R zf7q!e90uS^Jg~Kj8@wMslSTK~(ur^E4qnpgHl8u6tHKo2T*;wdG3!s+p8_zi*X$5j z-vQdVuTC~c&)F-NdBbTbVDViqcG$Xp@A$&V<~o}8_UnlFhju|-o{7K=31etqY^@kF z`uh{U4Nq$}k!p>~X&fJL7=~wggHmkwVSDoOJPDfCZ!z+iHfgUmn-wK?YT@GYyUATK z?XpW#BfNcaSL}S)SSDD4m`MXmmZ!{$d3wuu)I|2&x0#2vaXrp{(e7;jWXQg0u|2Kp zO<7)T`Yq$uzmrFS`M{Ggc5=a_e&qC5{abLh@)|^*KlnZTbJKOAYo-G2bwba&n1@ki z(Hoi`f<_!;Bj+zwVcYy}1H*q}5cYn)i!Vp@Kj-&fmXFu(^T;zwD=#@%qyfoRAlpUr>Gu*RBNi0!GD#eUrcLG&D4? zD^vXc6ZxH7bSmi^qMP^L>h_b*Z>^oI@GaXgbFi}??;aXb$@m*2kO4;xz||?BQYQa1 zZ_}igT&DhS+D;t#f75p1Hkqo{r9?61J&SFSF?k@USub z&X@J5)Hw@rl@+Vn{kePU@7c4Nwrcfw`SD#d#XQ>j1K!n`^1FdMVyVaAnHg~$?OLM? zzO6Thf9oW;@<}UqhZsuTKc9B(4Jac&0Es)oaqS7NM$>3_p@+{HR*$-N+dA$SB?tZL z;ftcxIWhHI;u+aokT*jfJ;A_|fY71k?DT`ds9(bH59^kG<8$}sCpGkKC~Bsikmx1x zjM#eZg-GULGS#*cJjp+H?v3X&_?GxJHCE}Q4@dw&9MH3!!^6V$J+A7&5;U#5$AH`Y z?%#2c%nWpoJtn*iA%Xm#9j4nP`{_k^32NY%roYCTn`V6eB*bsCv9eGatD~9J%cyU@ z>rl(hm^eG20CqWz1qB>G&F!vIORGqw7?oB7O_*fan|=xm0w|Q8!PqL~x$jzHr~>%l z0<)R>plV!NvQzla1Llek9###02xKVnQn}XOJHL=?aZ@X?!Sb7$_FZ}tkI!?3^PGz* z!7o0lsdsVp_riURMylzgZW@Ig)V6JLWLBEwWxqDdAqE|_%QcuJWe8B?!N0=fmHqi6O!2w)0 zdu|fR#1k9{BttLqLqG?!$j#wkAb7^fwas=O6buACe&L)ahQqagwtR0lM{EEA&xf~( zE28_J;idVx4)aCcw;2do9GD4&`50b|z!%>k=s$Y&(>vhZcx|Y?<=&$o2yvvbhppsY z0%v%|z(dNkaPCmX&RRLQ}$@Bw38$x1Y|FteEGBUEkL6tBgzSKUu8acEYB%ru4 zx7m6+2}s!?hKnKTCtQaSl|a}Iq~j(qMu!)U`c1>7SIz>>`(N%{O-M(t%{tY6%jNWC znST6)O$zrm*z(J&s7v!veK;$5ee)M%7f6TriE)>vhS^2;ax2f)?fVllXqdF*^=Ste z+O13f^FZEcrK|utUx=h6G!pfyZj>P)pi&NX!(Mow7LwF&m`?m0?*I1f-v}!7jOX#? z(dJ}szWL$Sbhrd78ym8$Yz6c~upXBq5{!1MgE{9znf`O08Jrs!A#+kV``%Cb3*&FG zE15CWdd)@g-wc8J^M<-gxl)!=Y=Nw8Z}N?SO(ZMD^8*Qtc;iKuc2c zK$S0;ocmt%BfOE@UdpTjgsy6JtT6C3C41{#AKcfe+B*~wr73wR>K4^>+5wEHrY+Zi zG|+Ofw;l*k6sODG&%KXQ#Rg-=! z|J+o`BBp@$Hsl52@cNv+S~LasM-r}u!z>nw;z~A^|7rJd{zYUdr3sh&W8oiRgJoSt zG9Yr|3M7<-w;S;$gWi6H_pzNmOrDuUZw6TP!N-vSM<+dlTJp*Z#DV{z-e)|-4D}BM zq|_#O(tuj>-@5!I>=w|qe|rg4N^wd*3hCxWMm;9KjCqZ{Em! z%c^-B-1>p=&`{XRkAf#u5syk8Xe3>Tud$Sok04n%#il|5;l}*bV|c@lh;HNz(hta- z;>XNiF%~iYSB>{(K4cA5|MNYJ;7*vI?nz~yRm>Y*z=03TF4W?Kis>bx10k;~Za`oj zC4Q3#*rOdWW-XOrY7~K!_6ta3>@WI1jvQ5SY-0MKmbDTLEmFai4hVbHDPTENs;TK~ zRn=_$vXJNCyQjd`1N9^2rWkg14dN=-!26s{V{5PUeTlC1Cdf340x`uimcn?R{hIWM zWm1Q_#8sNf<`nADS!wZ70e#2mevAbsL}FB}xTKi~rDGN;WZ6f(;MwvvSRMD8cQD0j zG6htyC!|}30&%XC;8zZI`tUYxqE?0auaE}_wD7;>DNW~nNj3GHy+Q^_`?R$eS;%*4 zFJJp&g5S3ND+>Jc%XqZsJeG}hd8MsFdN@NU3AkAtJ$S)i&&u|G2{0g7!+up1T@B6e zmT~kr>W&B#ll*mTz?FDUx%A6)O~;L=!^<+3Y2C9LSK@^iB?VMKA&yCwR2nfLEw}I0 z^e@c=0=d3=1Z0b3(kevR9#72QsZ%rjUOzO7I80P82AEJJ5--@}2__f$6#_vbyTjf# z0lGn{=A&B%eUD4{N@nP9GsH4oy2nv-mTMtmvI`)v5JP$p?H1kX+%J9YZf#J;4{i<0 zkoUUkQo9@O_dsxhCAA_d^K# z#@1P}#PqE7*TLC;!?hEG&0Qs5&9ANZFpUb+*99eFIXOAn3C}!v&%0&hV83EwVBgz= zZufU}#hX$wF!fYKk5H4-CVc)Z3#}vdd+P&KO6^yk7 z;mxu$g<_#-&%nmats5_h`-dsnwNcw#%fux3}@<(i1&Z2`EX5;M0E-hWd>c zLBYX3MR2NQ|4eWjm6!8S#!XF45%A|+H8eLj- z;E9+Ymgv8Kv-kGG2oje7K(MXtDY=}6M#7aM`s8E?{8aGjWjqOc1eMg)wNX?o>6|Zw z=PO48e{C%3gNH;6z5QKv)YBliB()FVo;lhy#(*v0gV%OG>l^fmM<`lbS(!B~@tt2) z)L=_vBMqd$(#prDaoUcHgJY<@?%$K%A!Hewn7QpB%sIfL!8N++hL4Z`Bn;WJT{fT` z+zO-;&Q8v)+HD^{e&nVk)K1o>@!y^HIg5}1vFi>F%4TNd9Z9hJblBf>4#0%8s%fCE z9%A@+eqJdPZd)_S^-kS4<1WLOfggo-F~IUACKj>mk>3-an3#`sjpgY4{Tm|bIOBQCRei6GpuyQ% z9Q=*leTw4=b}o?37-~9Bx6H-L&aO)O_|YSyZmGB_(1}`1yWGuBEbA!HE*_;q(-GXc z3)p5Mn04RTtMf;UjJG6CEL(rIg+_nO{!_7WU>RM`(dK;7nd$dVL&HzMDl7UP5T<5& zA0@{QRj`m%4 z0NQITh+5qeJH3V(7qo?JtWw(ROUEmvWd(v#uITXWlVK7*6YV47o$J}QagFbb{i0ht z9#iAv<5*vDlnL)My^=vj`<01r1d#XhOA6mLOHWIOm}IR+kRyN=rl+FcTGsmjM5Ydt zDAhLEd4DtR>`w=HaxqjPTe662Si6xRGz7dXgHgMNtNxSHQ;S*54t$l;<Eb-N8;J?}`k%*VWH_!3KZ%;x<-|<8PY*IR-$w1q`CplC&-jAY zU-shq!yKR*W>lq%iu~lc^R)|!obleC=2008`pKo+DExRahBb_)2Y$Aq7PWXw$fr z1D85}`I!nS{Bk24gnkh8o~(@8aZ{R!!S&QfK$oKi>zD7oJtKujEdR`-ps~?tF1uX} z^(?JK1zet)Vs^-T^wQJs8pZI^Dhw6>p6Wv!hL`_bC55;^5}YwW@zkD@=K|a^ji*aG z!T3Z!-W88XX&I10zvdx9)%nx#a5UIYFI~@o{OrHN{fkb~jfTGgRw&cg=Z(ed*9~sA z({`v2k)fcDbuqdr%ErdVuMi#;WrZo(L@S9ME_bVg^nyqpx4P-+dRGBTLKGZ+s-{0v z_EieN2XMPhD9tv{h{7J-l=SiOt#EdNr(AYj&v>ygGNLW`fsirq<{1SU8OqBD`IA7e z-sP%v2+EL6Pf9`2a`KnmdHHgz$s5z`>ovJ~q|KLCfSky8=c<`3X6;TI%n`Jo7IB{V zvDB9$WIrQ+Ja!$8CcSg#j)BMSqNn)9Sd0I4Ow6q9WKGL;>nk9kZJxF>*Vc&59n6sd zG8e}s5~asgiV=~KpP5!0zgec3@g^T_W3M;nUG2Lbz4*_RS(z)?Qu z)^NR^?M$Poq2FxVc{gJjM;Qb`3E53tfKYfp{&N!PXR2`^iT$Zo*~z#z3UGU$Gco0^ zUHaF=c3Dg{xo;P-7*CA5y<`1P;?}K+QJK^oQH5 z3=o{ekVtw&>HP3mEn+b{IX|tj!;G#v^`Bn!2w}av0m=s0edip28cs&$rI@I%SNGRo zp$JN_EEqWDJy~leLn50`TWT_Ww@w!p1UDzDZK<&)OA##9wpbpg1)>U$x~VOolCVk< zvF`Zt#(VMS)g_k?1%@akM1dI%?k@O+#gX^6zp%~NfSk(w@(0*2r?f$fv!e!2z!lj7 ziOAVpsiC2v?rX!2qeT>cm$u_cgN%oL4-+-$1t|%2YHZm{Svx6TNy}8*Padr%qpr&T zq6Twbt*-BHPFC$7?xr99W&~JukXr0mm$qV*{uH$j6abU%Tcj?*2fNc{hHVYozB{`t znQPGy=wOmc&eA3K^#=<5wdn>;W#w6aDiif&kdh-Iej)6?bU^j_-W=F>zJBv2?b-UN zcmfwtROIT9xNXK>US62>c`&iBFMxvr-F2g*qf=MW zv#ZgJ6{9VMBO8i#Ynq{e^C-N(nh*E6ZpxaaS3rAY?3cgW-&f-M~^{^*e|nv;q5 zHiH<7jFdDlH+QEoi}j6%hli}}?HgH*jk=_u@A5N+{638j8&|Yp#Iw3Opl=~;yKuMk z0rUW~QQ3q=fc*;?8yhpv@Y|b@hJTEVj5kd-O#%Stv?<*X9&$1=TzuTZ>xW`wl7MvY zFPz^0nK5*Gs*X=gjDvlhsf`lwYJ-D=1tO$X_>&@}KeT_G3l9r>^s6oYntTNI3!Cs1 z?1~s)MU2%s=jY9VE@8{X5P^NuY2Kg(;0@B4^QHj3BP=*9?9HP`k4UAT*=uw3fV#fs zMcrDzEZXaMtt!!rpXbdRz3s_5`goIyA$FW$zrg-=64#Yu359jKNg0>bzQJ-I5 zT3X7~LmQ2m-+f&{LrPkppC|DM0c`~%mEYz_BGzM*ENUL-KjeC4eT};~tnA;R!~~in z1Z3ak4st^8X2SJ;I?_Piy?_7S&kv_9siM;P7vnGRAGl6VP8Jpyd1wsu_3u%hWb!@n zvcz}ae!|6NPK5`7(yyZPF%Lk_=3fF71D;_-A2}ZaWNjMCv$5NL{mQW1MB=KVXIS}E zyOD{%M0-8=cY1>x;OivB$K%`pn2WYH#)ELqE>LB1^A>|j0=bR;?Ogmf!afUs@~W)Q z38}W?b9;Mx0jvUltI55eg^qXyi=E$vyZ{_CaIUEod%L<0iJ=!&LCHu^`*z<}zhCm|y_IXT$ykVJ5aG;~+kbeDjI zQKTWIZ>kR#?u+%gtXj_S*4Q2@)*!&itr#jX>@##ngdlXYl+Zo=l!V^Lo7iSUG?B4A zCkGp{sOX>;#53dO6GYT1`KjmjSP}8SvqXZE zYj-yjMHPn z^Gkkg9w7nHRmvCiJr!$i#z9n|Mj#2Zq;3vCAvw#16V%l|%++ob#i8?1pdd_foKMqe zx}gA0Ba+GR1to%CAp~YcX^<844;-4|yJVS6O$1LU^$PLM|B{%P;`!Au!9PD=_U$+qn$#5WG z#zYKRNOQtHC&mdQK%hiVxUQRv?G9J+nKR%KQ441^)@mjGO?scr%F(9VT|~}+nDY!V z1j!r$D>z5|3PxHQBmC|2yI#*Buv?$={y=XK89PX<-jKcrw(WO#Q#6a;U9K2Mi$8Zx zXle0B(N5bwevHh1bW5Pf5)f>13fKJO^H32lrKQEMr+Q8OImEv>0WqdUij3L^Q*G@8 zvYJ=_?4Nm-DcOMpA)J_+kH=KPv!gnlsEkWT({H|``&`Wj-QP3&PA^7p&r=~x^5bAZ zAOMbv0nd2zMo)fyO8|OtqSD;{_3MwBrCbcRKq%QQ*rT4X4L3^=|09EDf-*3_yLmhE zyCtlS@q@$R;>$Pt^BZtik{;B{eDOLd7^Y12h|1C#cY%);20#x|JgV z9pnyyKB*vtARt~$rE6~1TE?&(%P{>wIgaWSiFbY=XVVx>=kK*t1Tdsv^*{FJjoM+v z?I&SDlm%84YiJi29cmG;g^%~z#LrfYrBz^Ue=3S}Dwt%Elf{-vrc?@XUE@f7km5T^ zOGUN&cfS(6E>=dGo&>Bn1y7es3lnkHW2HF zl2EuDBevsuJFnnM(HBz!Z>7{c#s4dVWxlSyquXbzf*X}7ePVta5~4^y>fhGdt3AEJ8EpU zwoe>$q2(%=t_cVuKSkR+U7UPEDvwY=fH*y<%VKADx2w1J&dzbP)o77kgPZ5+saag1 zR82`|)I8<2{~FtPud9LLcriipdu-Ayr(>7u^>8g7$+&s`+=x!`rQ@z9b}6 z+f52GF$s%`Ht0=O-`iCHMG?Mh#-WU6lo&t1$r4&=Yoh9WtlSs`EQ2b_+SpfL2N!uQ zEekIhGYq^5hyL5QZ|NegRtYimJG!l_K_OH%NW5CiisrOt`JgF0Xs90nAi&S9#xkA75WZGjoHm^hyqEZpsK5#-3DO zUS6P`TBV~LtSFEuqa<(DRj{pq}Xl~-~_Ywq2^T^KX)W3`cP zxyBMbvFZfMXNdOpYG5+}HIfGum@2tturO*e7W2D!DJkjm;+jMi^8n&Ez zYJ7b>DjK`+l%2gMNx{+4QBTBO#FY(Y!SA*)vw8A~WOsKLhvBnroDQfM@=!FKnHOCq z=VOYopuy_(y&cSR`6tYdI5;?7Q>|M~UUk#7(w}vHSPYif>Xdfsywm8YIHV#Al3C3k zzK6*Bb484-A+-G#UJbF$_OF51#-)Ilu7zCbz8MgYlTlE(W72E*K5Caa3=SH1a?_GCK`4+j*7sPeHz77QHX5wvFN#U{70x|(hhF7Br>3H+vqUF|YZP(oa+k;H$ANQg zpDxVs6D!Uoe%*8|E-!B*U^ZNEN9Pn&3T$VJ#(xH08u- zbi=WH<`Ix9Vv!@(f;Lkp2Aty{1nv(|r@#AQQBpYJjzv9X)AN0Swr z2EM0>27X`zlX)?~lrc~KR68ZsX(#NM1tSQO0y|S0zF0Gi96y~tMrqZ8> zbI@4Fb6c8DQxg-v!$nP0ua6fx?`fxEn#aaqA*ck<`4^2<`<~;b9M~v`2~E4qgBJU< zsL66?Orxjxl!&9``lu`z*DjaBOKDR?9$&j$rKtNq7rZQ=ot-^g7b-)K=XNa9BI_otujnB=sc?3cD|j#qYC z@L!xO4sU+}N!-aOF)*SmZEx53w0D2`5=^Y*`Nyh?^ZYUzjCxIAv7hN9(%EU%wBIkv zJ?eL2=m!$1J^NA(DRrdVi)$2V;5N!u@AAMkO+&S;3>O-Q9&M6IpDs;q<4RD9ZzX_* zq2I<+q2sL)b}p`eN#NZTJ~PW%`Zc4)aUC;0B&dD-yEGs$nGV!VjD7}OfgLQJ--!yw zP7geV$5*1;Gy6Y%LEz>yuY;9n@qY89Gh(X}gDtbZlxfHFmy$m(P6>vF&|Ez`$bkoj ze_~pjypMh&F3#2~dW(8l%zHhy+7s(tw)o8s3cyMo6afJ^*zF15!}>5gO!ekw4R&pN zIWaEvd_0W{q%2Ompp)LD+8(h0iev(Jmj5-zz;|<0)DG;IR5fIlj`#h3Qn;*Q&iO+O z?b3yt<(B(lfd>AU&jkcFx4gFy5U`B)m}#T~QS^1<*ShM;%G`@{UD@;;+Gjl=@scww zjhF$@UbgC%OERV#78f$^-i(eh6nwgE7D(D#8NhJH0dFSfgOS%sLc<+DGmH0ury}W!7ov#Rz(<2>RN;3C$ zNh}R~k8^q!6+hbV?MNL{6aFnXW@$b-!jGPuEVk|xwWo`wHYl(KxKN8-RF9y|goyc( z=s!#GabJ3imdZ%$&9f;dbb*ZN)3dHErDq3y$NNqCo-4C{LeX_N3_srQay<~VofqDk zX|UTIC{ZxBCXrTYKE28x9(MVYmD)Kq01T87yPDXmS}+rioA`L_`QF}jjQpzL61oXS8<+SaF`KiTIG>_X zIeQu)W>)b1wisthmG%%MPx8 zuK=0OUSYi22}F3oo%4e|Mct)-fnk;@c}OHZbi`_f?3O-O;OBlLX1(b*=qTl0XJ_1g zEiNFN5Q!IzVdo(nGH!IF$zRTDcHq;9&&y0iTH>j=c#8pHR4h}1>EVd?T{tS&lDngMz=fHo_Y>`R~EXk^?(ErRF8dgSZZdxWt~C~ zrHuxo%i6<~JWD7KMO5~+eLsc=d=x;s%GDl3m4~e*&deVMRq7eVOROEfT6ud;)I`L9 zIIFs9K&Iq{mMDMeS#NXgFL#UXY~zlY!BP|A0cZ<1C9?Bzp<@*>r|OPs;?=w5c1`S( zr`;$T9Uk^(#%tR+%U;LoCvi3PE1}(riakpuvBatRk%L#vo1zXoJ@!k5$fE=2QrGpN z)!k0oGvCuS13$fzXvA7WvukL{k(lynbKrcux%FI2A7|F--zi)HfmW%yIAuG=82;Fb)H_-MN}Tr!u2Nn;55^JAyUr zY)6qoO(sW$Yya)hdq%`T98yDyv5gx%3N3HdC#2c)*h!jv=@DlX3NnnK!i^g>e}H_1 zh@c?x8<9g#VlB8KkY47vf0lc77QZLFLpFj^P-)NxQM{HCf6_#BPm-qdcB4akVl8JO z@9T{Dk{6qr@$b1Q=@HCvw;@K{9|_xZz2Y>u!c526F5mN0&uLrV!<9&E4zVis9UKyl zLQ8!l2zt-`(TnIiXI!&-s$o(%GbmiB7mKgQlS&5~QU*&5Z-3^iVdz}7^_;uScx6+3 z;NELi={LV4W?+h{Z(S$ym~;NuG6(P?KPxYmtVz&eu3yx&@51|q4RJ3gZy*H{5pckr z|2pUaUAfqZY}&j9rxCcYe<$n8!}dkD2z9lVgKYYbf9vqN%8TfsG~R`mAu}QB)d%kC zxJ}6AgSil}xPA7oec%CN?_^q5UD%PW*6xSOtp++P)^FM$f2+&iqw_uq97`(5r_HQ) z_kZ(kIuK?xpj4fLCt2WrXS?}uPp?MFVo=O*ECy%1F8^*W4FCtEgKT2*K9a4b_0g75 zTL?t=?C9${i1f^xBNo5Q%*yW=Geq}paOd4S1oAOPNFd*8l!H^j6@bJYFe`rRfW z-8wVh$%WiMAGs5IF0Up_llqnx*{`XE+~n7PC*;^uQd8Y)wreAY{9Md?SuZm!V6W3TGMl|RlIZ%*UID5-!-4*^J5HG6BX*&(yt|# z)o*$~>Zm8~%Mr~mtDE~C$zMCVmK0q$Z|pQutlSZq45wzoNmQAWtNGSL{pvEbN@#$y zh5x1E z$FML~PEIt%F176Z`v@vT^Qm8RW+o~!Csb2yj9)=CK9P=!>TBo&0>Yirf-)QkbyrZe zUkYGjJi(9V(7IR$QJ2=-nt|YAGhJXFL^6)S*zs751N1uBknILA7I((-#Q2|`Mz2GXNeysIio#>3?dNJkf15w49oYN@J z+8cI{NR?}eCnfg%GdF>g;(#eX(sk++j|7yQEMDGwukLFwzRUX1?!ea^D8nVrfW=lV z-#_p@%g_)pjhLAE(PTbBm}elycZfa+veG3w>smZ}MRW@Sd>Ul~v)}=`1F7BI3cb>; z8nr08^HYM}M%Olbva4V3oG@G1XXWRV>Zwl6CI=fQ5WlSuNk9Y2c3s#~Lhc25wWvI{ zM~>eTBPpPI1cE|g{3*9zzKN8A8fuSP{L9_J0n(WoPAEz%tC^s%US4E52ibxHJf6Oq zl~+H|*UX3&_)G=II=&iiIP$8PZBDIfJ#yJo0e!Id{S7*2YF!+M;kTACr3Tg;(0N`L z@<18zg8w**L0+KwqdQeioz!Krt#jA7P}XU)8#YerCf?J~!;@?C`=8PUY?#7vobd$MB!Go1M z{e(E8Dz8FHWDVC~w~$wTBLd_+@Aa=%3!_nup1nk>%+ohPNC9!x#NKeSCJ=vS$p14JVXndCH^`7V52^7O<(KBK~1VX^>~XCs==O) ztE;=CZ}Rfe_n}+<3^QjUu4u%^N{2Y&`Y5_{D3 z^|xF^TqAQ^zA)VeMNB7c?c}S^d6u9Q?iPAe5)cg=rcFIPqs8_dDiOD2V@8HK)Wy^O^Vu&hKr1V{SsKTRKJPtoOf#g zze|~_6hWKh&5v;#uskxW9N&!M)Dyw_t^e+MQzdN~z)d(`Hu)-o@@OP6Q!(RU7OnLB z8Q8H548*XQ$phT;rW;tYuad6-W}>3Gr)M3UZo#Pz36EteEGp`q-(BfY)_D6U>@R4W z2UIdl854JPcl@Ydm4S}l0#;G|6wN;Vm&Pfi2xmNrw=Z0y{ z#PuSaNj=WQJWJ5|yaR`d%J; z3)*~{@-`z*J6VBXvk&$zMHMe3KTd8|Pj1G$HI$Uw3hH=zen4758S>Hp1|lSze^~Krp-s+zV`_6yS?3$$r5N~#83^ooDI|9qCA!@;kBR6ZJKFep-xj=F8C}& zNF`R#Sk$}dG;ZT2tm6SqLeI|6dJRtYH*2EO_ZN?g-w7V??+=>GuQvp_hH`3xUOOMEMbO6K`oSw=uBT6h zD1T=>ulKn+Z0yrB_Rd$cZgkh9NlSIwt_Hp8Qa=TRVoy_r&M&@iXZrZ89gB1FQxm0&RD(Gu z^B5ibgm~)m6yt1coc*g!lDg{))i=CB{yt163)qr(y`nRGsu2-bTB@XF!HPrA%$#SP z*bmCL4Q`uJVPOZy$I+lXrl27A-0_hhXNhxu6nM@1XroGt128`e%f{UReS&9eyfQS; z68QSXI&mOm(w-jOOwpY{!a)Z_2%vc`DJ2P%V!#F-W`CcBy*uN%H)!tjnv_Y93fazN zcW$~1)693w*nBUGCrxzOntB3?vMel##&v)Gd<4b~3JMDAl+kQy0)PLOKY#v!>azVz z?W+GZze99|*Z%mi zsQcECsv0baMUmzjXrqFIf{n7XrKF_p(!91DZA=D0%?5V^;PK6~{Pd|f=*G=H^8FM6 zu* zRjoRVQDVnKpGa6dGL+uP6A}@*3U2@nBdDoQR@-v1KeYyx`&2<&z@-Ky*%FppfK>s2 zdY$21EfdVop0TnjleUBUUo_>+?(EmL_Q&m1$;mi=SFNqBbNA-r&8i(+*~R-J4I=Ciyb`ZU3I6#@^St zOEp~TU8BgC;kR4K)#7b*M(Qi>&uB?<$)B0RL-vS&ew702vEJsrl{CQl%<^yT1IAx% zwCPtHzrM9J30sG>lycS1h?4sToSg5)AIqmCc(eJB(|xB+hF2EUE);gsRg3BoD|w&& z`8>}heoP8^Kgx%8c2-jG+*k!&@yyiI+N;C$21izbs4c$x4dqD2DGDZpsE491KxB3Hqcb2R)5}R_f2l9i=dUABMCB=@CMB($Z`Fk^j)5jU zR@lr10H8Y~A6Qpu85tWt*DuWoNLc;=^qIgs03sF&+)Fc#XS{1htkN&%&Nb*V&$pXc zm_@t}3RIrz@C#8#qUpRzGZd7P($X5u`X<2JH(&fE5bJE&+Y2w&VL4o#_MQ#!d+z13 zK3v%Lum&r#7jiEU2Pay|zgJtOT>SAjO;koKlR(Jr)^)0njrxU}rL;BE=d)kB;_BTt zIoK;S_dpB4ZFRV7&PCDJ1L)V1&vx2?5QdP5ApK$>7>Jvi)=Na?db+*90rSJJn!UFR zS)Vq8_@Z|#kEh+%e}nR&WRO%)P>_i>4%jApMuGBT3utqzeEu9qmoog#IUn4peLiPT zat2RU7>Plc)DvQbw*tjFm=eIxbgt74%l<87X(X5|WIyGyj@Tt(jyMM5yq&h=K4x?- zKHbN#h{n|pp5k}aNdo)HWo0|?CU30QI)c&rxH~L1Dd}~SV#!VJ4)9(L)m_FC70D!g+fekg7WAvZbzmDYy0}<(!v;`^3 z5m8|$3&BE%iHspc)FK`MNMz7YjP>Rk&gJMo>`u8*6w|GBYbarGobQ#L1^SVm%cIhy+YOO`vUlmzvOg$U^VfHaq&RJ*{(F!96M5Kn%Br zy1G*3!Ich|=JtEWUy|l6$1=uSPEQ1aw#p6Oy#w-Oc6N4G#zzp+#4j%qp*qD0wPRXE zR$xHW=e_Rf*Ar>~-2w0!1)%u{%92BB#jj9Xw56h~0|uWL3kv?kvukqc99;eky!ldv zKHyJ9+UL)%2P-NNLk|m929KW6V)3gJ)l7I(BFDR_B5g@653T5}X`22w38rAMkB|Xn z)cdyr=j+0{-x!Ev!Je2SXOvA~+2pN_wlHLz=V78OZm*FUJGGAY% zv+YVj~{8D*P|}~hF5oWZHl_r8zi2Pf8E=2N-(~BiA_pktsGDLoczpxi#T5~otnqzBrKj?r`pDl zHw9CqeS8q7baL_zENLTTj4>s8Or<|&CzYn{l+N9{D2+dx0x1=pQV&qq#4_E6If#h+ z4<2Kg`6IlA>Zv-xymKe{E!xcm$p%MB>;L-@scY@KcFB!8s6*U8R3b#<`7b9UY@IcE?eyQqqS zlM7K3c}0cDh#m|p>7v$kJ~TIceHRefF(SMSwqZrgAwa3MZg_XS(CYAZ2k7)el8}8| z2$%}&Coq0(-x(D+n>OU7ZXG8dzz&Z1}oc0K^sKW##yzp1lgJ=8o9$ za9lK?R|uV(9D##GvWE3+W{8-+QGQQj|7LH9Pk1{h9cLqHQ1>ABUS2gf$1;7A z$&nxxP;eGbw;nCRHv7J^&`JU|WaB9agc5PE&|Ea~20AC)Z&YqUnMw2<_g)ZKdH!R* z^i-UgO?cTVcB@NqqxQl2P_M}b2X+yNtlFMvUv!FWoC`UrW#(!+*o$cmHqj=RZbhL` zn9fjB?bN=b5lt+3KE*E&;zZrHL>wO-_H6ro)UIjOu%rWv#OP?^9!KCxptL4zE8_rk z6I1O3Q|;gM5cBsXII|F4|vss@R zUqSkXcpvGy?>z=73h%3ufH(XZpg`Py!OY%ML1_$Bf`FcNn3refdpbQO@IY{4^KM+1 z+SXGHf20IqAnXMz6sRebNu>`C1EG>ok0@gxU?pS7q2EEgN5Fz(kb-xDbAwt&3wd++ z95GKu3huK-&Y3m5L{8*7B7JwF$%Qdu)X^2iiiZt3Y|#B@+x+jY21(5sr8AFJ?<@`( zQg*C5@GDrHwGhM6@kiN@<=EKS!|xDODw>J&n}$s;Mn&7lW@gfnJmlM7)C3W^QFNky z*O$j&SBH!H0Z=s$JsOFXl8aco*IGtFUu!>3NxtUn1aQ*2?jvGM$AA3zfphb0d!`a& z_Wg?~m1Ke8hD$vZqL{C$I`UX9aRxLtkGH0QFpq*un=LG;ZLVx`6VRmYHnhzj!6efZ zSvtnN$)#05z+N_MN=Uk5K1UB2##odxa&_`bU14owZ0wy_Csh}hh<2lA>Hc2RXNMIW zba1C!(06plI%{67qSHm(VoW^;KtJ{T@Y1^PT?SAWUN<*3Ud(q!mufZt4aDE_z8W`& zwfUTmU|O!5efAb)fsg|<9=!&fqC0i8wD`RaSfe|0Si>eahxq%}51yI}!+PA^(ncL@jZ$_*E4sUNz~5qH!r( zvDoe5La~hHRi@#psLuKH!wo)gLg5-cXG9O!**7VzMcp?Sc-7~nN@IrV`8m%tFO|!FNP?q7`g%r&`CCS3Jxs`!Blm1Q*9Y_f$2Zrt7m}_8fcKS zT5pP0+h0$t+o$xIiQINA&hh^6;v@YnAn06lx(Q*Dy+d@nT$BcoIKh^ac*-BUZhxVq zuAbVL=l+SX4Vaj2Kq6#?s{=|WV1TaFZ8)=!1b2QWg; z(^Cq%?}zXB|NJL?=ly>Ob{`~h0swV;FbTIM&r&_-#q-pr4F6Z^>Jl&-MRTA40Fb1oyiT1 z6ss;X!e$3oH=h8zo=gtFx>=?mk@dnXh(8s-D&*rq?)~>rS!TpP9H2|oLye~4oOkpH zWUrRGdU*R#gL|p^cMVP7#kA7k$_$OLY+y240CEw~Qm+5y|1j$qaR##fd?!>;ipcXB zu#5zIf$OVIr|JCL7vJU7)FYRlO^Y4a2Ud>WU>M*p(m8qNhTETeCWC-!Cum0*_bWd2 z6wAo{ty{`Sh&rPj1cJ!Y_&|slSatdov!%l!5XXr2Ov_7dBo&8NRn?U)I>_Ha$!;e&h9{MQ-)r zz%xvae|r7q|8AzQ%ABcQR2=$e(Qh3`#tOWH8#x|Wi~SADcE+hPkW&u6;G%Z*F#j)1 z=D(8r63qg@y0L;Nv?2ffSPJkN6j)7=arMF^)f5_nxU5n_cGaKagFsaUsLBX?VaG_o z?ZGS8tpy)@kjnDkcfvd${+20O#|FiL-3XBo5cTcQ2v7G0F-oNy>%Hn17KitdkIpJK zM#w$t=%KAG{eRQ6e*f>kf+6oe3|7s%3@?=mei$vL2w_iGHjm*~pt+2UK8O%j_jEb< zA+Nr@l6keC!?!K0R{|1(t3_#MZQecf4Xn2^j&0svN!z>45SEQZy47dxgk~mKn7`$T zBT$p!(mz%IsY7*g`S#{f)aDWm~zY z^{qy@$nu5rraM+{dJ0}j-K7YgztEz5WipfN3!<&bm|3e^UZwv#AD5U8g~uwdpVJF) zoo<2XbSLEWQ#vEkTCtv8A98s!FX($}N$wX+kM8>*q(D~c{%osyNd3k>ZTRlSoK2a2 zE=_)I@xnHW9sZP7><;+}~Gz>DMs3dsF;7Hf_(G>-gE=3l|74`ehE=gd|i*9 zLO2ESCrZfiNI2=CAzAw;xP4J#9U-5xreTpW($TS1b`Y@K$`DmCk|Kyfdky_c+8E9q zTljyymRwhL-c9p?I6{zIJ;C=X6OV?fMNqitim*FTy5h91d|0 zf62-}0fI??E57_v_%Cha(xIf!V=7Q^USBJ0iAue0zYV{_18E&Hus(lHG2tout#Pka_irXZ@+kfYd7rV6t|rz z*r)rH6GJ=?X>QY>B0h?-YPJiI^98>;fkP4(-@lKfmETGGQaTKu@9Dv)DN;g#W6k`o z_w0xls$8E>se#KhP%a8z`C&Hu>)Wy145vf!=9E4BPhFp^`g*AP?XGsLtW68j(9i(> zd!{Idmb^L?<{;zcMH~b_oj3o`vKy6~L2hB_ox+U#&oueb7o!_DR-0p_(p6~Os*KbF zcaGBiE%A&0addBff{8sAnpXH}hj8TfJk|o)o`m_0Ul|q${{Zt}@G9l@IHt?zym2Gt zOcAeP@TsI8xZ(YoX7LHmAOE}c%Yp~_egt}hLE06s{EhD9uNA|m}Y z*Eicr;;lb_dI`0xPD$eVjX2p$3^%>VY5lXafE}{^Fzko@^|kZumoE#@<~zs8cqQ!F z)8}jyPxsp8kOfbuDL{#KXzWOUmF#Y(Zb#kCXS26VP1$NS0A2mRwHzz`qG}y3W+*Gp zai+cYCFSOnkI92`hYEH_cQ;`EDU#|Z#<4c}%SB>(8XdT~gB3;12$Fm@5+i`8yMt&j z)so$RmZiCPeQ|S3emB{yb8GDapJ}CM|68(>k`gF=i3fwncTCrIpC6kOu4u1ds7c1S zz{=E{gIDN^0C$yJmM6;)4`zK(#&0ug*rRFtl;MG6%V}9qgi~$`jwlgHbfgg$qw?yjp!%BFyfbKru<#PtBJ>Ug|*% zp!mM}-tLLGmJs8at_Sp851>3bU6k0CMSY6)vb2Bw@(*SSrvsa=4$qLauBOxo6pB&K zbrz-vPe^Mw%ZK)d*pTyX41_+X_V)IXQj4ZEu9hXt-Z$U?3OuO-nwsNCT^k$LyXEbrXiPo-b{TfU(Y$}})4VV3Z0{l-##!~Pq$OP6rhySh60r1HfY zo1W!cNQ?_YWpS`tVQTDMcNTS~W|c&x#>66n(>n{MG?5@o+E35rv3FUPveCoRbImro zphIF}WOV7$S^pUa{O;ybkxxter@1Zjo1M-x^*9Ut zxPY zIA??Nf}q^i=O0%zlca3`qW6RS1&ho#Q2*tnxbhuGj7w7XnD2LlPkUoAGKR-Zs1atu zLv;>>u5@5!^Mzf#Yu!PP{rLSk?Pws^&8x|DXV0I1m+<}!#g)(W7A6`0+R%Lxwaf-N zJJ(mUguh3~fi`mnrxh6yR220>r|TsbG(qO%)M3WnI+SU3XJ+iUg1u{R zDr$EF&XVs!Np9|Qij)~WB?~&dYc&U%F)am-{+%QIEI`6{A&kP!|SW1Sg%{tF97!%68 zUQko+E0v>+s@W$uu|}PvXJTbz1Mq3x*vBgmEnDy4;sk|A%-Ab!+0oqZorl!<@Lm(pqL#-8&T!5B&hVn@fxujI94{ic2 zH=w>$q-vDx1k%|1pNcOr`xEuVX@2kV-tvGiaaa?7Xm4~p6R#c?prD}WNd-~+|D5~3 zydQ%gnk%k%y5JpIJ#crru?4t8XAp|-Z+g~`ha&@KUsTtc?2L^gKxJ^agm{HrQk3)F zgL70bQVMDCB5w#3&ky0v)x__gxLt%ReRk+5!UsO{s@=N>9hnWdJ!sgZ=_s^~bO4Aw zw&{&hKH9PNDBOa?k2rB=_9z8!Avo$hv&bmb=%ye2i490TKPNURh5MYH36| zBh!-ZK50Kd?>yoR$2M|vNm@QE-HBrR_a6fR$GcbVIfqpne(Fryh&$ymA7JY=G6LA$ zL7h`dhP*n~FRnI>j4({3Bk}nfSAt(hR(Nb6nUzO{dLBGHclmNK zNa-f7gZl*7R_Fb5j6`8{1tmOF1KQP2HLJr|+`gpSlE3QNlA9$VCNe_O*C#&h5qFnD zt^r&g=QY-uPK&#E;nEXred~u+H^S0vE-8izL%ZDFnD%rj;2Bqbci~~zuR0i&;d`WR zW=7vIxpHy~x!d3cz-A0;>;dunW+1qS~3cqBl&w3=I9 ze!B9&ub%OZbKBsC-xkZ2VC%$4y1!B_g1n(r)GR%itzaJ0h2bJ&Wy`o;Tl!<;A*q>d zreE(yf98&0sB*WB64un{0AcftmG0hF*8f~MfZx4xqfQnxe%->(``xHpiNZs7MQB@d zW(A)mIkU1-bo+%TK0UUl^z&qFMgIriu6L*vk!v|1&Qi<=b-AvZqGAw>rd43z?OV?m zQz%osK^kVnF>1uospo550pVJX&4YLJX}p#e7HkSWgRD>O68n!Kelg0pZv#~*B0N9- zU4qBzL!FOuA8lYn2rMeJvy`z$qG)SdTX-018LcVECB}bk=RRBt_lKW_#R9zbKk%tj zeIk*9V45LjO(&=wc9Q1Q>2=;eUDsSFId;u*OPZ~vDKG9?BlbhZ&tkORj?FK); z>$Tl*d{MhcQ@1<4CxYC+9sLW4OR+uc!NI#eK0c6_#Qv=z`IiqdFfqNLm-e2$_pW}R z#s>VPg6LeU<@RVUhoPuShK`ErQJ*~7-ug%0=X&!q;d1M^P+~UF#Vx~>Z|_|fLtW6XZu@nL& zJnb;C6cFR)c=y8>^K)|P)Ky!ru5>OL%OtdMH-0E6nV?Htb90j)S{zQY%85elBJsM! z&;Ty3Um2(H8c*HtF9)_zy$^WYRQ^3fe)U9eWBA3C09lb6c^^M^-osbNmPlQBI8Vrk z3tVcH&GKf%mt0eKXr8cdF?|)=BWe)G7T_)0v-sbA=2Xw2-wg%T?K2ASAI1FT>~YGE z({oiOxte6P$^8DpSE$W@4u1oXRlTRNffTkW!u7cK1?Fi#2B~)c_bPP5$Ns(j;mh&`67Tb{9Wtp3HIk+zZ{E%qtO!7tygZUj%22tWLY!y_fF@ z+WUvc_wz?)E=bx{osPew{k#%x$oVVn^o_I5zT>-ygyMTQa%YI~j0S?g?K#DRmWjyZ z-I<>m0B~wzB+590!$*pi?C!1ecZ_h-o5k$4EA5G8%xURY6Ga`}#gEKTA&gf(JC1ym zV)>x`Ft?OZM6XC$S*3Jwa?$Q>QoMq9e*{|QTj68D-0W=YnSxqc6q|(YFOQ51kAF0d zGvZ6vQ3@9Y0+#Ds0(EwGe0#f(Ip z0-ffIVY%jz*TPw)YceuCpKkku#R(kV`ub~@8W75W=Je(lcSM!fB1lZuc&I?NIvPNE zSrgl7M>#n?4doIRHWxv8U<$qTZj@yIz!6NZ6LzW`xpDjU>b2{tq~Ygmy7SE~c0y0@ z-5t^l6~0TQ82a?`IdkMxLsoj)RXRoLgWbKRYZzmhY+_0PW&R@=i3|eFUqqxNzR>6n z6_a!YruUgaovQ|JRLc%iqG&f01^LYc9@aRE9rTt0j1V{#t%-FcKyoi<@XBKbo&!k>X~o$Vr$Fze3<0ntnm3z-m{--kZGYO`O7@33R<`ay!q8{vj4(A7!5q=E+5?!-MK$s z#K1%Jo6Ld0s}hC&uslg!>EmOnNirMR)kt2$^Jvy?EMr@a5n`=3A41~7sjwi_tlXk7AG>RbY-P~CoJ`0i~Gxd2J$kjTr-(&l3u*fXp$^Cyh40?SSR zo(qaA!G}@G^=8$I%H3eJss=}}P$TO3aCQvVeT1=&2GPoRhw6C$7nh<9$(h7VB_-vn z!J0f=0Pl=aNDZZb+s=_MiAmZ#3+RNjugMF)yr`%n|IwC_lM#;I{^s>8^^>m0+Oz)*M%VeR@fY_ckNF z;?0{SESeV;e`hB*G3u&C{8{eoqzi8#&ECNel{zlDR!XT{2xoGMlmykzAobUOtd4jl zt=#|QZbtPZ_Sq9Kox!&iBer$bmM}A$@Jzf`yOhekvs}hl;l>|7UN}4{+n%k(K7{eI zQtVyTShy_?WIbNoc#Ms)2dQdne;i8ZOjXTiV*_a#{W0D!Drz_Bku=`3iqX-! zcW<{gw^7rSA`dp2s@j+84vLy}GZl6_y&0=$|MQ)nQ=fn^c(Em8-uInKQ$^bCy>t1J z^W4GC?S5Mep7>cplft@r#}b-Lb?{rjtY{P=5j%z&jHJq7!4_A||hI{=VS#>%JQiw1cHK27=e4&;Cs_=(gQ| z|L-kPG5@{P|1iAJ|FGT|N6j%uzA4szK)ri1fWfer+I)s4y3($r4w$|o?S`US3q7Cs z6@QOAlgr$Xt9%I<61SOSlB@PIC|bqz1o|K!dmA%}xFt_C2wKE{57$=E3&Kjx~NMEM3I+$p0VShL_ z6DjpvxXY;hZ6<#bs-js&4U@*(^61ObNQvRC=WR(k*p2fy4hXQ(YinOodzvS^{^dz@-4>UUpB+}i~ zata6_Byw5-&%z;LjE(T_-|k6jWjxwp?NLaO&L~a(FhIQT#pn}qnKyGzn(OmJ`BAQC zC?-5=&f?K$@43;dI~RuZ-xU+={JSJ>{Cg?CAAfo2KM5H-B$DvZE%Z?j>wkZBm<0b{ z)4}imE|0@6bQ`|V|NiX%E`$7k|0#+Z7sqZk-*E4Br~lsveBMF{q3XXsJ(s5X=f5c^ z1;t02An0-b_sD+v@1^+9kMmck{y+DW+?40Y{nr|c(i?+S4oynaA|8V%PqZ&EDomY& zT0T}i+Wgs?gvmC}GJ(RhjPvk|KW493^82xI4lcIC3CI}_4>70d1>BIw{QH!52HnS{ zoE|$iAEEfz&R|Ny@+H_qdqxDfr8=KPRxd@P8T8zh%kcvnf0}kDnj`h^US* z8dAN^C=0`XnmY+io^!%af6W>y z>@4JwNN&4O)R2KV10-p&}l1B%hn+O3aS*oQbRz=^F+ zb_=dADU_;5ktIfavB{NddHO=>=}&=qon`7TvOGx7QoWh~=T8c@$}c_|jn?gwew6qU zi?ov{CG^6Sxy7QRb9duZBFlgC0v&EIt1Gkd3TXxO(0pLJPA! z{W3~kh3X8G_#p3cW}9IP|F}x3|3HSyLGci>seL4)k5I2wH!I`pjXP2=zfZDj{E(K- z=1=m$+^+XqXClor6Mbb_argg<+yqGT{_Q&|p7VW9{bwXJNsh(2xq1dUuIfw3bhuwD z>;*onf`I}hTjDarn~}iO{k#VI6sei0N+fA6>7!TSlFT~oZIeWOmwZ>i=v#n5wdKyGo)8NK@;qdYc$47^=I)6tfFVZDrm%K>m{@v>$cbTgHN2w-C?y!I04;JR;}f*|VhIFY$7$8N;!RcwhthEmhURn_v!#zI(e7zOlf`cI+#C z!>L*%yx~}DVV~1~Q{da^S3*JAz!yj@F@*LZai@Vef9?i}+xy!Upr_hz-#6WMR>6BF z0@eBjXW1$&zw0|eo5t%;`eArxz6o@VM*|5O=_)1%gmr_Q8Jbi>#~-Ib9$4Zun{7EI zipitiC*NMLuXdiGZ$m@HuK2rOn4{c&Bye@|8XgS*sb0H2V+r%GoT_>QJ+H5W*ARuX zM-YL$<0!0P=Y9k`ti$ysE%?MnA&cmD+p^7tE{9tO@(QGNb$9okw|10+_I=P726;3q za%yK{s zU;POi#UekEsikmG_0Y8L>sM`}T~b>R-E}@bX)BjcKV}5vBqapd*e5;x&MBRwJ|idL zLZ$fo!%r;o$7%X;E5md>3k&s}KUYbGi1Thj$4;D}VeLVVr*S!!8)7$n)-H-ef6t(s zyD{s%$EO-FMwFNT;rt-i`ZeblK&=;;TMC)hB117vBYtR)2St9T;qA zO)TiJ$hglTNOrhNuPFJK^on&BdFxitMAD{4d$9$U3~;LIcxpxxlQPIgpnduMNPxFDl zabvH}ugOOt*IhUD@y)}{7{b*aW(jDCmyExQVatQ5*pu3Fc>#Tr#(Rlq9>+BOb@=AP zhu33N<1Boh9>_akcEVDh{huyh6QkEcrz1X-KP*j6XXU+R?|fNCv?|v{_TbKU98Hmz zV2#1EHOn{GZ04vhkQ<~5cVC&qt&u39E^3l0>odPT_xZpOH(JB~0v}ufIRdM0b(-C7rY<>2vvQTw)nPFjI&TKU!Pm zG+bH+Jwr#@iszSe^UZhXU-d@NTW-=n-!Fl zB436?)-3;J21Z%=$j!cO>!0;o)gvh|ZlCIz`%DT7N;uR>8~r)tR4Xhk{RjC&z}o^M z@!ta7LSPLQ|Me(Lqy9af$*jU6>Fo_W<OE38v*9{_;2dG{pf_0ke9l%~n*d8%Y4m1c)UNOv5d)L zW8e+g*ziggu!6kb#Aj}Y4Vh@$n#jz^=#8tzTegw$wY2hWFj)wtVw3%f+xBbJt0{Gb zNlASSGI6)7$&@$uFO0aO6ufT;ef9p309$$%X?w9eh8yRC-DizD%XV>ZK_m(%>iIC^ zc=gzO{;7X1xU5J4k#el01Jf+X%ji?@Oia=n?&)1UY;NB`eA6Gr z8Ad(R5tMlNtN$FcKNHtq4q8GolaOGLu53cv^`A24@OMX#ksjBiU2WQm$?9J`0+!_l z-4_2#t+fqreMegOjUSj0MaHTiy3ykN)_yAY9sl=LDYSVGJIen(Aoc#Y@;LnY|G_-` zo@8Oq?IY9b4`~kH$mm0jB9`lq)F+!H^;nU^M+92m+^Nn>*(Uv`DyReEfSclVd;SJc zWwoiCqgR~ScyIfQv7XT_SP16k2hAkTWsNtsjvf>hXXzDDFT>p&FW>I{owT+SNR4Qf zYNB$1&d>StC_0@*ixXpirsR^lGNjUuWS^qiCS1Z_H+j#z;)-^1{^u48BDgqvQag9u zIh%KBgZ0gf->u$-Bi4Mif4f!AQ#VTm?@}a}2mMsk=oL~f<}>7rvqjMffx!eg(YLtv zA_7rGhoC!$5<(uLa`1qfdlOQ`+ck$XV#G165U4d(TTUs4y3by=45 zxxe||OZ5*q5yqbMNp6us1t~IkYMPBRdV=*z@|UvzU~lBh9RE1zvnAowPt0$bxJ<7@ z;${zaYnSZkXi@N-c$h`wz=UXgf$~q8&C9KF*ed{+-c$cW2b6jv6AaDYdo zOFNTD%Ij@MLnj}9BsE&tXw@pGtXi}h%9xXg;X*AUz=buiT4?Dqzb#hKQrvr5w9wn_ z7kM+1p7?2Aqfw1ULhr%ZOQKhTlO=t0&Nb*f*2g7>r+Pm_3H6DB_kb*SriqS@E0BEx z0|UYC^j*3Nm+H-Emv#?htZ1A0&5fmA5Z`#I8L$4aXFFC{`Pz#7<>!|)o!<);Smzs- z+kSZaUGm~F_8TI0z*dUY!IS&8K8i)(5_4dH?1a=6z3ld8~}#7)k7n&Xai-{M87xe@-~z+3?v26q9L} z2U=%d19g&}>&W+nSZHUC2z`R-V*_QD3(ok*RsVw?(S3Iv*Zp0W{+R`B*V$)k>f~P^ zVuy3~QfSmPj|9^#tj$Pmty7&3VHzzYR4ujPs*6W2*1b4G5g%EU1l<;xT{NZfs-;=q zY=&7dBBt?7OKq*ViMHZ7CApaB!JIooIyT=H`$281r`!v6hUh_)ZnbmAaduX`@6ujCRXeeB`;9tretW zM|q2-p0VUfJ^;x7te!srAw^Wh`0i0k8h@xI84=|}h~PHyq+Z~28A;Q#KkODWh^Ar} zh*zgOrqK=3HCLzravA)TL#y#a_uTrV1#QJ%LFd~stO@dlC|{4aF4iOREiY9u9|*Y9 zgF%j15d&ynTijti|{-G$i3!krPt+iY0!g(oA;-~}d>Daqn_!V9- z@(AJsMZWF|eA$3~Vx|;Y^8uZnhFHS=#(+A`7MhSdbjQ?eZolZR=y{Ugmv)tMrh)dj zy?9a@FKiYtyu3~IChbadanoX-$H=pyE8s#Zta*jIWMInulEeY%66ooHB5Gr@a7?rL zWnxu9cp4z&K@%D9al!`T57aqL$&y8Tmtb)bYE<$+AMoT+ZH{U$4~tCru<%^ zB*4e#3u0&<#uvGoA52=~mJ+LpH3M6t<&&Db3yXzYWh-et5ykK1<>cgKcnYE67^K}E zWjOB=n4?fq;+BG(YpWCqT&>g1rKm%;r|ivWAXH-a9^^#F=Vy5N$s+j;jO}XL|oz?b|HKK3-_UX^^gl~Nn~N9i@W>h0rSBMALho{ z&0Whm^5X?Y6aBYDC5nMZYgB^Fsu~UE1QHH|>xNFX1D4d*ogc}tA&BV_21@o~O#>UK zwurdL-RrZn*{V1#`GDQ^dRn>8&#EyX$J(qw=4@GA>)02R5QL^Ud9s-y_^jgB?zts! zYU^ajy0!x^q|{+=bI1u(W&j!i*$Ns7KNc`2)$0s4-G*ONl@$c>jps0qLTJg|PXeSS5w49MJilW;k6mmi4?M*6D0XD@@ zsZE*Z;_j>P3rPSRxWY;C_(;1PtHnfl%(ucl$~U*~=}{UZ1L6hHFg;xua9gVMGfD>+ zm=sCjovc()h(91UTm%Ufk4z`rHxBO1QK-QBzHILm?6FA)k|yD7r0nBFJgWm*;`mD} zJD8Bf5vuXmXBae7<>hvou4ynN4ZF5zL~|ZgyIMev4Zy77Do11D?U$D%Y$d1SA<+af zIkBfGy}chChL&pfzh$;1qUAm2_nKqy6VIAZm(E{4TMS|Y5Z5AD}qxZ17<4?w0A^bpvYliSyrz(W=`P z=is15_)pHRl!J4{ORYM!j^Fp?V{XEIPQ@NlFJ-eTh$@0kAHWE$pshI2ZtV9!HHKNk zirYXwCVI1_)SzU#6}c0!Mav=`M)!=1-v{)EH+F~E0>oTg9UWO4y;q_woNCq}?bi4s z$Q#QEyK}4IG8UXnF!l|$!P@WYg{+_PG{4Tnjg4~o(8w@GQ)aKB2;9C97j&06 z-vr%~a2!ZUjH~l*Uaj4ut=nF}zj(3PQJ45oTYG!l3ODSJ&sK)@kSxou8ePnVcl|an=OlsWow&6MA;Mmk}R$8$S~2weIHr1THlS7!1?A%w*&oe z_B-hetgM|FoQ6c?{?Z;Bn3Awwy0kgV5e4&q5@{{=r=p6zkv-8wE+Xz_qpmo_p<1gB zLLr9f+&M|#o={+Aei^E8z_uG>P5c}bU+zXJ$eXR0s*`u^nq#!&Sy?Z5j&!nvl1_XM z>?~`WP*&^djXz+iP;-uycDKgm>f%QC_ZmbUjWB`!2Y~?xCZ*O3AG%JSIcwrWuJqlQ z9})83Zj%_{rJ5`^&7@(Hl?0I-;O`d|73B`OXe5aP#w1EXnjM(I5syt+JV!i0C7m!_ z>E`c0yoJ-Ka-4meslmXOu)Mr9;%C{OxaeBNX>dt?LAqh_vP=Dj5=}?}A+bP3MTNG~ zb9qAH03JNU|3nlywR7+)_*fK^*ly&IlSo2aY5@6F0RiwY94f zwkR`tu#A^+#FY7zWD3MH(<~33fb#6zd8V#PM74PSlTxQ4?oYQ#NP;+7(qi{FA!P8V zePQiBe1I-Hr+|L=qg|IG-2L5aava@MKhXl%;`4d!pFh{Z{)aV{@Sc5pBynN>_NPrGmje5UUc687t(Fyc1D zUyX+zMKD}iU+k{er;qGxajJPNc)i##UK0oD1mscxiqW#xKG-Fk2v}_>S8cuq!=eN+ zD-%qe94)K#&6%iIFv#yMRw@~ls}5Wn(SqRx^z^qt-lp4%3~D&tHX_sD_;oO2YUOE< z)Uda&O&O^!weCA+yxxC|;^Dgjf@4(LA^Fh7Wn-9it8cN|)opE>RObv};E<@x4uhY8 z4GT^;hToPJdehb29|jMYD^iUBjpk@bB&p2ZCou5E(BjF{rIaTI+qiW>=>gi8Pkj6M zB_i)z(<#{h{@!L-uB%45NkDgAsWXg^WNml}NFy`UQNUIl5zp&AYfZ}t`w~n9JQoK2 zpqD@Kj4T{6c(Mpg9 z-KyE_dk^SPDOF(WZ5~ap0{t_`zZK&S<)~}Cy!TZ#9I%-l7G#R0rkxcJegZDbP*fci6)qJMU3Kt%ocZ+`$He|^f));-uYf>D1EN|Z zb;Zxbq;7?`7pYih|O1W<@ zPs2$k_UG6pa;hDWt5>&wyrjOse~Olt&{OoX?O7cm#qa9X`m<2UXPY9SRjbK{!%Sn! zxMSY9O|E#uqp1lAl`h?xVh8#7P%BHz{r#=t$0mW-q@|^0Wby*{w-u(_pocwciX=fv z5U%!Qj-&m!T1iPs{sn@u*U0&^XYHY`2C1(p*UAwC0|VG{+J67muLWfu(6sIDP7-tS zT%F88KF%Mkbn`j{1mAitJWomC!P=;gEwiIM1L>5ZBW5wKz_?nd9a~_CL2flZ;y3L7 z$9`+r%}HAuqEH+D8G=mq8<=AV0SrmIl%T8RIi(*Trl+RB2%!HnXzMSa@k^!0KQN2m zhf+en(srY3pEB~Y%xJL^HL4TFZBRGv6N{&itw|tqfGu^PjIn>R+2NWlL#u4PSmm+j z>1Ydxj;Bw*Lq5iQ_VRF*UJH|mRmiJXa(fuqr(#$%o0jDKz(T9kbFu16>zDC*_@IIH z@8tZ*Pl_MLP5Ex5N!Sm%J2!2%B}u^31Ps5)%E{q3u%;f8m#YeRM6Ek(3`&xm>bf|S zE{Wgo6g}fOXvb{**A&5Df0QJ4RkqbyDLFCDL|faP z(3XCZJ;2vP#&hCx%T$$3>lvy~fCVnVRd98(3at+wX-g8H>#bFXP2=7@?R(DJ&)YU} zgAp2+BuI91u30T*o+FJzPE2ddN4;5u*3 zRBt4pt2Y~(8lIwFLQeJim5=OLIk7p93e;hNr%R80qCmUAoCbP=4`1;g8%8i`* z4e$FK(g$2PsbXE0JpJw$w@N`%BlzsYo0g+z>O9B24 z#R#ij_kHe4C*l(9FQ6K5FPp90v))pT3Q?sRx3@XRdYQ35>v4JG83w>5S_vmHAy9-f z4D+;1!??v26ss^aX_)G-I=4$9(yo9beXb?nJ*?ORI;dNQg+2vB1gTfNLw9&UkjVYo2Q;LBvxjYC0}e$PgJNKjOkWW!=5o63Yy|7G#~5pLV3k@t*(M%-N#jXp#b?h0xI7ZOwKc`Z=Yfd~kI~ z1#7oSjcrLKuCAbqReN;Xj87^o>_b7vp55FuOLcK^iAFJL^~T|sMQ)R2U?_5yk2lEr7<>lr;$?i|6QdK(JzOccH0ZYgnb zbrwUKw?BfW)kw*qubq~p`(!`qmAbozLg4B;NI8?YChPB<*z$Y&Qv@>AHxX~-(Xk7z zmBWr&fq`cJMy=2m((V4MtJ7_+I#`F;0tyU-;5&C-;_(QmeDaW~ChBB^mDM$^d|U{w93dcn5fwJa{G+wyVT_8as$_bfkV^kH$yPW}eIu)P?MRPX%x=S4Ka|T&$%>9(z z?clwKdLV`r{5EBz%71;}?qNG$Qy$V1-6;9DG4WO?rC3w_-;9L*@kA*kAJnVcP)ib7 zyzD@l)^l+XTcxy?ibP96og2C=)Sl2)dgQtEckj*%7cT9B+Z@dLMIDE&2TO6A3!`q_ zTxBl!M%X2!Tvh=vz8^tdWU z(NiIy9yMr3dGW$IXj`UpT7D08Gl-7v;0#ZbH1Ge4T5t7S99kT~V4&6kZN9t7Y?9v2 zdy!}-%*F|TT*Nv2hg7FgRZve?Gj=@~Je@7NY>S@oYi1o~50;#Gm@H+td2}m^O~m%@ zL-(E@)2stwCo+_JZ^D5p^yYSGp-JuZI3I z$c5Q=aEMs7If8!Go~MIWvSg9n)+h>vy5;x1c5C?khYuB29fu99GAG>9Vw3{N^(87G z%^)r0eUmH_{7z*>)F#>+|KUNkCs9*Fb*RcAslX2U_}2YjKQ-9ykoTIMKVuc?ma&W) zdQvmWVili&)d>jpmha?%KBwVl!byJa#G5-aHh}kE_Kt=7dPHQY55c3!A{Xe2Z{&x$ z=G+_;Qeby)(@~8quRi?CSr;(W2Z;G1 zKO&h$X6xL!bDK*87JnyA_qG-*NjS0Fw_gCy3rY$q2E}`hjwTTFYiEB~u3Z?FwfqH* z7AGc|zxW52X+r3Gtt>5gxKiIlL10H|A;cT?EEiO({%bhpMz3y5<|2x7dV3aCDnn*^BsDHK7b zNT(x0?116JETI+}@kW`-W2uMyW3N#oRTyhqHZ1GO$v!#wVjqPM0Az|~xWz&?0ZoI2 zuH5>3R~eLp&<)=kpRvImi00coOW$-D`d*6u7|9-7F|wq>8gP}Dm(InVn~RH|pI?Y` zyG0OVJ6dA^@c&fm3(ygQ><eD%wBFYiDfShJKCRpy&haIuIb{QMH@9 zh8uW1h=nD_#{Qy*8VO92L7P{F2?2JHE|o@pTT8mKDd=j?tSqa~Sy@T=9a&nrY*tAqtL1c~t zRwQX?9S3f0l6W^{stQ%}pFh%W)A!BI&0Teh>&Lg(yE|Akv*dVZ(dD=su9BYMDi_d1 zQ_|o+@k_N~QJE^=xO#K83sKBxmk4GIb7y7oxBrt1;r8o;Cg=Kj>k0l zPDr)8eQIp7??J5>-;-kdnWfr)GGNKh59ibhhctz~KaetRw|i0s!?Dzpw78_C_^o=a z?vy|h`)xYZ^wy2Xh5L4Mr(j?-yeQEd6@?bwiyHCUr0ln+m2)oVQ-y}pTyqm^?A?`^ z4xUj@f_`<|=2UqM^%^w;1M^703_t9;_td1__VN*88ha!HTPwHEPw+AtNKsQuA+3q( zJtzdau4yVOL@%_wTS2=%k<~cl^pb+d?t=0e!q#lxRO

Q>)Y}QI`*?N8(O>=CE1- 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 ~JR@->R77Z_NmvtFJge>PPp zz^v>i>~fosVt(;=t-;j;fndsBbES134I^@fLsRVgtaH!Z|QyeAV-;nJ=z|K1sFw;NBhP;=Pz<| zEuXB|+t5~)h7}#I+AqBEs<{oMu-?6Wew%bkm&0q~iG5@ zk9<*PZq%*OEYq_%spKK)O!&wnHC3OSijT6H6yIIoNmFQiggY|(0XUkM>BO2kEkRFo*;n zmBP68%L81d7V>A4W2Kgw+0J3$tCJ-cc%C!BqE*4hy*;uo;@A1BiIfp^G1E_$%vY34c(_7{jata7*>Z*Od}EL$G3*8-&{CFq(VSr+#M&D z-zV$^Fyb8hxgX+8ok{F+u-q_-kxqZn@F_IGNg-v873u687%=354h}VYH!PLB7SDCb z?PFeW@LDPF)h8Q(%MV10(1md*zVoZGu3UJyRwG$|Rkpx>)_ZE?YFH%g|yxC?j?G%s;H8p~EhTU_kvp8Zf z&HVLxNWa9ms3xZbvrsOt8!WY6r+=^d%6}jGq3w2KWu!I_toXh^zws;C&9FPnclp>E zNs$MoCsm%x=h|WW%KPzlR-OL3O8aVQ1>4DKXwq}(TWdL%SKWzUh|mp}CBfhuC`sCA zYLN);aNRu0k$4`nm%sl+)(UyS2sHe|lc7BRLX#(mfjkvf!DAlO0=O12Qp7n*FjOH^ zYv=~ZC1zKT{YJw0uV0wrQ(dx@LtMw(SpKU-5;6_5!nCT@fBS`=%j*jCJDYWcDq$I= zq~&&7;wx$jKx&paDxFl8q>)`MG{zD1*!>f=>IQf3UH_EOGPU)|N~x=AInWn!c6Ien z;j@&dmLREXIPtU@I*2accNWp5mTl%2ze>a$QK!9ZjG*-rnRLO87P{u4C?KT*-KU}D z?MK@0Q{Do$vCL>}t>KbuT<4rt*}B8paFibDUoCbM#N&78^R5=XmFpjx%V#*9>A|Eu zyldCwnId6?0}RI|8!o$wV$_PF!6^RzlftfB+COpP2MigCPxsVb*Ch?;=V<4Q0-pdz z8-N_W=ljzqu++wJe;Fz_YMJ^~;a&}AEPlXzYv(VsYTDFK{Ao`h12x;y(e@@ZK|sV6 zyW;1_3Tm0HIV(eycSC!hPZ_S;O-eKQ{J9kuDG(rhyss7l^+w`A735{Q0McpK5{n>m zf!S|OIgMBLL=o@xd&w{GQk?N}p^?5IHP4+a_5h*2fyDJ^jYZHGz&fUIPP};y?{fqJ zdU~2Lh+%8^{R2ZvDJFdlaip*R4@ptvEAn6%cgc-gL*w7;UYV~Wv43!gM5n!3fEx*B zFY!}9CpYt&Ups*%{q7wGd0#*=z)WBc5N4z((a?dn(x7(E4?pFOL)#k@2mKf5yR5Xz(5|A_nd@aGlGu%)-Bd)K6IwN z{!>vr#Kv7;x~Je`K_w&Krt!S@mt@3Hw(w+XSwI)W=kUyP@3e7YxLkZ^nI?nD_dhez zV5GqYNeA_pp+&e(a}=5JQDT8aF!)Jw$2<~=Vq-h}>vcnGe<LWb?qMzk$`lqu zLWb_c+-_ui=fsz19x(DWql%G0PHyjwhZlq^O__R^E*`sU5EG)7`m;-2j;zfdd9+lE zI+&{nIGy%})@?H>d|7~vlUl^3RPRKyFC!>X*y8tJB@^veqlFl$>;W`PioDxCG}9G3 zKGZ@%8^LPvAwTpXl+E6!AZTlJ))#+=oDcb18ojQC3wKwg@?ZEfVXuNGqZbbUN%bTt zub*0O_S*(i^#6V76#i!wTqK+(U6ybKxiV)(Q#L<)a7-(`Fp1lP_;$A#dZ@n+`O#%_AO8L?< z`o>3Y+OtUkdbB_HNYd7fJND+R*-$!vH!i{;SA2DCjX|iXfD+}e7n_Dkl1F8B8n2#e zc@>TP%9XlmBiDVeJ5esF=0kOzI!!Y|;X4N@Z3oA2n2hh4tkzcZ4j5V7)=wLY=GoO) zbTxBS8V&2Q(g>g#6t72W-0DrHn7j{DwN4)KK(O#hV0gKBbfoWIOeb-P=3Aus zWIBI)q40!GPE=Qk`j-&=*YQKFk;bIf^7&D(jPsN&?%PYu&8O-Eg7vmeS zlr+zG#9AFJT~CQAe!VvCwud2hz)6k}UHI`=35u%I5;wznff(O6)=uwYcwzbz9@FjJ zmrib*+Z7Sai6m>k+&-||ShraB_VAOLaK%&Mcu{Vq$v3zI8pg$@(yfIPb~VS7xrE9R z3mDdbz;W4Ru}OS>;JxMRE1av6nq6EpU|gj6rPpBI_Z-=a-OdFT^@(zq`Hcw~etSa+ z#JJnOIuh=x;Exj0?#`}MV@%1+k-+OkW##9~g?+<*XftVl){v@G=ODpB#(|Y}1(wpu z>rC=P=G-AWo@Sfh-4;$tUSgKKLZlAg08N0*o>;ra(`~odmdX4(hF-EqnD*I2>Rzbb zVzw^UU3>fMZ?@b(79ol+Bse653Y2;@2rs;~0Qumt|=!Qn|#=CMO=vr9j32!OaaBq><0zPfBxY6!hAXD?N*lJ%Ph^K;4j}|6g9p>d7K9P`J8F4CCZcTxBS~j8Lv&vR8#B>O^gHa$!z+z(S zQwia>brG}&7{ViP&&MUa;22&JMEz(SU#Or6}1jMreDy?&phGp@|A^Ia}awhB3qW&KWk85L)Bf4xqQ zc%+_>D;&3P0|OY3Wn&SiWxf{+3#lz~k?d-f;w5b%QL9se6)?;IbuRw}rQcY&OUrai za%Vj64|1Y~{)f~Bcu$yE6~1h2Kl8GlC>7Xl-2!7AU``8I?ooYr;i`j@_DpLeA#H!P z*(Ho(f!Q(}`dPB~f!pprzQLylCZ$yIsx0b-b~}}3My5NE~sC1-bHj%^Ud* zczO;8$=Xk!&Yb#4SPW!xLHMg-Pm(`boC_)g z(@KhPwO*$vh5`>0Ae94gjH=7dtE{I&itC^(enmZwV){n<%wR2zKt8Tb*zD;eOz&5F zvtcUQ?-kVJevOlqS0i@qwhx?cSFL@*LrDg3>=U*`>^3NpV7=2*oSF48pflSPY$13H zVoBlI0hvKtN=O(Rl_4gvFw8nE06V`tu7*Zg;ACKlErQ9BpQeJ34$ z1lEWDn%M~|3?9VJh5bhyUBu@r()ri_t}qS7mVbZ#e=y3wrvA?_{?Gno%4;RT$(^`q zu%)|WtxwB!9h-n=dm;^ipkvPTza_A%ljq8sud}0>r>(qly^1k~re-e+ixwwsFY%t- z81L38XRATlmAeiIz8X zpa15+AMjLgfNJWcEEk+aCi%w*y&y)6FA~d_6NNY#c_)tr)(z5x(Hr*v^DiXouY1a> zU822H%(;S(@}{9o4&I37O&AsX&r`xbi_Y*Wi;p<^YO*Wm+Vj0OT&tD31j=+LgO0sZ z*y~P3GYIpmTveT&8EG#Ytn4}q9~yefXs29!A9XDF}a3c1h-Z4MSyEvGPU%!ZjA;Ox7--{iwtlZorTkkq5E-1jE{a*UN zyzS^WZu|V&*Na9Bi!`iwEz~6?U0mMkWKcYEhFJNy{SQ3p&krxqX132-pG1$7-6F%s zzg_xJz~uYah6u&rm+?tkd-&M3o^8Kh@hGcUZ4nYQ)!p$oi*uCxqux#&gx#TwI6tQR ztW#G+GM4KZbimCOb98#|ijQZ%e=-Q&Y17Qeo7FQ&wAq~GUtoc8AC2hY?zH6P;ZMMN z%;-?*o^goBXTpp~#)^M`@7+s2Z9E0RC2p`SmTjH5==s_3=X}O88Rxg}GTbL@=a>5D zWnOb$d7WQ(c2IHV#=^_6bSjY;^giS}X%SgW_dR~f-KY^#aV$yX<%}YJK1Ku<$2ZX% z?M$n}=a+^o{T+?d<93^I>rfiRYnnTJ&Z7r}fJ%8VhmV2=TGvFg+!U|9>;cCq~XK4?uorM>&j|R^xiaDpRM1wIXPa|$D;AY z!on!`am)0LdMyxsjO6cWrYf^nQ5l;&y)4k%FqAePj6<94s`;z!#&|`x*~+5TWTL?C z+9&yGLf4CDaYgopZoQ|2RIc|22+Jd>M#Bj$-04F-KV&CDyx;LZu9aqJuOmh(>~J{W8*5T21eGzPi7xJ4WT!h2}G%5ZQe zzh>Lg9NinDtLxfi!onwvrkeTOlptgX52%@@S2Z-r3o!WIO6|qkS<6bMsqu@qC`r&g!ND zRpS$h^7R*uaa>y_A7EXq_x{;6Qg;?-ntiQh1!rvR^i%c9i3^D?ANJ@xGxBnB!npM! z4kkOjWI9e>lnCo5&Uo9durij&$)=Wg{&vLo%dXXZmS@0)A(Q1 zIdK59 zt{3$~3?sI{Cy@K0=9uet^A*433~4pvlH1fx!I zReOd~AGw_$PwsN>I(tARBSzQ?E#S7fvxm!8{UV_CvyI$z=a?hpFe8Kt8SM9SOy~LG z)a#PSJ#zZeYHBJsEu#hIxYESM@zK8WFDc9d0(-w~Q%6U_sXcYLr@w85js>0ERUPP0 zCmikSV_{(%iB1qO>fLwToe9kMDqq^%t)+%@K3*iT7nA9~%iY!5o}ZUR#^WyOMotmb zEOcadWscJe1SBLCkj^2cI~-6t2Vn#xq(izv zLb@9Tq`O-{x=Z@Kcs}3XTJKu?rLfk_eV^+(=j^l3-ZV5T2d)?)PJ%$w2aU7shk3Ok zn3Q&q2m6XK!A&vQ21i$}5Up+b`3ZIYIl@x{ozC4VK%|f7W&PnH!4I8g84?VMb*>I3 z+3$*@?`N8h&O|k^FtPIToeMiq6rmT_wb+4DBO_|`G9st@0bHQl(Do=hTV3gj4B0Qe zwxxkeXpGO71{hE3X!%%9fB5lbK5bySe833ofJw0ixo;N~O>D8U7J%)D9@c!bbN+|nr5}!} z?moE7(F1GFO_%N7`}p`@zI+ut?VrMAV=c%lC@`o*{5*J014Tb|ZMby!s98KyIP#_Q zJltC%E?Lm=X^6PH|pyq*@Y-HP$oBaIG{#Y`RSymV(Pg!x> zfR!4$n5E^uLV2pX` z64u@o=jA_T-Kk{|Ojab3EbjD}?GQsVvgM{|(5HUi`5KeNGl&6Kt7WGq21OBqh4Gv5 zeELxnf`|N7iu0^2M0P{iWp@4OoM-1>btKH1n*yz6>xgnd3sd%!GRya0qba&FXs~z% zhSX=3xs2cw552{SG5sz)k+rJf?$wFRVWL6FGh|MzWv=j7N1`iB(8p|IjP~n-T|Y?` zM~_#XjXs5ppiAF5sK}j0%O17mX3lrtUL{4EH0pC0Zl6%Dr5jt}*^DD>?d$C7Hz=xT zeivQ;T*4ClUzysU4g>4iTFpid#-2c1(-6%#@)=#ogFhy4_*|)w!{|Qk*0>!v`SEKk zd{>9vfLd$<^J96y8Pc_UTQX#s1>~FXu^QK-dd(eFjq80olwL}_emy-kyb-be*BSFw zYAp;Brc_Gdv~wFX7{2&*O9^}Fl-WG~Xf>arPwOr}qy0Me5A}tJQaz%hNUCgY|1$Lh{Ti++h@qjAW2L;+gh1j$kP( z_Tc$5?MRZ~^x%P1dYQIBiWA0?A0Il>^AetQjgGd*Inb8zM+i~R_4L(!Nc{US7_L2k{MY-->M3yP;Z-GA|5pc8`7+OdzXJv& zpbrD8?7_{{FE&Y;6|KYe_f!+fkN5w3b)0f7)BywD@-m91qN=jsaeyB*pYt7@^f}e2 zx@?B?YEDu}@x*0^5H%kkZ|j$bI%^;=BPHSth9D47F7>o6V4w7?PrS^ukyg2W|Bv+l z+7kip0MwTY2HW%dT=hT1Ao$ULRt*kXHfRyrYf@%p^HK3-e#C&2oZWlb`FEIq?Enit>u3B|8*!y z1Xhp~#&PVvwv*oTEhm~6|=gtO+ zQ@sw@CDyCydkl~sR2^C*lIB5lC=79!?!B~0*-uQk5;n?^j(_1$W>mP(K`J|DYqK7S zq&Tc8{`TiX121d{1#Mw9w)MNP+Xj!Ht#$T^L#BL3qt_-a!gp<@gC{f_3X?#3FM>CE zO2gWnKla#Df8qNs6g=-MOq7^y@}M$*eza)$>Ep-Aw`W&9x9vzvqqsL!CS5B^{Gwd$M4>6fB4|Lei?Lb`8ac!o{!gf`eWnrpbtjd98mbL-x--gKxErDs_Og zqoQr8uJ#aGxnd1MOI6DF_nvZ@@y4NpTu)}EtNLxkk`3*D-^bo&m;AEjbCtjoMWJ-m zlUMP@dENi}Iu$J#`__8H*9$u}$Jb6ahxtQ8J%OCnS7k8;CYc!>$uCq#c;N7;s0UJ^ zR>qYRgdsy%PP10{5(#7pi6l1j}QD9B4CVu`oXi7Jdkkmc~g#saM+q^G!NI!aGV-Lv&P>UrXa zrPVk+Q%-WK^RpK(4Um0q#GUw$x%W$RQyvRig1Ec!K*%Y|PBy&n9N(#b-muuvTj&)g z{Bf@{)k}EiO6Fy6)5nhXhwqR*bD2{2XHq>D3zXKT3&(*wBXjn>w7+%Q6T+B zrLe%RLc0hO9vS(Yd#gx;HGM@?N%XY$sMzy*&KJaX!)k{OMPgDa@?@vDnd8L0k*}br zwCfQS1rR8Ore{hM%#^a0yX>Vv28DUE3mc3F=C}2}d6;QeC9spm#1PcuGvy`3CM3MM zW$6jfxqc1>LYe16r{wm2cbEOw`;UouCDw|BwDjlL5*$fj^eb`6^e?bG`kufBlEInG z6%Kq#$g^tCpNq%lzEJqkpFW^)Qll_X%VL}`h!7p(ndOE*@`Un*^}mL&rpd(Qkr$0Z zkeYY9Cy)7V*EW!#B!k1pO6=W>2k;uFBl&ySBXKHa-84moze##~cM@H$x)k>RGepKZ44v@G0ft#acUwix!ljEVGEJn5Zrdl#nplVWjf*;tbe;JT|M02Ue(}?xQ75 zUfh3?N6SBYh4GM=bc|;{LzeQ2vabIQEQafIfxokMQEb+)e|WhmXH%LBK^X`;XGK@b z-ylH8#>UjrATUZ&qrolFi{pa5<_j3)dGm3G%bFJj35k}9%4$@WkX<7!1HyIzsM(0n)37!cfEc0|YdRz6GYQ#_q3TTwIuGXpg!sCd^ zLhH?)>UmC6Wh4aT%;m;veT1zo&o}u2;cG2*(_f7)vt^!R(|`VydF(dOH+opRpQ#78 zH<(zMga2HSSq{PasaL%KFtDi<3j>Gq654xeYLRG$$G?r7>)&5@uoEQi_#QDDhumIn zFN06@cI5Z$gn_9zMw>y*ITWqD>$vj}4$dMaPZ}XDO74u3z}o(!DCrsxeT%u^nBHR2 zSjjiHa-~|+`RVL>Sv1--pOdz}Of`d~`3dt;O7^Vg(y5tRVGy1HA1^{^P~rDJ)7v$i z&+8V8OjI&vR)#(g7|SBAFSCr*jBJd2U|OBD`PGpPU$MF?@8zabZXF$EV1~8AbEUUF zJL)bg>JrQ)38&E32-P#f)$o^-L@^;upQ~mpdv0b{Jw$1(o@vvsh9abW-Ve34OS%bw zHa2R=kY8n}sob=!Lm2d({!-A2I_1gK#vChvlM!eW*{VERJrGTzlR#rq4jBxVE05cK zzC}ds>0QjEO?SQQ`+S?&_&pbdjJ-fhDv9s2txmeDj&loWpPWAD_e^M)zwX~8TI=;I3&2Q!sqy#Kt&N6b;7KDZc9=ZQ z`xzhIOK|d&T^sceHNC9v4%ISjrrQ1s41koA@#qqR=6~j#&A%QP^?_+PR63e9wu=n-j;KCD9;&{FZ9Lg%*`@yCG+3M~pb8T#i zVi5=shqgn#8jIEss9m%}{?vo9)wc)v4`hLt6Z7ggKoe3h9!ay@s=c9QGNsw8}?juWtX?&}XmO zb2BWVU;%Ue!fu^D1{8{#KbKGH=5`b6>f#K%0xVZTUH+Px@6450Z8d~7`^4^hYyF#{ zUbwC(X~;M{kDFJ9ode#d8MEfp;r=mV1&q|g94pdS}X zo1ag`r3&D~)nd?BTYcaujcDIGX*_GS=>Pk-e}faGM@I~*Fc+}h+03rr&%FbOUmtE! zzc|~DG6N==e~zY_*}`K#p8@<+PeTI@vMn8=Yp#;WXXn6IzeqjM!i*L?1t$n@skTgDaO3j$W8hIYv2cAn z6S+#~PvBfDOcwkNcKBC_+H`FN7<+);f|g69-?+5mh4|t>3+ce0OHdr#KLp=0TS1#1 z=ldClS0E9oTXq$*mde8R9Eae(sqa(s;O|vc{kGgh8AKBDQkgkcApe-Hl0Ir2#z_~^ z795*fOEPB4M(=P!Y>c1JRGVtPd&P55`ehDSqfFC2w&B}6e+ym}ef&_9)lpT9&3I?W z8-tGrm{OVPMSqT!L~mo0qwYZM(Jo?$7~M2*nt2s(!0&36Hok@ zab;2R2eI33h`KqpzIMtVE=%j;tNPaQglZs<$@;CmvX<8GAh=C&qxD~~8h3ft$Ml@p z#@T}k3FOXm6ut?%wO!x3Z$@!6eG@#NB@{j1OrE$c$lnY|^(1}r zh^Eu4;Nk(VWywDVD*r5K?u*D!Og7uu+E(OPy>iJ{qdtXGeN*eR|%luSsVcsQ|Qp7PIi92 zQF^)`_9-dGJ2<=?@bWW<#GviuZeFU*At^F4A0QC^D#Jh{`ikDAY5E3 z_@N4SKlq^u8B(ave<^<}$td23+Jm44jzJ`3F}+RhWDvuULi3;BcV4ZHwy|_J)v}xg zhhlaOp%v>3((wI~Vw%L)tA%laqx94^+x4beblzc&9c090K{E+BJQP_!v#ecZd@@sf z{gPIMkC%7jyM=y|{ce1bu8a5SlCHS;r-+Cpj@!MR9&>pccA3v$=;9FTw8Kv`I;I4R zpE*)LODDE`d^iUekVDYl18D-`g=aKMkxh70=zhQO^TO>wm9ae9^*4Y(eY|aIY&u7@ z#u*@~si+bn;{m|K1QREL(gT3uw%974reZSQ7u?IF@SAnG%Jjo z1Iqneg^d$;@s@WYJy#puCkum*&cFkjqnjW8(Woj(UMU*Dt!GnRGb9BgW$6P7x_s5zRyt= z_gNpvsG6BMDR^ZTGNF76^YpZ2b?Dw`sLOu-v1#@i5|sZP#gab`ABafLe`7TCKDUmM zG9cetXQDAi5bd)3fYNzp<%`MJNw!yPD}V49C=Uum8a<9T=KQ?OQygSl*P^Y$t<_(?C;$wP978H ztxZoqs1lgCCx)26LxZ#CI@om zK*)`XJ6VI!5#lsJvIZT}pYwMi?guavJ9K?jCaZ@qc*yt94DR4TuhOE&`-6=3SwVij z^>f1A>DHf-8AL3sLDQcHRKc_6jq9AC9~eULev#;T;t%h!xnw_@qds6|C?|y6pnKeC z0zoQ`0!)ZM!K3}7;$V+6d5+>do^K(vt>T39E|=J;o(Cr`q8rC`TgBf<$;mmb|AlYa z)mr@8Zw2U3vf>21{P}tFnttuhZU8BqQp8lOdek&{%=cEisWCs_r@gP>CEJbLC-#=o z{c6$Sx@`IT)IaB3{{wp;X6-lz!K}Fwr?8RiFreO#>s%k&Ou}CXU6?=2sc)K7nOX6& zc5k-vXDAEJt+N4T+GtA$rk^-9#RG2dUM3Zi=e3rb509FQiwc5+L(zN|7ioY?o{ag= z(0ps7rF4lGo8IIiW9X<6@5ZkmYzeC| zro~wp(|va(s+`}*d+yE9iyuitIe9u!J;av(m9hMw`IDKVTrT*zppJ9EW!HF$4v_Y( zs>K;4m;|_+?3%zogiwf>?)!Ly$^#6e$N!Z|@4mOR1tetu89P-toTNQ21zA@0V?J$S z##RI3db^|fM&;F?Pt#xYNy2FgIgU)-ioeR^VyCAzlCtLs^2{95ml3IyESSsc1MU; z0{zL?9EM!5{2dmICh^jCI*3cZ&NgPsmO?PEd1kql69nJ7 zuYVE(Y4>yEDWAv$UWWjWaSKi(Q>mrtR-K zL#QIgg)7ra_<8A>pYaE(3a&Odcvu{>F|*4lZILaOT)Oze%Cw6Di|Ib{C#gvXM})zQ z;pAV<5Y(iO+l0tD`b7EkpH)u|gOR6%*XMnuaZe}X%A~9l>7N@{tprr zFe{0fRO7}om+&&MW%2Z~ayHF7nPbIj$4YR!`82uc_X zCARIoC^(YR9fuNz- zJ6QE8KxAn-Gm}+up5UixpP>l$js^t0?03 zek6nOczB}m5kvAn$Sl}#h-l5suD~zeU=t|e9(3zSMpYkO`a_6hVKjFXJ6kT+$YEuJ zdbj1A{lGx)dlzVukDa%PB|rRHC*nm&GNOHMW|LFKecDkbEA=dZ#A5t6V$a9nBIg;s zJ@f8e5e(uRSL}UMjo{g8ujl3CBOANRk8+vP-cI9b;02V2CQlQBUtw4*Hru&9ur#3j zfCmoFV7YWclN??OcmN|9v87Cg&x|o3Wm4WfEHnDU>QgJ<5wXwLDd$9=b50r$Le1EP zicJRpEYZFh**ZbN;B@YO-hKI%iF~&C#RW$)z~;@f`P02;T`s#>IFPjb1RCSe;h$?3 zWm4$!7Xq1Typ5}8!@cqGdEh2e7^(5Ny{kt-fz2Xs-Ckh1u*Y<6Wl>r z?f81E@AL__&BTg^6sI~IXpNDBn}Oy~oDQk0zBxex?m_~?Qq{aZGEa9Q`61`lR}^Up*Y^B4**Y`p{>NhTYARg-Sqr!yqGj?LslH;b@ zIbzJ`N19)Q^M_z`yHw^*yiD&Z_Wp z$>kzVtKzF|XAgI-?9}1-xCH&{Gr6VdDv@K0i;H=AzdNuPX;4ErN}Vut_`PTAF59zu z%KR)M&;4@`sEPhNvHh8%IK=M;|xDgt!$BJDQ>4GS2pSi7!gPGAO<5sqU zZrr!q%g(0iIP=_JSWU>W@L#Y3sqZb=SRicDm31_Ai-~sK|Jd{2LN#;2iPI1yK>t+&S=}`nhu=um+3zDLFb`zA=W&al=Q)@SQCj(gf z#dID1)TM*N#}_UZ6!`v~Dekjd2PZoVc8iPRyw;+mUaP11y|U_@kD!Y(|ciFHO?bh|tl0GrkQiXf`=5D!efArRLv;O77~CLawK6 zSS0ZATA#r*u_MWAUI>SkCUi~$Bw+m7MIh>5>&WJ7677V!gLwzVAT zcLLA2xF$1HHe-0>vQ*RZ#?BW6?4HOncTDb898E#}J z)xH0s^lO{JV<|dEZRb@vxij_wHe&obU53*dH}x{9#W1hcGAZe7)K{gjL7uw$3Zt8< zjJ>Ld9J4wV_BJZTzZPYL={P81qR(jKL-l)?kPS6zU{l}NhJ>v6=B$zMF{@ox?8q5+$K(5pgv-J3IJyY;e zbVe)fJlersQdpq!EPmH$yXX}Y?e7JBl0lz;X=n%HXQ_!*EY%mmeVB5m8rxk3-VTzx z=Z{pPJS6o7g#_CW#-Z1x?{_v{!^`)o&S%Q-tk>i>5B8XanT6e^A^#28@!>2%wZ_DeYsgdEdN&;>eisIK}3#u*q?oupV z2Z`f|lQlNuzh`gjoFB54c(TFSM?r#=xt;7S>%bI}Yo5`*8tJ%7X~ zH*D#5xIg>9Q2l0i75z#ZE9dqwA=&$ao+_!6TmJM;c-461ZlnsTUo>O)7nCb*JL7wS zu4H~*HhuORx~{p%0+s??JlugqZUJrv7ItBLNCOo|$)J~Pm_~3kIw=?57DlzSIJ;!D z4-6#MrC;rsIA~E_6`rdJT$%oaRFiSAHk&jmf|vimCzJ70O)CoSTd?KucAC2B^XTsT zlrz)6b+Gu03a6FK@bJ=`^jq^+jl59hRK=Eie|)iiVb1X}IKB4Xxnln;cM5-X*>}2j zPky;BV1O!?hYcLw$!_n`qMxZ&lVu0!bjPC^!h7sJyWhwUJ67-A>vgB4(Y%~p*j_xd#a6vuhu zvcwAfKjg-YXWk+0WY0+@zw^g8i+bEnkJ-TCUxL0io&1;qzMfR>$FzxkIYfOsAMZh4 zR1pd0Z$6NCJ8tDc3?#`sAIKPbLt#CH{Y=&nrX0o z>|Lshw;8)zeC2L4G+cO?tsDHu_};)CrPqgrNusWF^G3+~XZNnOZS9J>y>=&h*Oj+C zGzm8zZ7kja=^LIf34;1$p=8igK{x|2Faa0%ESi^nQg3)A=JBf_Z4P~bXCLIP<^_|6fOF=XnYPY=I>@XI+$?TJa19{ ztj88f$lyn!{9LZ~#`{H?YH`~ITG8+-&T`w`KVA-+f4?e()s(pOo;PNyO1wvx2&Hn< zRcS1YxT2V7M$Q)ze|~9>PZ2?qLG--m?!@9pK05%VOYVdh00V=m70M%lwvW>|2SX1X3|aSi$gx|WnbkB-FD)Q{ZG7*it<>jmlcXP_ z=<1Ayet}0ebC=zZyBl1)zDD+_DxkuVu9$V4vx{qv;!s+YYI^olCRheq3$3_Ff)rCl zg@EMTHm)*mv?>~%B;5jn!+RsltNtk8&#x%#-R#=<(A*#2-ogANY~-0B9R|8Yg<01l zU$zTvzdsiEK?5u{S06OHGJ4BmJbE;DpI<5kn*D7--F)~Yy33}WqL&U!i$>2@Qrz+~ z+9PkfV^2D4Lh*6_e9C#!KlNpy-y+AuPkLT+{&*|p5X7%A9p$w#I>$C86 zho)eKJuv3_g^_?6@*C%el{HBQy8JJ)spb>$xU{bL@Dkb8E?FNpy&^1`fCNRaVeOquZWaozfl-SIf_X+ws@`Oun+vW0136s zi`4GA3OnSfa(4$je@LvrNJpx7fjj`5Cu|6h0$ub|h%A~3aJfmhqUb@KgmD+?>gZGj zow_Yt?;kPb64zK#2RMumK5d`K|G{WuD*fSlEfF`dLAGWtoZPsv#!l*EV5stb?s{q= zv)U!_J!ov=QJ5(~ytb|$T?}8GO`4rfNHC)F{|c&WEv@*@WfeFnWESyKLW@KhUVc1O zceQXDJ66A{T<}3Bb$#k}6gv9_F-vX$L_L%xVftb@oq#`S%f%1OPw=6g$34_H*~nmIAsVDne}b+^S5cA5en^BMx8TUaN*IGp@;1ziG!f5c z>)pptxy{GK{ItT{vk1;DjJPa8FI-54^!&Q%(0Dc>1W{dImv6n3Ul|%2`uY6xndqUI z{oDOfE45A~a_T2f)(`#xtGNYlb83=M&nm9lM&8;4S#gY9IrrRFc4No2$z4YG2T0lH z*VzH==-BRPk3-SZ9i0#-<86Y~h20bzk5bE@{V%x7V0|_`R7Rg1)bVLgf)M*UlaXHa({nRCxrrr6O)qK8eyfZ!qK2%p}RU zQnfnAnoayLvZIw-piYF9(47&7DE zi=LfEZ11giwCBOX69aYs1PeOxefB;04E~OfMx@Q?)sL0=Qunva*@mMySSsPXMK$x~ znKj-9l4@3d7LL}FwHrjjPbs~XBBhu*0^we&rnMFWR76YZ2{|9mZiwzi@cs;^PXgOzF|laYlw;5{WPWAPw9K zO7i}dJ$mSUJax6XnNW{tzMBa-)(flU%3m!mb0jA9PiY$TbB8n0_y0A&D~KG0nX)M` zH8KMGc!D-(J-QR;-_#w@zj4qUCqpzEZfz5C!m0bb05m@GHVHD_heVS<|gmG{MwDpi$zsNtrEx{ zJb0%K#~hR!$s5@b8S@ftOoPZHog6;=?6`6l<78o;M36Mks|ecdh!@XHhze|14aul^ zNKS>+O-ntbX~{(lp-xT@n^aEvQVd@72;*)%T&}T^&nK#=V{i#@!4psG>%G?nNSyv- z#>w1n5`c$ER{rh15%O=}^NEX4-Ff8J>iYWcn#pyf@Ih+VT5`R`-VKLH%9j14uP=Iv z*UgyCj|LJGGzhFsqu}$k@r1Wr+7EBvK~=*mb(f4~@c;{T7lE7eWK%T%r|1y-i&2g$ z5?j{7mqGl3EuM4x(ep!SDrgKcL#ClMq@+5tw8^sB<#-?Av&+)Q2 zFKrM}ErkkDnX+);H`fzTizJo({*;wF=6d%a^WEb&%ow_%L!bU) z@B8|o0pRZU-EePAjC`PO`;LF*LGAvhrPb3Q97O^5xYWlV_$_GNu<`WWb|~xnG*oqNim~@7xI8%> zJW0u7CvC?U7sVtj0g;;t_nWrIJ@hA1nV;TAkhbp}Oc!btPiU&E>Znc7bd^boX+%GN z@*)4h)2@65=P6xmx)A%&k$OiY zTrsA+tZQUF0<`W5@?kt1n8`kKWjaIa2==xKH)UB#m0#$5PF!p!^3RXvg%h$&}`Wc?CR>2-7$5J*%=xTUCJX zc^K5~-@%Z^^OVhTVpq8Fg*ys`u{RrIRy`9WD~s6n<^`M@$GRV{q|ZH~d0gk;d8M^7 ze*tro!S#qwZKTU}AgwgIIMS4exvTKc&C{u%ncrG8?vgm8K-Qjmb^XuJD4o`7POa&B zDbB*+-$J#4w_#~Kb9Nb<3F?i?FLD(~NXbgn8{ogi3D|#W>Tq6&rE~50j|FuV?VS^M z($Km_)d}8z5gFE0GGwHq@SoO15@i|r`F{_(^n_fsRCpoEyhV7a*;08K55klLZv=)# zS&6@$WCW&X%qOa?&c0J`&df@mXE)%ZLD>T1N@5gRF_0x<{~-p9X6=h_fd z5z$YKRo>P|e`Uf%%ls}($2qugs-aQusX{O^nxYal{Bur!Bd=S5{UMAKJxXy9r{>3$ zN7{6Z_PqMob^#*R{5Y60h2+U&L+-#y`tME()&=~>A1*S9GA6coaRJF*vuJLRot}$9 zFbELi`ZxGg*6eSXU%nICDmoLf0zCouE(5tdKCr<7~)H zI)D%DZ=Rb-5n_WCk6yXwNoPZAm6a#*PzJC96ls>KRHcO>`#mF1MTLkeHB42OImQf= z6U-CAK=Xm;h?6ESUww+}CT1qV#!l=HoBeyVzwQQ_ChC0r%LlS$nyTpU|1Q52jUAM-Qc#u| zgM{l%e0mt#W`OT4fkO`#S73m74k#ZRj)^Z3DYxx$39{~H#|hpW?Mp)+o@UT~It?78 z3Ddl=eZAYJm+gaQv=UJvS>Gd3n1fW>FeBxv@Ni;m@kfYsikOJ`>S`2WaZTpu9_O8e z@k&UdZD81vV1{MvFx&JNSlzhWs949pHxB#V`bE5pwkozfQ|y!A@x`eDOo0v^i_%|( z&VSB*q%!&eKi-4j8?3wLgHmf&{mq+yxMd)Y}dnjeP4`EaZT1BFHlSEci1BlCO9B)NXfZZo)n<_O9uyr(_gf3jKq z38-LX6o)+-^zMz%d3b8d@^x}Y+B@C^YiX6b?^GNy0y3F#z)ulZ<=eaW5A^>5MfegF zW&bA0{lt}YDfJVi6i=q^1}JT|nkbkQ1TYEzPSnlo#B8bYL@5U1l{N5W+EsHVN^-cH z^|5~^v0RHDC+2#vc<%LZEVP#jBmc}30afCqjx1VZHz-*5A3Jz!}=)##3eiH(h+ zCELBlqd092B)yQpstCTnSPX^V25;a0vM0M5^3*0!bxXI z-=9!@+v_OpX?`BGp)~5() zdS`x?JK28?ir=Gs_r|aA4LYzzGcnE_|4?Q)!ccC5Q&$?9i7y>SC!NUx7rUONdAwF;C;L zYm`*wOQEgPG7iXYmDYHnS2PS=w#T-$>BQ-i^kOZg99X?3etiY7)QnrtB9v;kL3yXo z-L9nng5E$I!}Y1>OtnlR-|Vy~cQks(df0aN4z>-QMA7Rg3C=X;M=GFPvltg?w!aHR zKbQnm_@MYwPPPZsnlbJdj#rmFhWFD!Yk@-Q{W#Ys>We_*`bT){n7f+ak z@b2(u_*t-nvkv&jZ4Ij0W}$?h)jta??cPnU`4e@q&}KC2_k7=>zN37C+ifyFPb&}| zLdHu*PTtb=6eak?IpF8+Z4m4M2?;p~3ABjItT)KMUYMid%G^YauHxIl-DDaMlP204 zgR!Y(D>~!ma4v*gj07{p2c3z6 zcIBU21PX@X!`VSzOMfz=fZ2ZO97d90qOxo}>oAtW?NB^_+hpyoW8Kihj+R}xU4i6U z3U>*WvP{!P_LXE^3A&gS;la>lGbM^N@nxV@6KZMg%ZB>?z~kmHOFFUsSdm7O$F$2kBweql~o_e%(_j0paV!fhcd2D zct(UYa^9;XYpJSBxR^5b4HGFBgDuXL`dRqh3I#SQeBq5n)Z0y^-S)t$zw6al(MzJ$ z74BmY;HlV~B;d7MPmQJ-^~YZEp4Mb-V8j>=E;;XIH2!&|=nk6-4nFtRi;Q~+s+b41 zT!t7_pt~9~~l>Yb%Q7eSsGrcJc#l+7S*CRiHr(GdP{09p5Z~FmWV!$JYsKS%4YHfj(RuM$vK?UwW{<}y zY{;9Xf1XVdbpEIEdy2p-_Q7q$e`CROyu#StV5I47U8Aem*?s_HPB3|C-S=b~z)A({ z7^DHc=|cwPKL?FM36XNRzA);CJrB_MpY(tCW$r3%`{&$P2Lhl--Z;a)Z&Sru)=gP*I6uSbOj9foD`pAswTy4G= zJ|YX2gu`Vwg3NljI@UrcGp#AcM?JY3pwb$CIC(x|_uk;7N3!Ah;zulRDbYb>#TnBM ztd{81z((gfrtK(BzLe$VwwE&+T1y_WynE?9dkn7(>#62nbpc8SJ&AC))BTl6vGQgv zJjYGc_so3j^mJN+faHKMR~t)0|%9aE>sP zDSP2Phr2_&cWM^8lmg=XCoi^7+iT*Hlcx-*2NI5gln~1Vf3E)S-tOsSxVo0(e#@tb zOsP$VAd%v;!>!_(`MyNxGe|@LUqp7-JL|hpDm@tkf`W zzMR7sEF|WXvQ*Mu?vK3aEz1dF*aHoJ&RMWRa7lRw8zNQmF*KRi=?&l;^h#cSMY|iB zDB{_Esx9evnz<@^UVJ!<*%(0o+1}=ES?7ip4UHg014xE2uxFn@j5PEJ(;B)iNBSep zE?L3j9Qf_C<_2y&kNdK?o&Qqju-}=nMI`N>oq5u9a z9ju=8eWS-0gVKx&Gwjzbb%B|dsC-&74Mud#xRAM*S+l4C2QHuAdTeDG)>(eTCSYUG zQFiPaonkX&NnbxLbWfZ-We6Rk>&)PO|Bu_8!gX zEt}zi_q?{(rlCSFb8!&2Fo$^gAKbTW#C+l1^P;%U&C1yf5i^n-zM{N^*=W+QfLFaP znVK=qTl@GGw}j7EtM8i_&%i2QAd|7I_|Wv0OpX{)TckvO(tPYHIxY#cEpK`w3d;W^ zcDtc%I3rRLfN;5XsjL3b2Qv)P_E-NAVIUAd+mkvPr~2o(^C`L}4Vk*oll4$1O9PZB z0SGrbzYJCtq0DImE*(~Ds8^iV^6- zqpz>d#C%XrAEwi1Cxq-qh<9mY#=W*etk25TS#{4OHF!gPeTKMVm*U-3!1dkL)SV&d zQX(lT!k&Tjgh>siR0?4PDqC|w55&iZ^Ua?bc)~MTO+&?OaO(+y zw5?~-QyX8!-|NXsN9Soy?InF9pWUUVJfsBFB``vWd_MBbH4_J15Mmn+1-qkB=e_U$ zVQ(JGJ6cbwA{Iv&LQPI>dRZWv8uLd82=ZOhV7wV|bl21U23OSmZ=C9*+(J&7hw#3b zU`tkXT^+sw*|<=n@B1zVNUKRx>n9f*BOH@;UYcvv34LBO3F9j~fir^sp9`%pmZPjh zQwz;LXYK@8SSNj)&RVa2#mDN9Uqv#`-pdyHG2Y@htg#UJPCiYWel*w8X%|o=2w{GoJ#=o}d3_@wHwo=NF3Y%`;dzQl~g3eI&P^|dlIDFaP#bK9LJofxCQ-nz#Rg1xjtRUm=H#Dd1}ZPa|) z^6U&Vq}M$H-4azeP&}nOUvXoI52^E#-wa1BDknzpYT%d+!E)hcxf5HD1s&~;JqvTJ z-ev!fIVCV-v{}ged?U8FDi$ud*GOZcvu7!*be(mVJ?pR#)wxI4iqTc%_q9Dv;TmIF zz{I`LBSD_QP|Z^!{;VuJfhM~p_8vwEghv8KBi@2i))+`EBHbb9O*!$Nh(6 zPcRkeY%v6Wk>~ukObnJl!7fji3=Z3I%K|>o8#hFnFtoOBMp2ffRvA<}QxQqr48NA( z?7%+41ENT8T*;60FU%UD>ApV9*`53xkU^eMRY=;U1Hm);j>J9&iNG8y`FHrV(TOgD zOC`QopPK*1JBb*c3eAunrfB4bq!E9= zFO9}2MJ1WsTxs9gN>TVw3P1(Ik_59MIi&^bE~0m!K)EqaH4STA7lRjO_7lvIKn^A*Va$!6h)pUUb?@Um!?8i<^O*)opo4L-TU>29;F4Oq(MSb zO1eQ%T9ANef?LYd zWHVev^}xe$PF(#=h--w{;w3R5B%=r3E62RL*NX01yaDaz9K^tI|1862J7Sb3MAvnY zSr^jcmltq(F1PM?^pV#mAN>`Z>PS>z0Hwn_vt5-XCy$$nxolg zE|MWapsXVbLBIW!4GTsj}K$PSdG%%>8GjW?<7g)YiZtX81a{3}3^|c@E zPt-p7f!(mmnc^!SI4-V~->`Pn<1uPRYaMQPdsuWZn)Ed5gXk9MCGA-IvCx0Ey=m3+ zl`X(^*D7BH-QQ3WS{^VcmB+l3<~TiXBwRMPcJPKD&%38#L4bj+u&}`i5W8%n%qDlv zS?kSKe1&_zJ(-;3R^zTES}kq8?TR4I8{dv+)sBl!oq_2c-uY8&DyI%@+}(A6lJ2*p z41(xJx))GUAt513N0FFv5ox3}HMysa3O49f3~JX=Ff+&FTPBCFk2g;S4(v_g0*bNw$zdHUZ1% zE8daUuCq$!F&^H^V(aK`g#R` zYE(rhgz!5=u2Ox`r=w*IxVZsRaQ3`$b9zF zjN$DwXtJ~yxBfm`Jl&hT5&Z!f;tQ#LdpwHA8;yI*g5jD?CEtUz|D*MulLTk;gQI(vXZFx*$Dd=C412|lGcwdF?VB`LMfe2$w<$MNv z7F}GtF!wND8SUXVITYeEvmQ6OX?Avq*Qt8>Sc3ff75=_r`tr}+p$=<;&pY}TgjD}r zn(Gf|oTvAJk(6Ie4MD$4YEzHHv1xj@c`eOyg45a3D;Meq)0=e(NLcNIVMT9F@w;V2{oi8UWJMrrP9M-%S zwg>_Qp#PgzH$0xRYW+RGS-L$sP0&l3-*XVT{JVP!yT*k8g|u(Jh3nGg7KlQ7*Wc%J zv(CE_;d2r6(bepLg_X5=Jrd0U`=6?~i4+0y=>DTqwoNe2^UrPXrH8y0$ z+SVI<;PRlqmyMz6c*>){@0HxUe%prigF~OIysD}jct}Riz?`;@s>dnWB_aNEyQMm# z&MuqmLL`XX69}_z)5*sC;c`OS?e5{bil&1)DcHkLAE3B!72I<1xHwq!xfP0vjgPmR ztJ*`QL>Lb0*gGzzmzuiUU&LSJ$T=s9HCP?@S~b; za#FeTa9SMoEZe?vU%5@Y^lGL7MWChhSqbOO!)Ax@XR+sMInK7dbVbZk85W22|H5km zQGY#IU<&*Yft-BS`7D4DQ9g%=>ZvIA7#k~ta8Kzo#2PwfDSRmaW_^S+!Qgkh4{#vB zsA#G4ny*{=PAR%_dLtc4KIfZ&1=O zysf@iGg(M*yR`%rChREco{L12i(8C4$K-evCH{mTFQ&J_eK1(3xmv??r08I18wvm% zL+mqx8(@)2%5VJZA}tBs`+C3q_JC-lf;S9RaI#e(M!z#%TRlS~(K)jLzVmajoIF}| z^}};@&;q8GBd1KJyzk=U)w#{sd_a>?S>0)n;N{}lKD()`RDuyI#}fpOj;)uZ=#)n- z`;bA5rLH$_Q9cef{Wj!ScAJlYX5ktQQ}7tC%#6~6hRV5^>|a0(~$7zw9tMQB>4;nhY&u3q=E(Ncy zYKk^BnN1-xRW@eM^84ABZID2h!`W`ho@LK-6c-@}2W;zwn)A65)0cF=2b(9ot@Q*2xKf2|f47tc1?6cmmF zH>S`d^Ih((fE^)l>|;>387hfetpJB z<$kt9OoXqYszXMA=RDNl7##INj1f@Cj0VWB*7CP9Bxl59v5ZP|Ye>$|b=~B3*Y3(t z)B|iqJy*ivKiTNdId`=D(Lacz1db5@9Ts^J)y-+q(ctan;rKtb*$^?Rc+a{EAwGd( zI+rcj^(YEo{9+4(Ki+P7h9b()EJSIFvf&5!#dbf7)nT0(8GMeB7@C_taJRhxAJu1f z@@7w_`Bly&=jA0~?Szk)WTb+49TWu4#8?%zarg5rICx?Q2g|p%-Ufaj-V6&5{|ZH? z3j`#n%!%F8Jm+@?Bwqj%&)NB0x8gAG-Jdfs4!6x@H5$if-;FYt+0>~X}yrG-!m zD=MNQzNxFJ;ROE6y{a2WQd3Uy&6wVehz!NRzyR`p=I0rT4m`~_7lZl+0msK2>jY}a z`BGBBuR<;B=l`x5?(C9&8Z7_g!@S&L(yV}wBOVbUPn30`^*wcMlt(0E_L^1p9_$Z3 zw>H;|;u&&(8WXA0Zs6vaVJeXX63i)v5u>-qRLBDFNPzL8zrqv+KL`%TYo**af0$f4|UoJr%9^L>!pgE>_@o z1^iR2=g26ipc{crk%0gJOrwF~&2RonN*@L!|Mb0LLc`v={rk$UDEVgR-7r8>ww<=g z*r0?A5GA6C7aKf7E>nM}EK4k~o*i8!hIDgN5l#v1VLMzdcJc)w=Iw?Y z3GsDz>TYh6Gmg@Tc?xFL3iOXvRl-NWflOq{A4QS@@46<@Z`p-dAv*CeLs-W(z0y;X zJUGzgD=fpAHoJfa0F3H!+$H(TiBfTlN713OOhv9>F1yLxf5#v&@y-05*y4Ctl?&k% zl2WF5Z(C>x{dpoECHWlLToi;$uCp{a%tHqFZ-I(NoItG;acH6bc05Wr&V-Ma%V{k3 z)Nsv^P8Jao(>t+e9X!M``&N6`Jn=l+_q&|AsYPdTOR-kMr?oR6MsIL8k6xU~&cB|{ zyk~vAtI8dLc1nQ+kN%r#pd>eFIu1l$KAJVgT~ewaD`xFSgJS%qJA`n-3N$O(blGe9 zu+ZvA*mc&|ahwMFXq>_-FoHh8+WRN=6j;3lFM7OO7j7#^?OH2*VBt46UGm!X!)-e! zkKmL)AD2NjV|D!bCye!GO+-4A^kW_{20VK+oJ8)Af!2xR!UB zg2NinVZ@=V3C&5GgUhAybP7jx7{#fQeh%k`}%NFO~vEqpgNR6jUHiGZ(f4Yp{9TFN`gR%|$`@i%iRp=>8G?({c97|Hc0XYTq_lajYD!|1=U_eMU zV(Z8A?U{~Ul?=32yUhB}(TB@*E)?!JLIq9OFNS_RsZ5G&(20-Z-sMRbu;qg64foDh z^WH{pkAK1P5y*nXDn*Rl`pjH!RA>@nDc&5%D_eg{Ks(J~CP}%zi|XY2^TG!Cnv`9K zRqRd8UQE8l04OkuT8)`Ldd40sikhq zkpk+4=tLYe2ypSkn6>^Mf02nKN*%41ag$m<7-$7djcG2M7rdP2<%u8>&MogwoEI|@1r?AuoCBM)goJ+#9$t&^GT`R``evo$bUL^_T@>Z7;RQj2 zsUkny+6>lr&H*vuvx~yPl#PH421Rsq?9tkA*igzFR(_`Fv}q8o`I_@G;)O7muFO=N0aR;9C3x#9%WVdpSzc1I z+~V8~WCbW<8g+*9^%MVqvPtOnqC%&B)S5>z?KQ_61{?@nqmJoN6b}AX(v;-a?&m-f ztP8W)4VIr5_eUHe<`!h+@Lu0>R{nvz*qrC|(_id=yd9VKe6r8A{pOIHHm94O?=dav z%;|G~wOGBVfy!tFE|wA+M|?5CR2Y%Y!*@a~cDgCQH(qOtM$N_3kP^z&A48}3B;;hR z)u1@aC2!?&>;Ga(!a;nN;&s=IKKt9~-0^KtKDk)Wr?;{-m)8y1)+?q8TCA-26&HhT zPW*m$&r?fG#VuqlHcBA2pnMu#Ofc09oI!`IR`!W!#G?pcJpzWi+sMceOnKW@T{E8T z=ArRfRCyYI-|6z@yM=dlceg)0LNJL}*b+t*hN7R4f+0fzTX$l4D||{YM>!)g@o3N$ z-L~1}!q?mD4pqeCTHLBgSFT7JiulGjGS7k}j}qGODq>{c4`?wr$^_Uq#|LF`FHq_u z4~WFcbda@Lst!Rj|9ym!9_Ei@&n`C#yOte8dL+U)Yu6T@-kQAE6WqS3eQRqkIZ1GkOoR`DE9%8f=w@ov}*Inh(ddoxya$ zR}(bEcTgP4@SgxLN>|iSNXx~=MLBZZ{Wnj<>%pjEiFi}b-rN2WoOyQb$ui_~;gNRo z;ffpU%wK=RK#eX>eyJ9%7~M*TBnT2?3j`DfvQ$k|V@@P_L`tpWHXJ(E>ODZMgHM=nzB|90{EF@Lp@|ZTgm!ViS`d`B z>|+{J6CWu-WHitht=ea(H~Y1BuF~KK3M~zQbm1bm>$4MKy33DiAWdnELm>pneo9YS z42D-KeD|?E^=~aaIJo7F#y>xDh_$Lr2apaApHRiBj9t8XD95^HnWCF=t6LbBbHo+L z>0oa7CGrYjjLON6WACw$9#-$VPbfzB&l_7+zAtvnRdW7C2=4anWqAe)G2}o8f{533fHMqKkcy|q4LeJ%%Lfe7Xk=vc!T~A-HPTfbNFcrn|kRFK+*z)Yv@B6_@Ic3FP-JIS}riZ z^nvA6wrr;twX%}p6yP4rgVlU0jRcec`E1C8!sq7CG$>1JBFQy7<@E;64I0g#QTt zUPqb|UTYGU%g2WWdLz3|2|BO$%UneqJOt~PxKfg4W1l7Tdlk#|UU6b!&CaM>mfMg% zprt9GK{(lxT(-}O{3);MIlnc>cgc_lN7ki#_lRLz^tPRl4*UB;?B^>YueALs>)v7@ zijs0?NW{bMuXw?Ci~Ug6IgDb&9GVS#XI5~UDa209XX=KCh>(R^(bAlI3=D!+SRWMM zhV9`Rzzmf?Tq?Zi*=*9YQ4#AQ4S;a#g7(g|l#B~I>y2dq{XOxa&Ua)XA6<4BCT z%>)0&Pg3l_vUDp54bFDiVCA}Pa+4FlJ&@+J`av1rL<;NaQ*kIa&zspbKmPvQ2`CCA z&DWW_is<*r%fraO!D)2ouszn4WL%+_EY@$u?JNJR*bPWt4T%$Cm9S^mmRdVRLLTR= z7>HdE`{N++5`gEve5`vdlk0454~~sw5FpyVXucGLch4zEg))68#EYbB9@_3q9aO}< zB-VEAhCNo&=egwZq>cfA5{!~AhqjJ8`t5`-eWHNdw7Pwx0H47>A>Nd2QIo&w<3C5j zi_s@nUzNiiXXys**~|A0-&Nd?Yff8rpI^)3cE~GiM(QL{40*8#3UUcDpZWO+HHy$5 zuC!-Y2&Y-qY5~Gj%){m1!D+q0QnA3wK~o#XcCUO)E_zzAD7Y^2g$RV|2%W)cC`*xD z_v-z6#2yH(p;5Gi6F0`!T2;-p3_wKul)=j z9v*dhDlr*eT55DS&V_p`)YqH@W#pTojCM?+{DQD}kHM6IFDkG>tlwCxBIE&+2r)i| z)B5+{wgx;8cB4EWZ@;&S_2bQ(P-s4oPU^EmiI7|mDNulmYKT2t8p(Hcu#rNS|K6X% z2fnZG?U!E!q<|fQcc4jn!^lX*z`)MP(Ux7+RM!UI_7OpbI39Qw7WHo1&mic}U+7#e z3emIjn0Drud4T1{NCkFYja9QDSLVr^B zkHweGqhW1rR%+5|s&T9RBHMP5S&1Q#q7&BK4s;X@>Uk=rkri9F$z@TJ*C)42EQP@C za2s2TW(6#78*04$#XuPFX@Qq#%wX(^hDxnv0a~pbN=|3-zw3G^kwGB9;979j!qRoE zjL5`ko(0Izq2K}Vk&%`cppnNrUT)&!U|D#RBT04=GySTz1OQ^L zWfQzyo@Ptt`2%ZWgLVW*XiwS9SaXqpZb!d zqn(LKEp0zZmaZ&cH3l3{e4I;GoeR^o>)R6myI1E{oAivHs|^TIlka?;y$}77&%*j;1brr#a51{+T7PgV`zFyg-l3-i(R8Hgdag0BU@&w zE^f*!UZCVt+XB<{%*4p-nhMR{%Mi803nqp7e-$P(>1i~9AJmWnwWgVxNl2(r1H^Ka zRMgvyKB~*9c&ZrQ&EnK}WIrB)!D5WH8UukNx8!ao7ktxYpK67v1@Y&8GYf&fu5^77 zThMz&-%q!|=-3;$vEO&8V^RfjJVO?XHV*rP0U^o~P01TqK&Vfi+xz2{jWqLw0tZ5K zmSN|z$5Pnp@cy#7EAN{-M^EPA%38Y4QF{)vsVn>KYoGb;M3F?$U!KM1;1y#Wh}dgmT;+k`xG3 zpdC1IIF$>17k~e=)DG5`pzEEq_`>57>5^KRf($ns7~#3(WX(3-sK`A*K?Zedt1qrN z(3%dS?Z9U~Dg!92i^ks??_;p=y^kc0Pzsy9;@v0;#0itN+e&;IR`3D@RAqLuJ6w&B zn(4LkxU9PFEe8WNB`3zk-?sDkSt+SJfEheJIgyZ%m~nXuiG7?(f{sp$*|cDMmOf{* zn41)9gAi2G-~4gNpEtSO=dVhjNl6FWE!6CTU7DQSyYcZRu}#x;szCgRi6+_r1?cNt zfg5`gFlD)%CcAQ171I(wdN+Qn6~V6?@~GNIM!1bzo$KfdIIFR(XuW!G6r)-&Ubb9G zN5?m6P*3>$`7UN~;ub^9C>WUMak4dT%^wmXqoHAv&nT+a5|m7*@fs{B!tUauQ+q6( zIJ3UUMko4|^*5*U&xV=*Osq(D?giRnulsxd`VH?lMpHmlF7Qx3Z}&nllLEclmq*-- zK8SVurO*raSWXZ|1i^q(>DQS{#klVK?dZ8AB%MEcO8mpO(tlj&mxw*8Efg|3CLoFU zdLAtUv53!)LhscS;mMk6o2DVXOjhO>y_bq_d7)QCE{!lI&aN5TdN)B`Wqfq>r%M?E z!@I|l(lY0Jvz$4Kc535a^MUM`n{N>$tz>ZeP!l8h;K}(RnvA)-#1t0hc5LgoW||t9 zYS!C(eENPJKg#H3gova?ACg;Z=OYn1%r-U=L@_sY1l4E!2p=O&l>l<2O^krS_`W_S zFeEr$8;0?4@#N*xUhXf(;B?4Afqv4`$7&p~_zvKq0^!|6%yo3`dNzMSXJWQNs$-R?l^gLd( zx78WVlKl2_!kP`Ly${6eem#5!g{P zidlM?i;5l5QGbB0mrL!|&2<*&v~IdlZ$9jsyN+A)Ac38wZN0~;zaAskEOlPXDMt9Sx0)50efm;Ewuzj0v(rd*&a$r<^!&7+qT}4~Y_%|XWW&E4VZJ~`+ zTxP`6@A3OT{|OAvnJJu)p$?Z~Pgl%qBVa83kh-=>KgRbPH^_3PxD=aU}cc0?9}PnbliFpWDQ2H&qrY1+y6+xY|8{5V1JUWi+gg0535r<~b>RXeb#fjqWQYPvurEqivo z=zliab?WLL;$vgOL1I*uCMRHJ_8O0EJKHAA>va%QDSarL-A8xO5a4P~>!sXce~w~2 z6?SfG(b=Bt6*m-(swiK5*!Ji%lX&qm4aV_HHs$H-Wmn)Wu$~~r)Zxs{m{ifMaxyt+ z?-1DhRA4dp&HZW^OkN(>rh>+r93*FW!(=JIE(<1Z@WaNBU8$TM4{aBU1v6--rnhUg zSoc5iHg#kDrMEXanw;IZX9|siEYxI^V6|IQjw%sloV2Fb)d%-6gu>%flUyeg*`;bp zd7GHwDV$6}N_`QJes`YE=Wb!|`atfK2!N{ro6Y>lgww&yXa$TD8&2Y|P?s7vXlg<+ zkRR`iEM7Qj{`PAW%7eYc*IDb(sIYU2p!;1H58W@v=UrVd&Gp`LZc2(lpCD3$Y&@Ed z&UCLGz3ESGvoaZj5C2XpVE3z=+O{nl`MVX7k>QYoeLIR8=PtzeFR=Vdf21;PW)Gai zHL5XYO~F5ltu@R1M$WGAsh*9vj>b<%XuB}sJNNJwZ>MP zCfg43-Wd}RnzIBs?_y2-Mops6ml-0Dk9}rp#YZUDE8FC2Mh}!mAb;&*@GS+O=i6!B z5BKx`zp&9_F)+vi^OWxLfeM(O{cO0!h^g4@gX@s6{#nBMhJ?>vX#!#vi}!gpf4%4v zUr{KEle7i_@s=nSOIir`pB=W12 zUPs6)7My!?Pe!>QnW6y!h$JK_T>Bah3vdng2ej}X%kFl>w(PdEf3eqPpb*ggMNI46 z@9aAImB>2N5>escpf7L5C*O5&M(F{=kmK^h6|i%7pUh4>+BRJT&wi|WfoXd|fjMXC zM&j&0n~CPDlZOekgRlEZ0$760L#~&OE$-J4aed_qb8%$HwK|3rmuhM8nWaC=NlyS9 z!^;F@>UWbrvFpK*rLD`d*R8z$ zS=Mh@t4%;BDaG+LM;8@UyC=yUANzPD4x;bP#Q^}dvn@N_tFgg&E@2fdd=DuZ*3bJkeicxyyyWUSjFg5xfkG%tvMsbfD=P=okJ8Lz{5|iCc@|Liyp^HoH;q*d zpg7^ydgCBwk1`77EJok#Uu0+VM@>?O?~;G+{F+-yDL~154Q}wubu+!G>FJMZABg3H z0q_lAdbxRiRpC263lNs*=*r9Hpdlb@I4LR&_I$-Us=s`9 z{5b(G>xTn2zXPUV*HF;JR=@}xAgLVeuu$6m!@*mp(Z6|{@E2|+?b2M0Td12j8(fA|Kyy<%*5`^8<77lggiXSqVe!|qRUtsU(0 zI7dn#qXnp{ta_Oqv!q&$cuii_^AK8+Lf;LAZ+vX7vKK%gDDm%)yb=dnID~KNWZpu^f{485&D>I zJZ=8K*?0+hblpB>B+3l$x+&CGruJ>LuFLbp*xHLLRsMlMR5`Qh)aF$*>iJ-{qvyE0`h&(wl z5@*CEzWnh)s1B&z<-sJLcx-iGHh?(5FIpCxYrMC(;%i;u z{AOogLCDHQm%yoQ>uI~|((P6It!>8Z?N5MgF}Gp;vturP%IF}FslZOpWZc6FtAWm0 z)N`b8qOt_7>t&#pS}2`bj3)_1KaijP?RUP-q-5n!nS2~Gf1P+L^ja;K)4gsl_G$%Q zc%hx)RodSV@oeH{`Q&z^;Dn?(Y5{I?Qy&R_cPw zFb~fvngv_q{RdeKAq9_2kkVut&T)A}fmuE_%;&|I-F339_dD^=5sj}_!r5%TKR~G@ zeB@utK|09z?y|lDjve^X#a@6>2l?DK;y&i9Q>bZ-q)T)|;O#L4r_V(s z)*iX7DzwPf-axE|=-j7yhS=s>q{gSO>x}Tsy;Y$=RgD1MzqojCaPYzF0wrpm^UKW2 z%uHep`S$ASs&Ga{nc(U2*3z=-wi%%kn_-MXCVn!V$t=9jCg|K&Q$EZ-H(1l78#!NQ zMwLieeN@|*y;Tp8E7I|#{5F*ij|W8Vq#C3SO$p~gMot_a+_gsm z8jt6ognc%=K7D%ahpZ65ut9!V+iirJ!LRFFge@>H`@UjEE+dbOS~e)cV}ETBhl3W!(nrUt1GO z|B-(>uU`=m#1-{^+qPKLbwA(lCU4{AqP;45XmJ{g>Of^iFiM&1_|T}ZNmXh^1D&xO z8&2f_i92hs`{M-HrTeX_SGFzsJHI__VJ0&pe(#q-Oq^4hJ;q(~r?}#8 z3&xWMgHKL&f`e}e-X(;Dgy_97N-k!Id8zo~jrsRp>ne@+Qk>^4se1qU^gyz(O<#eYx4`AS;!p~Y`*J>zyFrqr}GAW zhOj_#M34#&N#p&zP`~_e_e-dL`}_u|y(?NgQJoSyNA@r5VdL8Yl+b~nFK|{GZZA^G z!62m>OiA@rz-#$!)0#fxVndjfMcT*81XEI3W& zAHtH!v4y?4m|A*TE?R2p1#6!5z2WV+QLB|qu^*#9UgpTE(B^ldrDe;A@GMg(=ok17vm6XgDTnRE65S1ubVJ>Y&l3i!zyV>`Usi>dXh)XjKHd}i zPw7<+D_Ux}5kk=OMZ$-JPti@Q`h#`ZHx+gG2P{^KNIf|_Xk5Z{z6WL`psoU;fI_4;na@WZG{!5p;ZxT zW{cFMAJ-^S6h1r;F~hJ6*)gDJ1s#UfnxPRybiAfccYT~QB!h0CTGrp^H=D)LfKJTj z2_vhlJZAOz*UHP#rx;aRUZMHsl5}V0Vi-^{qw4O$X;RE4yw}+bD6La=iq9JT>$^kSrh^@jZ^;kRBC$Ac6<=w^pKH@UnGf9w z+tC5!Y8_z$BS9uUV`I5Q#xChcl#;#j8bkmv+(qw+h%jDJPw_4ni)opZ<0!av*Sqz%*m%qIPx7!o5u z#gdDtyyY$$1$MXL)C4+80DwYFqg&|E4=C{k^3KgxZZv>VShGGmQ^afrr@;38U#+Ri zETn0#`z%*{Q5t+Cj8JT0*vGS|6m-$d_bg^pD}m4uT>dK3M~h!qJAMZc4gps^>|sZu zdR7ve7rS%D|7MmzclKwC?lLy|^QJ}Ht+QGS1rJd$Mlh6W0Yl=}>zz~Hx&2l&um%g=(Gx!AKws4z&OpvsR=t92Ds67Uzq8qi5xSn2S21| zF)^rESoCQ^xZDkDAPF+(%-EBO%Wd}Ibt3&AWgXa<7 zv&6+|yQZq4zlH*D1o8!GzytI7`hwr=Vu z+kmZxYvxpDC*fs8tJiy8VP?SMAHoHObT8n9X&~}OB?ZjW@qI_zFE>eqekEraT{ZD; zIS{TKvl}ytmRk4yE?1GOvt>ot(Q$#^qU&WD&<*rhzkZ@I!nKWmQ7s?p-CtdEA$mSv zaM``tvI?4x~dAAxlyvEjzyK z-g6PPm5Paf?x516Rpqd)m7}Y;cxw7VDx?ArE;@sPk77I;_Cf8O$d08{J54)HZE50F z9|tqzzx3n9PHUFv+hS>U>8pPhFYNgd>*qPITO83Y4*P+D81?aBBq}u?3Zd|6G~Nyc zSt}Ox^A4w2x<2~^WIuDepBLbYd(V14Z?An_u#+cJ<+H4VNTerJH@~-ymkoPKfH};apZEPjU$fdyBMe(ukb7jRz*^jTJa%Vy}WdZ_0g6f~N~p#j&9AGg`F@1QCl00l|T`4Mv3 zpS0IUZ9iX@cE~`K@|E<1VGa>pIqW<-k2f)ps3!KD+`ZZ!;G5sPW>T%F@MEI1*QyU}Wcj{0$l7OnoJW7m zAH_ybLG7=Xfl>13J>wq@b93{(k&iE9h&NLiE2Sv%(Oi~(f8LaTaR~@6Z1;!zq#FwY zfYkzyOqY%DB7fhc4Lz9(oZ1hFmR`aM2PK8p7dDKAc4vLWqmc&Rs1`f+5v4<=X@lMt z2W!1#POE+2|K4tmdFTx{p;2|0nlp(o52fyR zpeHp2ArkGScut7wTva?3O*Ny{^&PU%!LJVQ56Du;4K8`7pnn=pSt&P-#h?b7e5o`b z{Sw#)@+J&_D#L!>C+GZuKf>3P}N0ea-Eo0P*V2RJW)vfLKpc51}Q zh!@$b9J1wQD)-%;5>d%gnaHP~qgkr2s{szCG9#eOg};N=%|!4gU$}U2-zF+N&z{H_6;UgK^iJf>_Xw3EVr+=*u@t;5U;XjNhPcwAyid(l5 zfs;;;{PTQRV}sqDq-6h%K7AS9d|9p?f^~q+!sqqCRhX!m4!;wFm|rlWbN0h#QZsO@ zofDmMR69}spdtCnea(Fqr<8@nyaoO*qhZcGz(mIhA? zb|OoUAq1i}Zke)R;8$19A0^-JagGU|nJB8yb554KrvV1x&F?U8$2 zn1b&4^F|&auDD!3(^B&7D#)$o+paF8^LQ0+9B&ZNgQ}`I+JW@GLXSs(!oXD^mJ1$NX^wWkkw6;cf4s`2h!o-@e2h2n1}|m&S&Fw7pJjEt z?l&+~Leawr)EbRVnUx^E=mmPl>?9!med3>WE3~wNY)Mv}5we9`N?6ITaw2{B*Nb2_ zPM%y=9$KfcO~762TBEo0HP7JLQ|gW4TOEW9MR2d^Eei`NPRxmTJihBjF_9ru)HL#Vs%tSHBc>N<0sqKAcH<&84DN#P{AjNPd=>G#**Vk*kNp(N z=A1YKUEmGDidQJeG_Y{iowU2&uhTcbnDi{p=zoG&Aq zo1;a2DgE@@{hJ*>Z>}DE`oyO2eT9_@=kISX0N^TXF4?(PZti?GyBA!#;d!Xz9W$Gr zzH^YY%}-ZQXv!Y0+&v*AiAbZ?0oQ-4_Fj_Ow4}DJy!?yI=g~;AW$*j_RHMxo-v3sC z&A+XUN{B{2-lQ|LDc||1Ko$xAPk7+G;y+cZv1y0}Vx9D*2bP@-->H zyJEQ`(`Ohv{cM%&C}Z(Aw|b&obCJy6K1?se8*JC6Ua<;m*C^InV57Ya!gRk9HPPBw)l=E%*JG+i0#wMn4PbFv zPwB(^?WKq2>-FiS zoAv2G(aaehQx-C093|KGN6+9slvaFuj07Irwe%#t&w~C7XHl8 zd%yf$lHTjE$WjFhD$n&mf^WpZc1YH=u7e21Qe;ckE9rFxVJe4!t6)X5jmt;ry z>1)@kKg)IH49=JDoK2brTq%TV?2=Wf-{g)~D)4PWjq-BTgN;((AsYnNHPVp2LXzEB zIqEoL6NaD7+ri9YHwgYwIBb=2taSx;;%Y{}=H$fa{JMi1_;Z2k{`%)PY_LsZnt$)$ zMX9xl39BysA|K=`sUU6;eSfXmeIf76<@03ebgw^B>%-6SuK~N2lHby(F9M8sQl3x= zHSnG5`TJ5haR%a448tr=rvS_CXG_)2O49g2%dMEQvP6VtERnc5oG(EknSJ9g`jGc# zYm|DqZvUGUKCiygfCAEBs{Rv@*}3F-itRb|j=nlMgv(j@Q#D}bb3e`ek33ds+Id8$oM z0!3`V@mt!tfRfxMu>f58AH8s0`udVzL{eN5;}&m<|3oTdpl&ZVAARdS5IJZtO7}Ug zy(M=)0oJD0yXezRa>ox2hMy!kxpWd}4b@2TFxXfuue?>A^LSr`?-aR=A9uc{CwN)% zIxBqoiv%LElj$TTt3-)Mt;Z0$7E|6WL+iAwf|SL-?}9a?y)s3lPj1>i<}X06S(M>T0` zPuA;8M>ERZM(s-cJVk>Y^V+mJkx?dv#|CKD#(oE0wRySke<(qNB6^SP+Yb?FPH}n7 zhE5W#DT>_o*;eyDl+en8i}{20NA7P)`SCV5ynrynYvS-!&-L^{K7(IY**I`S;LwZf zFc1*UU$~5}PutAve$e<{?>G>JE8b(Fu0R~)+Lzc8Df|`!f$U35n)5EX3}?Li_;15l zrgrNdTj+E5;Iu-LVB7U~kI$;?N$)k2h$p#gUwtei7PhV=%2Q;#6(A51Co<|}hqh;{ zmVG8CHiA;I6x*;dl^2&!Vg|g<-IgB4yy7tV#EUWXRxz9z$FgN4Rwe)2!KLZK5@nb9 zSm5Wi?g;onb4I$yah#QH!+p}Gp3v^V_(mE#FZU=hR9gL@@HVHxKwGDBjBu1ez~+Ok zZZ^wO{!VOAwmYT2l4{{H0^X05DrKBw?DzJXZtK}5Rv%e3)2**gpS&7>fA~QDEwZn9 zt&muO;Pwt-;S1Ir%szq+zhvD&szY50%c3j(W@YZ_a9xwWyl8dtm{bfpdh~v;OSPCL zWi|@mUmHHZ*w1|p`J_Vs$J1YjMfr8@<1imZ7?c)aD3K27ZUpI+?(P=pRzhk3X=!N~ zx*Md19=c0F7`i)#-{t*&pZ7TYANQVp?Y-8y&fvr+WQQekWRmE}w9&t%w;ZvP&d6)I zyK`KM0$T#U*n0;O?ozlX!A@xA4)uDKFj#5`aQv4z3;KycMf zlzlc@o1A;=!alV6vJ$rRTd`Jg*MBjZk#oM{R+w%>$urIycSL4H)SsPp36X0 z7$KeK6x&$!uCnSK@Lw4b-Tu1}MxK$C9EmHhpnw%d9Va`z{)wTWr2?^Sh5nH;h9AaH z5^bi~n+4Fel||R*O(a`Sx1YEMb=((yKF_@_n6_*jBIiMESC`}#A)7I{TCol{fiOn8 z^_p^7QSfdqIS2Y}L=;i9Tz5EkJeXZAzqa4-eX8o=%VT$v&z?42z;Myx$yLwf%={u3 zXJFUACM19VAdHIhA4zcjNWO`!LUVi&!P4lG& z9n}Vxr!?$lkuK40aO#rL5(m`5Jq{g+(i>cHNO)Z+pP;cndqKU5deglt8xlTK+Y5x_V=8vQ^fA7 zta0t$=EIX{WN!F63h49`13|jyZ1DTMQET06jf`U?t`yqMd|w}k-0#FqU2Bar+sAcu zqcX5*W3Q|u7{K;&6RaWHn$3WI@iLS)!UQ5^Ea98Y$FZ*Zw@8~nN;Pp0y0@{hfwT5l zrG4D($|}r8h8;~TcBI{W9KV60t!l;V7^R>0TR z(0z}L+|Ubj({S1le^jd!bNi51%dyp=qlfbtwTt816fc7AoR+s3jFZSmJ3Gm3s3>4- zKiTmdHG^Q>G2CbJEU7fm@FHx=v~jTvnmaw$3T#^7Bw!M0E*Xz0PY)rl_jrWO*w3PU zjLVDVA3@-RfB*WKAtNoGdHbXkIywFaJ|fHZzVxRM0tDn}TBu-M0e73v&)(h}o_)S7 zw?*$f8LVqBzfR;;|NKNxw%>N&CkD5@L3)dSpWTK*g#xSa{tVyREg(De3wTnRGk$~k zvf_8&{^HhK+t8ycdd}$dH?_)B%?^&HCV=q*PS{QzN0DJ~pN_l7IvN(sJeU2j?Bq|r z;~q8l+~By4rp&%Rhw%(`80lBnj2P}m4b=GEm7L!hdo0#abRgIEE7x2ulU}Wn`p!06 zt7LHw%F8kk*eOP>RnY!581blEI&7(Mg*Q5`T+JYiwKO#JMyy&_MC}f2?7)dN7_a*S zbvh1W&9WhG-|@PC5;LzlDZA<|x+6UuY(;9BUI+D%Ji54E)@-1u=%`dd6JDy*T`TV< zE%SRPxWEPCK}V;%l;Y{!$m#APGaM6L(Q)_sT-Dq>6cpcZIZ1JiDrO4AL;7*ROVp1q zZkv0;uovBfD?4s;a%=X=Zs|KO_MU)`g|V>Mk5cHyAx_^QpJUvf^$CI#J?JY zj}FGT_wBa;K5HABo08+oj6L8!!qAdRXIXi9$YF-s-j!DZ{%a4PDZ$2TciG?) z6EdHhvIG?o%fH2OV1R`R!L=(=c|j*h)oa1~;^EtyVQm^3nv2~fA(9EgfU^=BIdlr2 z@IebT4b50-fo@LmPRG*L^(jpuCjW4gUjDd(seU2@-vN|#IjfB0H9dTpaE=%b&iC`J_y@@Me&eb+9zwhgzXv1fhDAks?v(AeiTeOJqe5m7M z&wpyB!9e+D*jiq}0Rx*iu3ZA!JNW46Vw218U%5@d0^A1P%siYyEzc++j0+<99{1zN z2ar*%M?3TT$NS6ejFiM=>--|7NHq-uJ?%As#*G`7O5E*VMM3`V8V9ANCOI9#0sCh1 zLv8zuAmCb#Fk$CL4D@)c)Oh=KsF%TS*1F!BXW%aTJwGMf=nXclTnrtw zU|LkI`_uE{xZ^TrZa>rYfa82DW34h!45q_CKo#h zJ|OBUUG0=oipoRN3RKSOT3LF+yr_l1u`hy*Qp6>UvyBojWpS=54~t0rUV9$?^$8ej zwgEms$8DEsj0rG4W5aqL>>n>lQ6GNoFRHtFw_OAck)kLlyH^~Y@bE+?` z7YfCfpplD|&LfPn1iG|^MHDPbJk?2l>Lsj+u%KHUp?SZ+NI zR7|E>#6E9jhiR3*fyKGY?U2W`92M^bqMeN^>$VHnQr`p0z1rI zDTPLE+DH#M-wPGYa0OzuanG7W0PA%mGW}RtX#ZklWMslz=K64LZYxqjPC+UoOd>kd zk3}PZJ6KmqEp5s{OY=K!(ZibG7bzxD2s`CK4pT;+e4v-5ZBHbAV@S0eCHkd8Iv4X2 zlo!o5SM^;o?tfor|74^qL30@rCGHMa{(bgb8{2%Q4`9IgWBBanL=Ox^T5_@4Sa0s~YDvHbCpSUY zE^_5jb#sn|r_;G|N#ig9SI|Yn=n2z0ceGa^a%Y-iMk?>O(S5#DsHEHW_0be!75zx$ z=w@@(=R6QlUOk{6w+G%47g2a_oZ8wlzKxAlv#VVILYo{7r7kRfD$+q*xbZcW_JDXW z!lxFm;ba!k?7*EWO3_L-5s{TQL@9|G4xgz1T<6VNzWfd5h`}7fg1rgxaBQ}rb$Moq zk^bg|`4JYWLX@3WQcD!cGf18~?61;M<_0^@cgnJot!HYd-~7!#Ed~=&-(0`@gT4aU z$sp#lnvNA?7#%w zdL1teE_s;TF|+lh>uZgZIHqw52nLn|hjF*m6(jGXHHQ_J{nZXOUnRtnWE^SaCwjGt zLGP&r7mL{6yoRWnw{!qL@UdW?vycR3KjZ@9t^ZB6=UTw^@+cH;a?P35<$vNOICcESwr!A?K0-Y>dhY?tuWtmGO&nbx)6ZSFeu|>QR_Jv-$~ne zniJ@S^01T_G4$}7H^V+H;52DYg+z>w5eRO^d_(Xz2FxpoIemO1@vN%!xrIC!#c;tW;fgW@ z@YisgIH<=>ei-lj@}@4Xd$oy=lS)Ik5 zCZlUsftpJyZ~Vec>1tvlxJ0LB4RZ{9K}40+s8`-n!FK090r>x`>*?9m>kbbOsbmH& zDmAVWV8}=Yib}}F({NS{>&sID=Huen;4$iIlJcs2sKm}PuW=eC;(8R2`->;f+ zmDKHqItFo^-F&ptqpi$XY7#DNK9i4n=Jkm zm1ICbNv*Gq33edPyX_O8D!ZnCx*sZ^GZwHh5u1j2EIrTfMiY7Xm5xV!+8;yWvT^Fl zY~^ZA1fWbG8ZUZOqLBC9rTUFT{wK2v`+N>iXazBkna%m~{Zm%4=TJ>XW>WQ+OXK1d zY22^=Jp$Hba;Tey3g`V-Ej(|s3i_~krC#n|gv2%HxxNN+F@O@JL%V%m1IM%3W2 zx>#hKJz?Ijn9iX@6<=XLXlwgQi=guqKzn!sigdH)-(N0Agtn`bxdpNn%^5gs1T*0! zD7p=$yy0^w#d^>@YAB8$&Cd`PbT+H}JAVang|K%ES#zWQS^JepM@vIPPw(y_-ogq3 z0K6Wswq|`!m;VTcH73b;Wloof`%uVjQPzBIs}k@T9T4TtwRauf9!FIgx-Y!Q4mfoO zr~(US$ek%R>1gl}@yGjnEvn%&adf5ItNZNhW2Y|BsB%mh_GESm;N?IKoY!b?UVH*> zW>bOFqUh1F=th|S%rEJWKAX!X9PrM}4UIU3dBiyvj6iB|a2VsoN2r|t{b=Ls*YVp~ z?ALiXgga>g-GlUt2Uq4X+54wnXgI_U)juV2!V8J%q>xS@Z$*sDOUqupOZ5t#@U&~Z zr(QIa!BVP8#k{J-KR#rd&90R1En_krEx!2vG_Ts2GuhZNB*esb8o%q0UeIb*%S?aErq<) zBJRucs$!3Kq+J$&Pja&TkxKXNhVO8LLew&2_*ly6d8CfEvc_vRz7qwk6ms!4YoA?+ zmLOD)EH!E2sNNh*RwA#{|0?#_%gS)%p(FhLsIDV{%Nn(X%dP`oxed+l zOA93a@_({4OSXil%6FqysidGAx0xSP8JUOKn6{*nqbedwqZlJ1p zdFd%;X=z!vwxr8U*YweyaFqZZ7h)NNCxIn?xP+CIjnuSK{mWf+8xkk*v3Bz%{^7Uc z94wFn1Jc)3^ncTQy zoi7=#Asn-eEY~3*1Q}%v?gj{tEOIY*bi+8M{8S8|(nae9wFVIcSygfuRlgM}=|e9x zmaf6iEF{VWCt2z^MeT0#AE)F;K%w3Lw^W!;e16?@qAYidqi@{O;eC?<_b?l*Th=L%mkuL7Gp3@0B}aT@nWMbi)^1>b$W8oi5L zG;wn;(T^MaF>(V`zxmB0;LYMLyJr)HgAc{#cXsGraSD`(BBZ&rzo} z;A%pK%xz!<0)eC-HvtX_!<^>K1TP~no}P5H!PGHL%yADiOU|Izy;8A)1)Iy#~nB-AMIC2D?5~N z<>loBj`BCpU!7grB;t01H#10EzMUNH3xFx%Kd9msf;ovC%UPgjlo6j`KSFTG6FC^T z;3kf5$HoMR5aVUIy78s{>owm`D1_`3R&wkD|NhL;MJOL7kw3*E{X#y4Wqv^MFyl?v zsxYWEo!SNxY!2%nw7CBcJdXJRQYH%%o!Z-b!MWpHT+@9tgBr&iB0hqHg7WLdhH|yj zBdql(unf2)2S#kQjRPnpcH;W8%PY!(37!6xGrVX%emBLy3try17WGac&=)_doifDC zzO$U=+ONIi?69Ek^~-oPtEP(dnNIcvapF0~SJKfRdToY!=3IzgP+K(UVSViX=>Wv+ zO~I6=tP~p;93Ye8!RX|I_f;1c+#F!ZsXAE7f#Wk_*no+HpZKIw7?z)}?I6sq(lCks zN?z1g!Zb+9(H|xn7a8kn!6nBU)s1Upyrr+D(P(11@Mok+u039bRQpG^#cR^q`Df_k z**Jpw>|nu%s$iLDFq+)`6)FHOkD5T9fBRKI>bdGcF0sRH*m?b}A3^2~c1s5igiR`V zEO70k3O0qH&FlzZy?PHSsLRi9%dI&Bl(By@8r6HMEA~Gp*wLFUi&BF5LhsU{{wl~O z0lQCdZ}Ire!XC8fQI)EaFYI<+JtWc~%4JjAzx;>|SK37%%oT=}Hp_|1DETP)hi_j; zozg|mf63A;m#Q37_JonNtY6-OU{Bz>HM=nEk)NM_EaOgEU=#_Ca8f27`YVY}NX6o5 zS(vHPz;3Que$Uwt&uXJ*5W34I?@zpzh72j;mfT2=iAn!7!4$dswf`&8!vn8{``^2ooNxVvDwIlLA_zt{)jUdDf8qM(?JnY}tQg;aJtNXW%IH6x+sDuSEIq!j^s z7ry4CbyNC{{Z2R*)k*7SIlij?C;OL4TSwzpEx(3y$+(zHjiK-1(49mXw?*7(*)X579S#<>iHf~h&WGoRD>7!PLQ z6HFMn6)hb41&#&VUA?=KDt3;i)ue-*$KQ>s=tEb;%PEU>3H*?MyH7(I=~8<=SWR`b zaokkR&J?Uua`*o?^r31Tr6tO^>;#O2>__i_{*&Bi0EpJD<_88eDC5uMOPJ*n)gaYQ zU>S0k`P8B@^Nh_aGUwx}B~$h%qy#a2^ui^(gpGZ9pp4;3`el;+`?g>%1M_oyL@e9q zv^S7XpSapg4ILIZh&QGF)p%~Z{ry6G&WnU6_klM@VUzKb=}w)xs)^>YVji>WwGJNX zay+3uriOD7lHg6+qfeoHWkdX86ImvF)eZd*V9Fr@9)A21^y|;PK*dtUY^q6B6Vf=1 zDo+v=NH}v?{0f+Ly%m1vXb^yJ`rS?quRGfCu4(fN8Opd8(4qA`>#Sz^`oCxUhgeEE z8K=!}DMV%j?w$Ri&&~Jez!4Ua2xlG+!IcP?1mU;$w!V&98&@uw@VqWs5e??RMhylm zUGw`l#m8qlE6q~Z%{H%|t8R|v-r;B`CN}o~3#WF)SALtR{lg)4kK3;5gN96Xn?JdR z1Y97+a>0Vv90I&jV+h~d(3H7Kedk79u9}*d4A;ayGY)`uWl^tNBK}+t#axknmhs-i zdvIUDk(A^z|xFr~T_IHhPi@ z2&E!-986mFhln2!>2#Cibh!4Os|Ho;q*PG9V$_1G10`lhz)WOP&L^+#d5QQ5 zS+A@`ap7b@k5|??~in!Y{@7@UX** z6f=M+aro76&*X~uZ=4pa367eZ*aD&~-|u~9yqG{R$C$`@-HU8LUSMO!9iRQX^{M7P z_zzTs-8|U|q{9{BX@+YG0ri%(wRNu5`?g5a@iBMbptQh->`^9*fM$v**{Bt~Nv+*BRKVnE4W03+K!MM5m zE&Yec&1>5CDdi`(esY6ydHpdBONR)9&x8mD{E6;$_QitcFltHm7?O*nXwA!2c2!Hb zKr(K(m#u0KRL#>VSLvhU(O+k#94~Z-_z0<_WlDd|5^K#=UthxyTsuG8da1)K{?g2w z(V9X$|6-t;h;d>9%4Zwi^mp!SSs#E+B!;Kv4uB|ilL22@E_q#RqL)B%c>PXp^1?RZ z>S8WaqUJr))m2=4wO-r!?BG@9!eRYk zxOB8T*Ldx1os9&5b&<21S^#Nl2I&b{1MRV1fqpQxBykExj9~=NR@PF1ww+q-!vpyTH+U&@@!<<#bi3j`oT;munoy(+e?M@%(&)(m z&Q0`L7Mv^b2Wb8A8RXSH;Z*yYc zvo2s&yIT_lZw54WSUo>1i2?f*7eR6}Kew^Bw9!v}8Qv}!>=IN02F>&j3u!g6TFC&c z_)7hNNM%2P+*+w7y<0GvBv&sMgOa>olgNa!zxbR_{Jv&{@Fj^O{Cj-BeRc#`33!zd=A8Ph4_At-`d_|2|!gCCA0-h_{v3p($%WXd^g9uYWE~6)D@jZQ;Yp zk*p4k9O%6DQBeGQbJ*PZ71-FT{L7^7oR4Uc=qiub1-9z+Op(mpH#Idc(?5T%J9un+`WvhBM`QV%%!@C!^55wAzn(F!#n4xY%z21{Z9E zu$D$H=W~dx#c87$vVM;(wpBcNt895WyvuH1SS#Ie=OpL5sfOm9YY52ckanA_gfnaP z{cc#aCh5>J$TWmMd4Td!-IsPzxT~s*-}cl?U;{#}NyVE`M$Da0eEgtW*RpJ`Uf>n= zWxHuv_xWTww3k0p(mj)NvIP}gb@UI2II&?{%|ji{GEF%+&PGK`=QE41F+=3H(*5o zKjD`lfL@8`_I1^FXZ~MhwDn?0p``X(=(nJ@CJx76<3pvE`op$^@-`) zp#mG#Js9`P=g#RKwESk~NIAOIs-ubMd4Ad1i^XIYIPq3u)mvf7G)}lcZXG=ZHS8ys zGGkuI#lZTJ#sk%70?hAwq|3ae_5XLsi6Pa+wz|yQncugf2A-lDSM_T>f)^ZOT$?Le z9)jjp{XBu*GjXCiPX;cq$37uY>or(|L&&Tc?)iAKSqb>2irUF{PnXD`0S{RzZW2Jl zdUAbq6lh)m`q>CCHOS=Hh$2pC_qwcxrl#Q>j}jZh02XR+1~mkb4fKWRlxu~2l8{(l z7LW>1B_o=NC_@Yzmv|$G^FXH#IX&`h%8rW0wN?bW z5LE&xN>ucZroFd@jcZ#Tt+lQ-)?}QnBTdyIKV)g3yLvRaj3MeU4P9hjKC*&sHgOK^ zha~RCD{a&j%EKIU84H*`=8ztRmkpZwoA}%`$wi4wPRLWtmAU%5^L@&xA7p%UvC z7t{AgEw{lhUU6|v0(QZ^=h50CHz!j>#sQ8Q!|s5q7l6qvDa{Gr>~O95Ke@_(IlG)Q z_)aoq36KV9+!Z~VStZL}_Q&n8J7-KIr?%57XEkrSTc{E&!fxl+7e3Z*5F^sMxQD3F z`ieiM%zjil&rq_etsvc>D>uG42mFanrn!_A(w4jUAdM>=k1FGYc40(LwnzN6C40`KK^C5CdrVpL4)K zbX_0#aWV1b?9!|TJu}t&1^Sf+-KXD*xM9`3FE;vbJR!XG`+@^S#>rpiTCm(yc*ux zNj^iuIc<_lF4V3*v_cM03-%)n+iK(kHtGEb#w1!fo>VXo4TMIb19>()Q#w1%L2sxzFy6pV$Mg)K^AOyD`1XOHSQdjG;B>iIH_^{;J z1okd3_f%g6B@>16PD7pzS*ke^IWK@J-=$fFeq3Dnk{1DGKx$;sJX%VYqdWZrDA zDIy5?!dsej<~x@1?%1?SL;1Es@y5`fb@gZ6RL&u!is^9@w#hi)#E9yF z+HO*$rR(VAMhli^$3(LnFs{CoccEEQ5+5DM+>Y8Q<}%Ze2w!n}g&MR~P_TS(Q!3ch z(B!d#5W9wtiC%9c7+~xBSn09j5(fW=XSX-`L=VB;G)R(D4vIFHOq=yGPfS(%q8#K% z!OG=)=QR+*UnZ|nBmzJ(>OKWoyu6*Bkt>Vb#QOOdU*A!nq>r`$7@$ln znA82t3#Nl(gkhNtR9imS9DI|AYh%~VL*=k`b}{GWpK-JWTYRfNjiy!Rv03HNJ*gDK z>yyXEY{BEE6yw1CgivEFlIhBKVnl=|z}}40wOak4RLaplP}%sl;?#O2ma^hK7hD`> zm9Rmk3bza*r(4J$aR0Wm;aCq0@Vh{7=!9TIG{%DE**(na@IC^6JkqCoq~9vH<*zMG z52;*Z36ME(gLSTTnk_^o4Qp5ESC441?n`W?MwYZ`9e5Z)IbT$4CT167wNselxp~Fb z2PjjouSuNZNne?>vqq%85ILp<`bb%^p$-|1q!kCwXdLHJ*?72mm0-gL*4Ur4?O#2H#+k@1>QaoFr{U}xO$M2=Zs z@5c~Mv_h)+$~xQgo0f@KPDA1y3jvfIt+18TKr6HQht&<0pfqUxkyk3)l}15_5?{uk z$N=K-)Q<* zzNgjg|4VO+wz99m0;%r0`Gq-GUW?v-X??oK**HU^!XPW?=nxRUB1ycq?*ePt(Ttea zXdWUm(%mysUpF+~-OW76Sf#%l$Vp9gncXHe53$^tL^mN4DmX{QfVPV|GDm-nn<&UxLK9&X>G@C>&UscwF|@$a8B+_sOek8Gs6lR9Kmg=kDO5^NM|O3@yD$wUy#X7!vf zT*^bZh)S+xut^hL zU^na4{Kkn&Q~+Z7K>@r!7=`!X3sl5e(u(PlN+L`^!_k#&b`ijwK+la1G;%i1*kSFh zYffhGdrToT0MZRGVmh5l9&OxhgJ!s!wTE^$&omPgHfmHMA4#ObHy`J+*D`c6kx}(; z*q%E4KNh5z2n*9AA0k45`LjUxPZQ}>8aaJF=Y=|#DdW;7D6~voPR@m&{JW`a4YwoA zO=;@+w-4MT;u#x-X;nJE9p>Vm`4$k56b>oZcH?m}aP1sj;|hI)N|q5amJySd`UN^v z-dox$MfM06cn&>aylBSY1;CQ>@i==zlyQF-I18bx=To$!Hn7CEHbut|6N@4wdbx-K@-7=ro2-*=lv8 zmD`@&Q?)=D^1WfVD@V<{l7DztqXlFl6_W{P)~+w_4wH;%UWbh1*fZ@(9Gs$Be^bDa z!qlbWO)u}rLCzTyVv&kooKRci5DSL5zKxJ%hXr(IQ(LJ1o$!{UC}*0&qLV|Vl;lYx zuqID_qq4gyv&k36k()t^W`AHYZ)+t!g;7@=ubWJT#sYm6(9z&0V>oUVPftLz;ywN2 z+b(PEB>$<7g$TjEKtllm3Rx4t^yFI$5DU1d0@_6hy^0k8Km`_#V)FF*Qdv_K4eOxO zbkl0Od@&7RI}fC=BTGjf`(y4L>ye9JlTFlPiIaJF+2oQyo5E%4Dmn=uaV7P;>%}tD zcv@YmaOzBs%m&kr=Q3pG4=aVxLro*yq+%~M)x9AFvAbe?ZO8ZJ#e#?jS0H>#;LF8M zPS5wjnzCYf2CzgRLW?GYI{1{>e@YpgdcQt2DJ%OCMBBt{t-?T-MqXJv3)y)>Z)<%& z%-;_#<%|RI4o=?5j{!tj1(G1uOiNmwu{0h2dKGyG2~qnUVIvjc^(FZ4BG{hPt;fS+4o?K*IKmrG}dC?=~33`dp6kyA!0hFNb?_Dm4!F{R=*(W`CbuK-_%!n#SQH?5nhF6 z@iQUvRRO7KNSrb;!jW{0Vk+@V_D=%2Bw2silfZwIp?ulDbL9174{ZSFm+s{{=&<-q^l>V=qXd5XLJlwUhyaBP zCYfa@rJ6edM33n=I^5nxIYaICrMaBT@w6k$(6o>ex(Vo3kYSaVGTi01%19%?J9-n% zT&Z@;Ti@CsydHx?0%V%bq=0%)4RhAFUo$jvh%Z1*7xeuM3sv_aqFZ`u7RlduZW6eW zk*-+CdPBP84uFaOTZ|DyLEvM(@>Ct1bzrS`=yNqNwi_Qyw@*&tcBr^=i2=$+D=gXL zlp`~UDvVk%3Y^Hj9e2;CX%~Zg=cw;kE=0q~`R3IQ!x!(&r?K4c7%v7#Jy|6DOJ{}z zaL|JLq@-Siz5wOCIA;6I*MuWz!z4GLqhm*qJpDQdxDzrt>DWdsb0CeJi$q9oNk<{> zxXs_W10nC=6Y;rsTYZ;XSyesE0ON$eDR8msAS9D_^-v=a<#%U3gS@Is^&G-gknBgD zb0aWM{I{6-C&?N0sS9Hwwg^e8;@aAaC|3U<4ry5`9j=|2yFWlN1~9NnucUp71Oe@Z z_7983)Wj;Mn{E<2c^RUpNU2jP_mEp~o=3r6!2?l@;_L3y3=GqIOA6O7r~BiO~TnhWLR&}q%qhvU6gzrbin z*9`2PS3|66?CKjWe|3`3GCrpX*;ghEO-&R@Clq)4mw%P$Xn~jZ_^V zZCOj1q_CjGWt8BRsf#ZWCaEUToI%wzrm&`oWVvH-o;We&KdgwuGH}vX~N!n`2ZOpv`1AUd}>P)^y2w{AwQ@R+=@~GICyTi z!+Oykt2*YFE**9PzF8YDuA!dB(^gBQ$uz&&XP9onOX+Po*s2(>6h-pv^Iy+VgS&cg zM7(-WD1jeK<^TOyHZnP%^VRP%84yh_C2g?8dx}a{t}kgU#50EA54vcr^F~fR^{5TjmRnqS-r+s4$8Z8!zI0+!qBh%kt1R zWO;^NWwzlYX_Y@`CJ%+6Oib!(>R-;kaMf}evd(!EWv>-EQ%Gs@%S_0knmd5eBxEgC ztjRbgN@JI}$A4i-vVH`RCf)6fxfk`$bJ`D8)zEi+sWx1OuCho{fVv^;DM#r zo7uO<0w*3BXia`#&-(O(Gf!nw#n{hCQ?u4_U&}N2K3qWNYHwxOuG+|JA#@_#3M|b} z00WsyjEjZInoP4=H{4>>r4vN_`EKH7Dc6(hSkLpVc$jAFAjM}f#Kr$q)n-Z#_8V!fsVlyO^lYz~DPd+Ss&QtUegQ4NwIZG8 z-HL5P@fTSxKfFv=N!g{zfe83h=a8_EBt2A;SO$bAQCHm2Fv8`jbCqQT7BX_-9kHsN z+s6u(FlK(BB72$1`J_QL__|1$SkTN%-1OUw9Y!b+sjljBT*WWu@l5GuHMB;k*s=JM zby2UiuBqeQpr|mt>FUmMn`HV8W(buY1C$<02X4gqK(zjOe(mXGQ~c3KDULs z`3mU&ow?R;=~kV`S1KFgWh{S=tp~)is&&f|nOsj3{n=9YLblnUHuE^do%}I<>2TzJ zu$Cq~veAgU{yIDZ6%yCwHElBSgAPyeN+6C*T1ykZGP+=12BfRut@3(@*gT$wF|u1= zE;nt#%;@Z%;??TYcz&8j0wbSTWk&^k_e)`6frX`|G!rAnVcOq~y zXP!_V>U`BxD52VE{hkt@3`mJV5ozqFT22qKl)yC;RFbQTc`xfgh|DW&>;X}>_Pjko zXL`@XSFy(Kzbzi}qh_+Esp&?bpFANUebY@4t0!DK4@7A=1x$2aAM#~=2%!x+q$4(O ztLSdcFRti*jVVK4)Dr z*)5OT_I+L+@YKTnry5~0N;rx3S0eulfZ3{&ZjWTT_G`>J`_*Bq?pt&r=Rqf=h!-q{ zrAHJl?}hm9McV=-i1$*>Jj8wIyEVOm_ot^PWJ`0t=m7DUoJ)pK$!87aAOMnQH;1SW zfqMWPXe)!|78$#xpzW~GCeZoo&-g;vi)Y`=W3!_$OcT}QpMOKKBWYaY5ZNA8Kca@) zKc6_})q2AR5EOMpgz1m+RlS2bH^~ZNk=5eFlwh^f|HfH9$rGFZC=UF;hvEjEgE{aj zhYJB0*qG*GjkL^voq5Gm@1B%2bN#f&Qo#?Dwb`B%Z{{it+IhhsAo_<9Dkt)^48ZRz zfPCkDWDsumPkwR_Jieok7Xx_R8&4S0pc%B>bf{nKQ_~=oLc4mVzj`KpEL@=~6;}dp zc&(ZD^RmeTgj+puOMfYlZ?nF4%a7$sts(VP>GRNa>bmZOSrXS>bDG*w|G?ytj71&$a2~w?E_Q9d$|x@S zTrFF{jZ_~fRgq2t1VOC52HgN0bbzcIYz6qHB?eOt?u1F6(7T~6Nsu@mK^av zF23V))=U2s7cNJK7aY)-W9JykS3DKo^(x^2q09hjWU{I)lfqKb=~1$d4L8MPIc_^; z!ChN&Ke%F-5P4)b*RCyk3vgc85aF&6AB)~KH${kRuo_IKD7o-odwRiLU2LQ=;jv^w zLApku&s##R00J6T{qXYVYfS@Oh#F?B$ z^b^;tRu@mg#ifE8TZliGG_YK^a!99MUyu0C2_Q=Ye9_7;*RzYi55$SXa0UmJs&qB* zA!`aB%^#>$Z3P!)2q&7@Q)<+5_SQ*s^r=U9$t`1 zFPJY?LrT@p$XL!rPT48u{c3Z-B~)pD7B0Fs5b$^>PpGf=C6^E@U6V8qbFlZIu72C( zsi5=z91l>3>BjltwJ(z!scE!#Ot$7sdJfIEr9Mu*OgN}cVUN*BfTk@z7~w_Z<_eWL z23j^s5Z41a!v*wV7>S&);vT!BgP=OLo?lwEc51%c1%Dyt5DSPjHigFpUTtr}r_wde zv9^alziA|{qthEY9#_NSWG{ZCPFX8vxA}~=0AxO$}|Y|p>yq` zYwKa~3_?s?_fgxqXHYvDMl1RJ|WD*5DtJwJ#vi0jg5O_%~iJ(<0P8mWuD~h zo_Ze`NZ6-4jVEv_gAFJ9d89@g@r7{0R)1!m_W+O@sfR`rgww{zYG~= z?vI+q-#~u|L(qSLUr#f_z=#aF_+C7-#B`RzT=KpxOH1Q`E6b1mJq6l^vvbRyy*sLT zFo+gGD6X2W`tkLxB3%*(>mrZNy(g0z3*8p5Wd)?byPSv!=-)F7PmHwI} zDd8kGI$ilsM@01H&H}%T2$Ee_r87Raq{2GT3}BB}kK1q$fz2Tiyt-Ad`5Au|-QF!x z6slxS7kRELHLeK#3d|-l3fKf#LYKFXDx&ur)HutG0hfR%>~gjJtVlWQGY~J2wX?=2 zz=&t|kAKXuQFt;$di>Dr_5yY@!?${SS|oM7?9ziH_LP>D1{j@>1K#;}>-|~ncXvyG ztwv?%9X{mrX~kpZW31?XwwTk{=xE2?S=6T~8%{{X!0Ma>BKjX7DfOk@&e&@`Ei#GL zDY$wWbxHU2OLS`(?c#KmGEcCr22e&q3L4gm*t;{R1E+DHY|3uULvYVzE(?GA@r8{E z@TDpeM(tgPxfy!4kaa#>>S}UthhF?qAI@Mkj|)nUBur_@M9)SN$k?kYfDh ztweFxVMMIw%=GMnLqmuEOFW6n&RZjQB||+sPV=&chO4sMb2at&TCOQRhh-rAQusmo zYaj**1Z79Am+x*!VJvp^Gm#RC=c-Tp{5uhr%ba^B=T)7Igv`rcYENKDb@ltuBWkb! z;tFe8uB$UjrEe=MM$VPmXhnp}N~#jbCG$Ejx7ltM4xL4AA~Jt7D1@uGnWY)bkBol!z~3A@wq>CsA#}MVzr9F-@`1 zggpe;400c|4rdkJX<(1>)cK?Q{KHsqdco00rPqB@$dp;(-(X-og)s7(L0nT#HxXv? zl{pumA?up&?y+Y4)yNZk3NYc}3N`5#80fj*oj%3fAopTmpnZrEgRH2T>}*gHt)koI zu$S}`1h}IZHbE{hr0565s*2st$GfG5asaVjCIg{{#eypsd(X zeYZ~?A?1`UJG4Q41do&7#~T*EzbQC+(|4(h(U2Er89k_^SXE-SIWLK^Glu@>gGsG! zCvrP1!dzN0RoF;zWaxW*efQg`apm)&X5*SSh0IPP z%Z!*)g`xiLU}P=!m~xZ*)t~=%m4}*?btQ`o5V^+`ji%LY?SyYtVF~1g_Bgx7Kh2D5 z@>YL2I0S5DhF91_+N-BH%YW4 z6;;$alF4Y;%>MPFBBwNeK5^htv3^^-lY4WKahj`7_VlK-1r8h%<)}iOGO+fV76k=8 z=)*g&1b)U zYD!tTQb&l4eDKUicHJa-MiJ*xpw8g_!ziR*f?^whtg z{!vj-kroFj>5icrrH2$0kZzFfE(6H{Bt#l%B&A^}siC`D7&@gJ-jnOT?&tsf-;eKn zaNE?y%wo-2XB@}A|8{PAfga^#?z|t2%f6^fZ+|@Lfh?TgA3HhVeQe_29{>fbfTWbk z)*7B|)#|GLGx<_(%hrL-8>h*exw8S=vSc1V^3^)G;=6K_u=f5llZEeDrccLpec~rg zKEZv!uQi1O`l216-;6<{jY+BUSsIIIh@q+a+i*jSX*b5|9GpE@T8Ou&D7(YJX=PAaM?ILPo;q)YXF5oTXqb zNXvO7lilkK*XaG!&lfm7_HC6p@Rmz&v=3;L)wg?p@H^11WuhV`*U!rO`2~(oPmLqQ zF&+9MME8Fs>w#4aS~YOUTS$Lx2uw+gZxR-Ub>%y#s35)0WX6pPkBYzj#ad36y)9q!(a6~2rKMM9XSCVvX9j0JFAKXxbk@ZZ>PzPPCgZ zZvbb(4V;VmzDL^=DiShSBv_wa-5%dAqPIqgCH-izU6S8aJMg>@jKoyjYkdDd(kO(zq^~Nit z_SJ(!v9&+M!`L9XA;EBs)FGlBOp;RDaqm-|Bn9}oB_mCOf5_P_p+-%oEw8}rK|kuT z_8U$hvrNaEHIoA;n29sTF!DkBUBs=pJDuZZ_)72p-*buLwq7%cwzz|;Q@q2xDjV5} zc(i+OzF_|K#;Zq7n1$@N?i{(Mt(tTva)VgUH@oqzUO&!*xs2C#cXxkhxi&t??ln*H zG1Ez6W~RSDBo-?x%2#Nyeth{l7RY_2t6LHpiC5E~v}WQaGJQu#kT7kIshx+A+yyOn zFxO-s!(dKYM(u3pH-5 zI#tl#KLO6-=fIPCdxL^U2JHvgq;;%Miuy*_S`w$E$sj5Wx0$r&o=Q0Xs*VcqQfwVO z=$4cF&Zfz0HWQaL(^FnvzK|i^m&7w{S%GY;Ezf0pez!a~zyZT$ygXk^xBwk+j!E8Bno}*P`|1^LpA5aHXK{Dv1jNi=4qQSk)3DT`)Fn$fd=&nnWFJ z5Xuy9Vru-&4T!>xvx;X(l*;4T5~gb1LRt!B*LFd7hQoOsoDi#2WM~MW2z3)Iq6Gg} z+2|q_vxb$Z`~GTj%|e3q6S--^S)(48&p8vlV1`MIHqlq#I2Gl|4{|)TaF){S1#%JKL;-FL(4vQe zQl+7KE7B;W0o`iz4%%_50f+M%C0D=`GW7rWnr<)82UkK&^r@KFAu0hJQKp(|rn=*0 zi-j6Wy9ei+cEyZZDUj8U?a}TP`{xM560Lv;h!W$`#U&Z>Qbx9(XRtwjo-8_#fBAgX zc*EHRHY}1;Cl*$(YAbC0x>+oCtOZk|3q0c2fzWVCs;}k6_y4Ec$XjR^0j(6;jv2CN zs53iy9ymPcXV4f@+Ld0Yt(4+tsjj5tDUC>%ZF(65CBk|DZMK=ZX{59qA$2HYVdM;6 zJIr@^O2Sm162xZAS*E|=^D(|A_!t?6XOu)B+lGPm-kfY^adP{=9klRcErO@iDi0}> zGGuA$fI{XRac=6z#>&o|3j9=`n(t~PZI0FMe5Gk7#t9Us>}%^7#eRImh=Nd;QNhru z!>h*6{N_|ai{SNksKREn<3hRU%w;_ z!gGXsA?9~K{;9_iUfFY}4d&>}@@oLCc}%c^Kfg+Q`QmF_%-_kJ$70t8O@AmQFtA8D zZnn5EDHF2s#B&WHo~UCoS4mPzc!|G5l5W^Wc{OEqL-R?eewG7~i=~rR}D>zY&Y}A>6oXn#jA{e(ksKaJGU0++~8@gzv6c*X@^$b`u+PoT1WdqM9fM z4r6Ovx)j>+c#D*cl`XhON|~9*)ZDRpbag(=&Kz4DCyM$3@S+wNit@5S6k-ma{;}&A z7g%T=kh!9%$VRioKZWHKASpcDH20?DBBUZOvIW^v2kqX2QCBU@29Ae>;K8Rs6&Vff zQXa6Of?CW0bPX@+c>CeM1=Ndhs1oK}o#}-{gh-*(QRb3=VsR}eTi?@7s}Q#-;8p~| zN1SDDs+LJlj2WMYG3Aw7F6U7+UO-8)z3NkzGBY!mT4g1HDUi#Yj1&v>uzm#n72@yH zpfUPp3V*F4VIWoFC10qYqY&FMC{c<{{MDALvm{Ka)CY6_4#=%=vgM5bNAC9D(+P44 zhlWU3Oz(Ocj8P6p0FsmsK)l_wD9>H^@lpE=$Ru$++;g-$ox&MQ66C$u8eB)z7R~>- z1KL52r(L@T{qaKdTtdwB^z`^)mRf{{TL+wh>lh5fUo!q zqLY^ZF;9E_&V>;yQ`_~4F7CdP|8@Lhq)<;WWC z7Z0L_N8ajQ=FP~6w8hyUA8*s zxYQ7yk&s3*!hZBusET%mcRm51V zx|NG9Oa5CuJnaJWnw%a!lZ=yf8@@lUlQ>oudxHtAFGgTg*T~1vDHBvT>?R-KAov#4h!nX9?;OUHe@SA+t?7_h4RWKjVOIh@3g-52=0Jdb|AmJdf)}M~BsDD!| zXkxHSSbN!@_6vKn!2}V$5zt~3o0%e9%z)0pQX*Q%C+ z74d3nw-sEjaA*|tIj8#oC09-`eVUh_#w{rB9`ULU)(W2f>ANTsC<#6hv%KS@rl zQ)O@K_H2XR#^;vO5a=vNuuN%PbjK!ysm)0|Fd-G?*p6+%m`YULA&G%0-a9#|DeVz3 zPdeF>Dv>11D3c%D!S-)45xK&ZntQTWxV2SU{tQ{;&mqHr9~@HW<9I>;ay=aAs#DBz z4rxl<8$9UJR&4HYjDjuT@5YZ%x>D~|n{lzr(=xZ2D+8cKE2=&e9&ra$M*_*r8OaM; z`Uv7wl%wX`E`Y;zm0Z@NYc1%)Z>qTNSmZo0x9Xx5-^aHB_T1{Dpe75YTSf`eF=8hc z1MSc{gesKD2P}I-Z)>@vi(Wf>=P5qdPd|0u>Zx(hohG|Zo_RCDhSupR#D4hc%K^gH zQ?=3l`oCHXgb7feuCzc>bSPNmeDI;$dBNBTJDGRsF6wvcbBl?|nHog}th!&l`n>0g zm=7YCuZ7V;yqc#Lw=00$Rjom`be6kNd1I?7{%39LnTY65u*_|z_8;M^=285-b0zZe z;I1bj8~~bwdo5Yn7$q?8OQb#1_c(|pDL(eN0{fgP6wWMdo<4isArc5XLD0DU(NPr+ zi68?j&;5UDbvM%1;+oc4@$R@-U*^0wMT+~2p+M7-c_px-o4FW`sZL|0jw$Z$;c&IM z?XsLQ5RUj0k9y1|lxr7)N_7wgAQ!X&r+0-bseYQy14|Ck^kJWC5dRoB1%Sf)V4>v! zJq=Rqc*zMfZGXRu|2vlu*F94zxV?*_ji(lqanH7E#B=D#y8V)wx$R836#6CNmCyy| z%c?JtkG8weNGFU4x{P|;f&A92_6&KW^m@*v|40Oz33T z#-w4=hIjpL2^Atwko=-^rWNlnDPgcZJ^h#E$3V{C>hCU&Nr_Hi4GExuxwssyTl~bN z`~>?nJT_?xZt;P8SKVVT0W>0rnvKASr&G{$^tz)9|71$8*lZw3ssFh?2OUuFkzkZI z4}i7Y#|MJC|JlE{T6gwpSNU9A^#f?<>2Y9cjsofTcc;Pb&X z&+nRHmYeCsKB$R)On)L5d^8ujbb}tSa}Nz0$o`~M{++mg$TVS&j@K&Y3GSX6Gco01 z$$|cI6ppA?TvCktVPv3VAH5MSL+kU|*@SNr%wD-#JrsnpyhNb)5piWnwUQK_%vxPNMNNe!Qyv}v{t_(WDk z;IcGT-(He-vohbkWjCnO?{3^d!*tiVIxPkFA4j3DFj-O63*2io|g36unm048Ot zK&r<;%zsK8TQZ9)0KI@M;C>!UMhh>Ety%>I3P5}UAlw43W7JBX8g0t?>ybRUj01dS zvhQM`sZuF|N-}^$qyDfxK;wTcl`&{2fpM}sP7*sa1zL@-l&OdIDgAMqOIaxW8M43} z6Rl74w-xe6PCzC~@=p#}i@m&{^grAlmRBJ}p4sm5B~gd-H@*87)J9b!F{N*sqL#Iz z$Rmo3>`tHaKK;Gru6+=OCl0y2?r7oxh9)MN(>CfrIQUPAO*(Vg>RRD|-ERweNd@l4 zHOPX!NfmJ>NV%YnR$vK9S3%UX{mL6DbC=l$k2f)RyyM5*-mB|a8-I5Agx3U-=)(ol z;7`yo?kA;(RcQY(M!=TDq)O8`qw{&rj4_RTYZ?=0)AYt&tM2@HJ#}lftP&3xFlTbn zQ1_Wdsg!wNV73f-0>?HY27s}l6?mAv_rGLbxt<#V zDsOcxApxT_KXqpHo=vHuF7juzDc}hvoPz9o{we~z6(yq5#PkqKtXrAR4>cNC-p`-; zKYz-@ls2|XomKujefyvfdn!1m166R50zU*lt4uKxb{c*SUpPia(A->Y3Yf72121ck-gA&x`pX13J)3VjZP zS1|!1@2E2ICORsKFy9A1_yu~JD5y9-R=}kB$E^I%We{qCuxlcyMA_JAX$1%$Z6A)` z)SfJ?zoAcY-G8Voe9(LkN9=6vv)io8d(ay(0UlUbF6!;0h`)Hu{P9gyD}Qv4a#lr?*Z!@+?^U7dUI0N z|Iaa_Duzy%ddajk-IsD@*zp-^HaqO(nkiZigw2ZTb}5O`+Za?$_E-w(c2!Q@P}uE- zg0(8(Oc;p2_%$oS&;HL<4xk+!_M{FxbACmKbpQ^-@5EvnQiygY31N-+qITOp7rF^A)E%b?^z#`{;n`Kwq4)e zpN8s7bDFHAd{9J&H9P(5TswPun$}$xc?Xvn+lbCgEtawRog`fRM_+gpLn9)r&d}45 z^02FqAG+6nyMw$C4y0{FzY$cshpy}0;|n5s4qQz8J~HeJJ&a!l3eLJX==U6os_Tg) zlK!5bdj__oG3@IBnTb2Kf0B}nRbO!Srmr6dBeY@Jm@v_)wO%iE*6a;o77vA8>b!E0 zN!6rQLkuZp@K<|&{88@j!-FB%mOcCj9zIGr(O>IzsURwV<--yV>?X%vc6D&zUg()^O1Gu%9YTSgZo26!+M_E zg?D_sA}IK@xVv|@r7(#8-BaNLO6nzm$XwM}n|kZafTl!nM1%w;&}?A9sahGN{AHth z1nde&L(1p+ zmVahw)G#SOZ+|tFdCX?Qy)QmzXB*v7Gj?P1`J9JeQ~ZH)NnEd27*nt$*fg~T6ClG$ zMY*4kYB2=!)9d?Q>C)f}oOiT<4}m*LsmRH7gCK*ylaDZbKL@wZiG*7b_-AJ>dz8lBOM<9H<^6UUrZux6wGJ=iL;4{qkL`)_E5-JY#ST z1)u~Q%hyXCzF3L^+8x0JO`3~VddQ2uoPtKR~jTXq;&B|)NkpxoX$>;NpxsXu; zK|FI6aF?rFS@ka&D~q}{JIH|Vp`sMT(AT)oE&xiU8E3qfW)yGo@Aq$`pZ;7X3X2=f zA7|69INv(UDR*3Ldfj1GQHh{Yky6o$H%o$MZ+Z$PM{DT$SpnSM)y1(zFEYR|=p$<( zD~#W$viuxHhONy~c!gfMj?z`pJnFlk&Zr zirekO+X=AuxLp$bNECdzLjefZ7t7eHI;tD4Ud4#xF)k%Bi{|Pmo{7^m}r$%dm@vj$_-=PYXt;h?J{88T~*fvj(soreKyMHWp2t|G^EV zBsZQhMkYA+F`8mJz@e1*$lUBow!URmwXDfh%*bQm%(TQmq$-vM3N*p~&7h^86c_hf zz+3^o|2s+&ab&to3YET%90|sSJNWo~9yP0o>xBo$M3WMTYeABSmL2@x4Zk3bRFoKY zu4<14ymaL5FQgc3c8}q<@YfZ`0$a@J5Xgz4@&{A`?7&&elGX`-e{K?D`E_ za5Y6``I_zQ8{vZ)=FU!7PM=U81ttiM)^G(6F z0y&}K1<7X7m5z=Ui7P)UZ9ut7rVvNzr2N27Og^?M3u5EbepTzBtr}Tn2NQH$t!>Es zB2vC?S-F}eiCMAj^u?9&()X;#twMhCT-RmsSz+OH)!(01S28C6Pkaj$DLQ_9n#kwm zx~c9l!1FH=yJ*;?&UEt_3~}sv7JzwSEgm7uK#QH0hgkVGKELNK3{vw%7OFGa4nTg4 zgNx#}d~yAs*uC} zyCEzTE=y^EzaCDD_Zz~lRcEsBY(7pg8;nP3Alt!=FUqTYF}*eG!7(W zYzrN1QMBREDM|S0Q+X|F|5k`WNkD3KgnggIFjwwRB0Y_H``#yRhdaq)rbE?2yB_YN z`kSp!a=9Wl^jjbom)Bfp;iEea|m)OnzbRdP7C!k_1!teyV&nGq58}=I~1Igk1fYUV1t++XpzC*I7 z>9bx@MdZ8JTSpMin4jyu-RoxL_vB0SP0clS=&1K;!RHj;on;U$s-mE>UbnyVc(3FV zAWd)o5u{}Zdyf1fv8(a&Qh_4lR70S84MwB0Ct`vw`~EXYTetgu)RAij`%iuZka%r? zv8PPnM}J)8DPX40RaG>ym;F*% zF7r0^-|U4sdDY>F9QRQ8v0W_W=!1r`m&*y5}<>(29`xlDR!_W5~8^dt{_5FM%w3sA76nOi9XM2`a>vsF&{7d&h zzAa_ag;x~$&hi*5OCdJPuHgboEpl}DMoALWW~S~4kP!l(5O+YLwfa8HE<=$;4g1ZX zaS}psrw~5vL(BQfiEed4k=HtiDAn^+W;St59`0(fv#tCVoaErW#^;cE##Ym*a?@#z z`D=GzlZL=~Z#HvM{d~qQJwVGQ2^33aJj~hj&&I)Id0W7IG^3urPw?nV$q&yzbcm^M>&g)hY1E+7$zqn{yeN=SNFFI>kVF1@C&;sXFRC})K|Fs=;u~fzmv>O9`qCxez z&%|RhyV(TYS^^Z=8;)b$MQLfzA~AMIMI8CX*V!zg=zc=R=X2DHv;6eiY%b$7 zP|M(J!V~}Yjc0w`!bs<(nY5>k4Xb6F>b|aiQa5e7kM202v5s3rBf;|)X){?V!o#Dv zRK7V~`zsG2J^n;ow)?T0iz2)P17skLc(j{l=G^~GiHr;K=TXE#|Kw3KNyYrmNL(BS zBer}#HmDmESD9VehdQo1nET{K?!EpnH>HRW*%I zuvjnZXD!_P``SmUywUb5J0x%8p|K3*NL+t$X&%B2#bX*zyq=Zy6fD&z zZ~IOoVOVm@!e6iy;sj6^<8aX=XaFuzO_TBPds@PugQ;_M;nZ{8zpI^uiPhKN|-jr_H!bb!PNiHN+%{mRus)uh1pfF z+l<#)+iG@BP6(ZV|5rmu2o;~+8wJGfxRW0c7LwYx7JocFi#8RQ;@-6#cMv57sUS`M z4gpZ)g%->>i4uBUHc8$p8VTVaKP1Y9Hd*J-F&yKE^II@o?zl}K_VaEXr?O8T*y348 zxW4a$&@b&C3{0fMZ)_|``ro}7wmEn#!!fz8tED-z;&s{{?azVapvyhuoi)dzNBfas z@Bg*Mf}NH1S))pm!GL8&`p$d{yCon1J^|@%-PMECYz=>{{l5l1#1jt=4qkZuPT2Nu zb+S_6yJz(zJi#IF8`GMuW*!%Z5sNHd2c}*+P0F$69qo>r16&0rW!YqAUf%fTGrk`N z41}wWu3*%NY%@|)2ukJm#WJ>@T6(x!f8)Rbah%x3OatZh31>!$1^dnK=DS*8MiaZ3 zVFrU0|G}XLW*V4+wn3^A0;GpZq7qwdPcH5)9;_doIhAq>)%>;@x6Z?BhS1Wqs#peL z&aWOYhAf6S1>5MbnKMkdE7psqvdEi72RqE2BXrE0PIu>V;ZReBs7$d~mfF>i?Zu5N zr4Lckp{M%Re;frjXUR)^UO=2(ri)jv@7@vP|09bZNdp+rw6MIQqTZx$;kZ$s_hL-wF%nKnv>Cd&WmFYg?P^xG+;m}^pI20&%{m<_ z2g%YTUI$+Zm|sB3)pPzIyPuRrZwbL5sK;t^Lne%rNy}fTcFX1^*z)G&WSM2=)bAsw z!8&A&Z~mI5JEYfgw%pXpNWA&atE*J+<1A3MI@(eD3&={r3&XRfj$UvEdz~bw;T0Q9 zO2uKhZf5B)QX(2dXEx=#muDCoZBM7M3u5#^yJ}S zj($(FQ>*qao2;@t3??^I>LLT)?{uI^S>$3Up<)vQ=lNd{fPq6kL1Em(AT_(NIVo%x zBH0n1zKB8LwbD>)M?6rBbE_CN;CQR5HCAl5yN8mlKbUJj2p2|3 ztfshZh#sm0P4{FgYEyV_#XsmvX%EyqabZ&2rWH03sb6DPZL7#*<-Fo=BfSBIdO zAVeDexIZy9DQ3cPF%?PJl%4MFFozS|!07&6TkHNpK^ZM+xjbDBI4ced--UBfp zk?(3Z83%%*Pj%k<3IL~~4;PC;G|EQTs1=YefIecMz(EZ1i}vM5bA9)U$|A$9I6UA9 zNG~W@1h*@eOtVbCsPj;339E*BtNjOi{{0#G;%864g+oM7M}X_D!bx#l-~Ft2aWU%7 zz4K&;DVScw3CM=CvG>YWBKKQb2;`pMzEj%u7)@7G<6W%sB}?7keG;&)PE79rdoezJ zzbqQqEVJHJ@aeYh)i~l!f8G17xJds$AH?__nkUNoiLuK?42?&1$Y zXxpNrpl$uSSo%q=HpLML+Oy-0zoj{9%)75{f^4&kqt)j#DL_E+f-vOt>Uvm7U?Hdl zdq0(}o}7OeDcW>0e4tdGwwfg-iD15YACaHkvzU=x{515tlCx_qE zZhO}D1|X)h2&;~|@-{%}6|W37%00X8Jmax>)Cks}p8+(F5lXQl0TIvmi z$wm3P`BshWn(CTrB33KT05P;#jFzPk)xjR>djmEoH*T9pAXivCb?$yUnbVT!OdpS; z_URd)zRyL=UOTx9AZ-V{q7C+^j=UXqH@eyP&%Es#Zw4zg^t{1*CSu=T*vO_=>N<-| zEN(3Q!iS&r>0&7up6`h&4e+j>+3 zNl|TjUEO|$a$_~1cn{3&3?quf4%fKMQ#~A|q>hIB>n~^C-~@ly@z^drP~xWpE`sa8 zl)%W(=)#ckg8e_m-O+FFx1rLW^{azj@pIZRgN*9@%7`Msm z8;~Y6Z@(!5a)C#0OCnR9?b8Kg?J1r+C4oCc!_?ZYyIn<#|K@ib^b!WOYThp}6umRU0S=3^T&n89W{D|dH?P!DV4Ws7KoT@ zuvO|~L;1ee0fG)kGigDPq;!}|R55M6*!(S4El$zW(NWc{E(XMNUB3h>Z>b6fV_{}4 z-Kfmh3_7}R65}I_@*asZ;9@a>%A$)`on9(?v9|6T#o?|l=C`An42~}#3Uacq3BmGI zno1?o8B~j#%*q~odR5!Y=9vZ#dMc%)2(w*CAb;_c7czu{Et%w0!X5f6QU4CmpyaC; zC-U3Ed(DzLT$j@4zwb{Ty?zurzGlj3#NR_go zkx_G;N{ipMY00afb*m+6>E-JA%i-EZPy1Wj+JgB&{u4tMwG0-|PfIz+q2f?qWjVQ? z&MpO*ocF;t&gZn(1>vRir7ZLI+sg~~)pbT{yL$JHT#a;nL>8@={!*5*0Lk3|wrjE0?0J82Pob)Y`*$9Vz+XX^ya zF~r6oAWvRF7EGp-U_h%b$r*|Cmp_{;sHwGaU}?qz`lq17B_wfF z&~R(5&F@lEO;-psVRYHd!J*?t)33j#PTz~2x{J1@PvxU4C&#%FV4S9H!1=Q?yLBnE zbwyc_)OGIzG!~cQJ1#{a;!LGoYtDu@-yIwQLLn2vnc4YBcxG1w^S|DZ8!M=dUe=M0 zIJ3Cc7OpGTnd``@xDQ%WlIQ19Q5o*_gY?W1n+T|Y0Hc7w&oeGKG=FqI!pbsK_YD!n zDqx_b*}Bx_s7{Ef4@hiJhIb z`+$2J_Z33*ri-I#EA{c;%Jq5%<1~AI-hyORGspwVI8|UxGx^!jRG7z<6{8Z4n=WlN zZ`GOF{mlDv5|vF?I2W8omc0%mLWsUlXIR{7!^PAAJFiu7m73Upg3o+jUEFg2#tD zfO>E&>EjgQ{5`iIC~eeIxN}$9Q)dxUDiD{&Df;?-eJ+iPyQ?{LwDW!B8QK7}6-Tx| z-+A9pyq748fXsF=RRonQa3eHobb!JSoGi@n8QJn|3(uO&Y864L@;2&~+?za7bsaXI zIqIHRFL?@tbQQ0@=HfVk~iA^co+^%>BPi77L`1jj6nQ&Cc;6*aty3hJjQFP{jW zy$dgq3>vkpn6?4++gRG*iS0^R8YXpV&?CNwaadTG|D7h7vkOl{Eo+&jkLqBfOU+7D z>vXy0WHvrNG8`I9v*;{HHJHoN9Ys?!I|tZowIZUhC=NPO%FxNW@geDqsmg7soFwO& zO?Hv16>W;sOJ5k(ozhL4PMpwbg^CWgDROQnn>&Ug36f6??5w|mz#@?e)qPt z#11BxGWLeO^UEm70`VZq0?{VENYox3J$*?2koZSDaqL>3^YUP@nCs~4 znyK?hJk15|x0f6Yc&d7u$%%=koQYSyjr^lU{LeOQ`IOLJ2A+9ABgxTk2+mjS3~+>& zR`RzFJ6f!L$y9Jeq|3WY4&z$Dmk{K+$YZkr@ftF$!~i27AW{fj95ND+dvC2DmXFvq z+?@5iQJ$*OT}ObZql9nyYWYw=;&xYFVyTyhPdQ!P>0J2z)Rl) zYwiNgT0^kWO4rwoIv#*D2_Pp>N3+fs^*Kr0Zf9Mi0Eebr+$@uNxeF3Aq3>Td0@E1} zBjcbEG=hX{e{rcv-8yg|=a_qHhNnkq<3Vyn;ztS_y-89sDgm(9HAZZ_h zHPQxX2=Q24P9#iCsZa-sfK(}zbOit9Kz#q@2{6H{7oBh3*fvu3Eqfj$7nf%Hp037fwhq3-(Ro;+-}!PCG(#Hx9O^uOOb}+dTXz$gy`GT$ zgnY#B@9%4{267zP(gY@rruNkQJkHhJmbYq!KMOBR7Bn>D*KaGx37O3r%O+55&} zz26)oE5lBS1!LYUPUF(*G9l3tt8Rni`c zy$2tP3PQmp|6#SUK{K6!J3O;%6y}h$E0>9z>*j4&9Iu4a zs?A!`{{(0%HGW1Lcyk2rxsUeaUUu_?c{Q|SOyeF17nZ~!Fc|n^eSRK5dE7$c)JVDa zf!DMSB-;eMVzgN%3EsY65MTHvHl2>Fn=_JpqN0`)1H zv(JL8$u=U4>ZAy_HjMY$>AQLBV`Dc7aoH^x+RJAcAPa5t(>3vcBH4>4NYKl&$GBsl zVMKG2-K(uEwpwaO)PQmnI5DI*U4A<4MyMRmO}=vf!_Lla*JMZ(?Ke3vm<22yfDd}v zPl>l^Kk-&=wz6I}O4g^oHnYHcAH*BNf;V}sOQ)^DJAj(O8+zp+CxI&K*>K#A*?dqD z16|4NoGj6+c@19cnXO;bpx-kw8F&|MLg;<$J+v)pVC9$Q5x>?Oq~XnFubi_u_@aC( z+ZkZB_jlHLrOb$WVUS@&ab5+8h;@Yq>8Brv6T0KMM7 z1y9amx7j*aKJLbw=c{nrqB8v1ssYArvALN%jkg3j)&5&Zz7rC(e~ zNJdyU1_&xv9Bzk`8>#}}Z+07oP6U{r_7}KedasRM0d^OkK$mVgl?|pAG zTGxH|_p;j#3@WYXp zFYHeXh+|aqN6%ZSPe6?GKzZi_QxZPg6hV*XA0MxtY6_lrNZkAQp&dnyzH^P{eD78c zcm2_z&Sf+FK7}7qxMbtiPEJ|BaGO~LScD}^A%=!R(xZl^rp}GSQ$%(T4=8-rZU)4L z{~@aU=*B>@bC{3=M-UNZdGvB@h zKSzvj5VXI=w~)a1G#hsq89Tr0IlKBue9lX-tzA#Jyd?D>cyd!?eU#vGpd{=H9JY`Q zunNC(b%8~~8RUdbeSUkZ+4?&vF-DuhG8YN*|E?j}x=TAVjo^$RA|CksRiHm&9Qz(p zE;ioEj5z3w^2E@7gtpk~=azM&{oZL4U`f_R|1iNueO$H6;CotP9-ito@{gzuo$l}M z|7aAN$k0Fd;08-Rq|D26c727mhKV8jjH^8*8j*8e#K{IT}& zU-RGXKX|^_I{qS93$mjAeNFV=-g5uG{;xy-AKv|c|H#_S9oI06oyLd7-*4X^CZ!}< JByRBO{{ZZr^fv$i literal 0 HcmV?d00001 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/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/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/python/figgyplot/__init__.py b/python/figgyplot/__init__.py index 04c3f71..5b3ccbe 100644 --- a/python/figgyplot/__init__.py +++ b/python/figgyplot/__init__.py @@ -3,5 +3,9 @@ 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'] diff --git a/python/figgyplot/_charts.py b/python/figgyplot/_charts.py index 030c13c..f283b89 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 def family(settings): @@ -50,10 +51,14 @@ def _bounded(self, kind, categories, series, **properties): _merge(chart, properties) with fig._lock: fig._require_open() + if fig._style_defaults is not None: + state=fig.get_settings();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). @@ -83,10 +88,12 @@ def bar(self, x, height, *, label=None, colors=None, color=None, 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. @@ -120,6 +127,7 @@ def bxp(self, stats, *, labels=None, color=None, orientation='vertical', **prope 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 +155,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 +202,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 +219,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 +232,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. @@ -276,6 +288,7 @@ def contour(self, *args, levels=7, color='black', linewidth=1, cmap='viridis', 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/_features.py b/python/figgyplot/_features.py index 842f26d..309a729 100644 --- a/python/figgyplot/_features.py +++ b/python/figgyplot/_features.py @@ -5,6 +5,7 @@ import uuid import numpy as np from . import _document +from . import _style_runtime def colorbar(config, values, cmap, vmin, vmax): @@ -68,12 +69,14 @@ def select(self): 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 +101,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 +149,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,6 +167,7 @@ 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) + @_style_runtime.styled def annotate(self, kind, *, id=None, **properties): """Add a document annotation using upstream geometry and logical pixels. @@ -188,12 +194,15 @@ 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 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) @@ -202,6 +211,7 @@ def _reference_line(self, axis, value, color, width, id): return self.annotate('ReferenceLine', id=id, axis=axis, value=float(value), label=None, style=dict(color=_color(color), width=float(width)*self.figure.dpi/72, style='Solid')) + @_style_runtime.styled def text(self, x, y, text, *, coords='data', fontsize=11, color='black', id=None): from .figure import _color, _segments if coords not in ('data', 'ratio'): @@ -211,9 +221,11 @@ def text(self, x, y, text, *, coords='data', fontsize=11, color='black', id=None return self.annotate('Text', id=id, anchor={coords.title(): dict(x=float(x), y=float(y))}, text=rich, box_style=None) + @_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) @@ -225,6 +237,7 @@ def _span(self, axis, low, high, color, alpha, id): rgba['a'] *= float(alpha) return self.annotate('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 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..6605ad4 --- /dev/null +++ b/python/figgyplot/_style_runtime.py @@ -0,0 +1,271 @@ +"""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) + 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) + with fig._lock: + 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 + candidate=styles._resolve(fig,state,fig._style_defaults) + 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 _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. + for key in e: + if key in original and key not in ('series','slices','categories'): + chart[key]=copy.deepcopy(original[key]) + 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]) + for key in e: + if key in original.get('style',{}):chart['style'][key]=copy.deepcopy(original['style'][key]) + 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/figure.py b/python/figgyplot/figure.py index 22ac633..e791a1b 100644 --- a/python/figgyplot/figure.py +++ b/python/figgyplot/figure.py @@ -14,6 +14,7 @@ import numpy as np from . import _document +from . import _style_runtime 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"] @@ -177,7 +178,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)) @@ -196,6 +197,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) @@ -212,6 +215,7 @@ def __init__(self, figsize=(8, 5), dpi=120, *, pool_bytes=8 * 1024**2, backend=" 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) @@ -251,6 +255,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,6 +270,7 @@ 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']] @@ -274,6 +280,7 @@ def set_layout(self, grid, *, placement=None, row_weights=None, column_weights=N gap=float(gap), size=size) _document.validate(state) _document.place_grid(state) + _style_runtime.containers(self,state,panel_ids=created) self.apply_settings(state) return self.subplots @@ -339,6 +346,65 @@ def get_settings(self): self._require_open() return json.loads(self._native.settings_json()) + 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.""" @@ -1146,6 +1212,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) @@ -1210,6 +1277,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): @@ -1249,10 +1317,14 @@ def pie(self, values, *, labels=None, colors=None, explode=None, startangle=90, for value, label, color, offset in zip(values, labels, colors, explode)] with fig._lock: fig._require_open() + 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) @@ -1292,15 +1364,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) + 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,6 +1384,7 @@ 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) @@ -1342,6 +1416,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,9 +1428,11 @@ 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) + @_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): marker_only_format = False if isinstance(y, str): @@ -1403,6 +1480,7 @@ 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 @@ -1443,6 +1521,7 @@ 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): z = np.asarray(z) if z.ndim != 2 or min(z.shape) < 2 or z.dtype.kind not in "fiu": diff --git a/python/figgyplot/pyplot.py b/python/figgyplot/pyplot.py index fa3b574..cdc6c3b 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 diff --git a/python/figgyplot/styles.py b/python/figgyplot/styles.py new file mode 100644 index 0000000..7306d84 --- /dev/null +++ b/python/figgyplot/styles.py @@ -0,0 +1,701 @@ +"""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,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} + 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',{}):_merge(chart,a['bounded'][kind]) + 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: + chart['title']=p['title'] + if chart.get('chart_title'): + from .figure import _segments + chart['chart_title']['text']['segments']=_segments(p['title']) + 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/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) From 664f92031922c37f618a49036cf607399edc5b11 Mon Sep 17 00:00:00 2001 From: youo he Date: Tue, 27 Oct 2026 07:35:11 +0000 Subject: [PATCH 11/23] Prepare 0.4.0 release and declare schema test dependencies Declare jsonschema in the test extra so the release functional job can collect review tests without depending on the PR job installing JupyterLab. Bump the package, lockfile and manual publication default to the unused 0.4.0 version and align installation/publication documentation. Validation: build a real 0.4.0 native wheel and sdist with strict Twine and source-integrity checks; install only wheel[test] in a fresh environment and pass the installed package smoke check plus all 72 review cases. All 134 release/workflow regression cases, actionlint, Rust formatting and whitespace checks pass. --- .github/workflows/publish.yml | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- PYPI_README.md | 12 +++++++----- README.md | 5 ++--- docs/DISTRIBUTION.md | 3 ++- docs/PUBLISHING.md | 16 ++++++++-------- pyproject.toml | 2 +- 8 files changed, 23 insertions(+), 21 deletions(-) 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 b7f4546..57e455e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -961,7 +961,7 @@ dependencies = [ [[package]] name = "figgyplot" -version = "0.3.0" +version = "0.4.0" dependencies = [ "arboard", "base64", diff --git a/Cargo.toml b/Cargo.toml index 4a16a35..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" diff --git a/PYPI_README.md b/PYPI_README.md index 661aab4..c98d740 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: diff --git a/README.md b/README.md index dd1d2cd..a807770 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**: 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/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/pyproject.toml b/pyproject.toml index cf08eae..28adc73 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"] [tool.maturin] python-source = "python" From 2e755c7236ce18710b2fbdd84e2ce33bf5c2c801 Mon Sep 17 00:00:00 2001 From: youo he Date: Tue, 27 Oct 2026 09:17:12 +0000 Subject: [PATCH 12/23] Fix styled chart composition and preserve accepted edits Restore only explicit nested style leaves, preserve existing colorbar appearance through a shared mapped-series update path, and resolve all inherited grid rectangles from one accepted layout. Serialize style disablement and construction so a completed series addition cannot raise a stale-style exception. Add 33 API composition regressions and a native/Jupyter scene that checks displayed signal/field pixels as well as complete model geometry and retained edits. The installed baseline fails 25 of the 33 cases; the fixed installed wheel passes all 785 API/chart cases. Additional validation passes 244 review/history/selection cases, 12 actual native/Jupyter cases, 134 release/workflow cases and strict wheel/sdist source integrity checks. The content oracle also rejects a Ready-but-black negative control. --- docs/STYLE_TOKENS.md | 7 + .../api-composition-20261027.json | 86 +++++++++++++ python/figgyplot/_charts.py | 9 +- python/figgyplot/_features.py | 9 +- python/figgyplot/_style_runtime.py | 48 +++++-- python/figgyplot/figure.py | 24 ++-- tests/test_api_composition.py | 121 ++++++++++++++++++ tests/test_interaction_parity.py | 65 ++++++++++ 8 files changed, 339 insertions(+), 30 deletions(-) create mode 100644 docs/measurements/api-composition-20261027.json create mode 100644 tests/test_api_composition.py diff --git a/docs/STYLE_TOKENS.md b/docs/STYLE_TOKENS.md index 5450cb5..3e05f93 100644 --- a/docs/STYLE_TOKENS.md +++ b/docs/STYLE_TOKENS.md @@ -213,6 +213,13 @@ 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 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/python/figgyplot/_charts.py b/python/figgyplot/_charts.py index f283b89..4303a0f 100644 --- a/python/figgyplot/_charts.py +++ b/python/figgyplot/_charts.py @@ -240,8 +240,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 ._features import _set_colorbar if len(args) == 1: z = np.asarray(args[0]) if z.ndim != 2: @@ -279,12 +279,9 @@ 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]))) diff --git a/python/figgyplot/_features.py b/python/figgyplot/_features.py index 309a729..a07341b 100644 --- a/python/figgyplot/_features.py +++ b/python/figgyplot/_features.py @@ -9,8 +9,6 @@ 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') @@ -18,6 +16,13 @@ def colorbar(config, values, cmap, vmin, vmax): high = float(finite.max()) if vmax is None else float(vmax) if low == high: high = low+max(1., abs(low)*0.01) + _set_colorbar(config,cmap,low,high) + + +def _set_colorbar(config,cmap,low,high): + """Update the shared scale without resetting accepted colorbar appearance.""" + from .figure import _colormap + from ._native import default_colorbar_json 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())) diff --git a/python/figgyplot/_style_runtime.py b/python/figgyplot/_style_runtime.py index 6605ad4..36db147 100644 --- a/python/figgyplot/_style_runtime.py +++ b/python/figgyplot/_style_runtime.py @@ -76,10 +76,10 @@ def wrapped(self,*args,**kwargs): active=_call.get() if active is not None and active['figure'] is fig: return method(self,*args,**kwargs) - 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) 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(): @@ -130,7 +130,14 @@ def containers(fig,state,panel_ids=(),overlays=(),initial=False): 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 - candidate=styles._resolve(fig,state,fig._style_defaults) + # 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'] @@ -144,6 +151,32 @@ def containers(fig,state,panel_ids=(),overlays=(),initial=False): 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]: @@ -225,17 +258,14 @@ def finalize(candidate): 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. - for key in e: - if key in original and key not in ('series','slices','categories'): - chart[key]=copy.deepcopy(original[key]) + _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]) - for key in e: - if key in original.get('style',{}):chart['style'][key]=copy.deepcopy(original['style'][key]) + _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',[]) diff --git a/python/figgyplot/figure.py b/python/figgyplot/figure.py index e791a1b..ca52efa 100644 --- a/python/figgyplot/figure.py +++ b/python/figgyplot/figure.py @@ -238,8 +238,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): @@ -275,9 +279,7 @@ def set_layout(self, grid, *, placement=None, row_weights=None, column_weights=N 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) @@ -1311,7 +1313,8 @@ def pie(self, values, *, labels=None, colors=None, explode=None, startangle=90, 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)] @@ -1547,15 +1550,10 @@ 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 ._features 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) 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_interaction_parity.py b/tests/test_interaction_parity.py index 5e57724..f53d6ae 100644 --- a/tests/test_interaction_parity.py +++ b/tests/test_interaction_parity.py @@ -3244,3 +3244,68 @@ def test_browser_review_images_upload_clipboard_preview_and_targeted_submission( 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() From 96e29af421546ee26ad409cfd0c97e57c6c0b4f8 Mon Sep 17 00:00:00 2001 From: youo he Date: Tue, 27 Oct 2026 15:39:46 +0000 Subject: [PATCH 13/23] Preserve bounded chart text and improve plotting API discovery --- README.md | 35 ++- docs/CHART_TYPES.md | 29 ++- docs/RENDERER_018_FEATURES.md | 5 +- docs/STYLE_TOKENS.md | 6 + .../blind-api-findings-20261028.json | 217 +++++++++++++++++ python/figgyplot/_chart_text.py | 43 ++++ python/figgyplot/_charts.py | 27 ++- python/figgyplot/figure.py | 128 +++++++++- python/figgyplot/styles.py | 21 +- tests/test_blind_api_findings.py | 229 ++++++++++++++++++ tests/test_interaction_parity.py | 57 +++++ 11 files changed, 764 insertions(+), 33 deletions(-) create mode 100644 docs/measurements/blind-api-findings-20261028.json create mode 100644 python/figgyplot/_chart_text.py create mode 100644 tests/test_blind_api_findings.py diff --git a/README.md b/README.md index a807770..68010aa 100644 --- a/README.md +++ b/README.md @@ -211,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 | @@ -312,6 +331,19 @@ Ranges, ticks and titles: 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. @@ -451,7 +483,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/docs/CHART_TYPES.md b/docs/CHART_TYPES.md index da2f29f..5843ba0 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,24 @@ 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. +`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/RENDERER_018_FEATURES.md b/docs/RENDERER_018_FEATURES.md index 096187d..12dab50 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 diff --git a/docs/STYLE_TOKENS.md b/docs/STYLE_TOKENS.md index 3e05f93..1f97cf2 100644 --- a/docs/STYLE_TOKENS.md +++ b/docs/STYLE_TOKENS.md @@ -179,6 +179,12 @@ 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. 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/python/figgyplot/_chart_text.py b/python/figgyplot/_chart_text.py new file mode 100644 index 0000000..1aa17fc --- /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(str(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 4303a0f..57e134b 100644 --- a/python/figgyplot/_charts.py +++ b/python/figgyplot/_charts.py @@ -3,7 +3,7 @@ import json import math import numpy as np -from . import _style_runtime +from . import _style_runtime, _chart_text def family(settings): @@ -39,20 +39,27 @@ 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 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=fig.get_settings();state[kind]=chart + state[kind]=chart chart=_style_runtime.bounded(fig,state,kind)[kind] fig._native.set_bounded(kind, json.dumps(chart, allow_nan=False)) fig._changed() @@ -81,7 +88,7 @@ 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, @@ -123,7 +130,7 @@ 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) diff --git a/python/figgyplot/figure.py b/python/figgyplot/figure.py index ca52efa..c018817 100644 --- a/python/figgyplot/figure.py +++ b/python/figgyplot/figure.py @@ -15,12 +15,24 @@ from . import _document from . import _style_runtime +from . import _chart_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"} _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): @@ -556,14 +568,10 @@ def update(self, target, *, _axes=None, **properties): 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) + title = _chart_text.title_options(chart) for key, value in properties.items(): if key == 'text': - title['text']['segments'] = _segments(str(value)) - chart['title'] = str(value) + _chart_text.set_title_text(chart, 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) @@ -674,6 +682,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: @@ -1308,8 +1318,7 @@ 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]) @@ -1320,6 +1329,7 @@ def pie(self, values, *, labels=None, colors=None, explode=None, startangle=90, 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'] @@ -1437,6 +1447,14 @@ def _add_fill_pair(self, x, lower, upper, line, label, sid): @_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): + """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. + """ marker_only_format = False if isinstance(y, str): if fmt is not None: @@ -1486,6 +1504,15 @@ def plot(self, x, y=None, fmt=None, *, color=None, linewidth=1.5, linestyle="-", @_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): + """Plot numeric X/Y points; return an Artist with a stable id. + + Scalar s is marker area in points squared. An array s maps values through + size_domain/size_range (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 ._features import colorbar x, y = _array(x), _array(y) if x.size != y.size or not x.size: @@ -1526,6 +1553,14 @@ def scatter(self, x, y, *, s=16, color=None, c=None, cmap='viridis', vmin=None, @_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") @@ -1559,12 +1594,30 @@ def imshow(self, z, *, extent=None, origin="upper", cmap="viridis", vmin=None, v return self._add(x, y, style, id=id, extra_columns=columns, config=config, bounds=ext) def set_title(self, 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.figure.update("chart_title", _axes=self, text=text, **kwargs) def set_xlabel(self, 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.figure.update("axis_title_bottom", _axes=self, text=text, **kwargs) def set_ylabel(self, 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. + """ if self.overlay_id: state = self.figure.get_settings() axis = self._overlay(state)['y_axis'] @@ -1656,6 +1709,38 @@ 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 _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=True): kind = _family(self.figure.get_settings()) if kind in ('categorical', 'boxplot', 'radar'): @@ -1666,18 +1751,35 @@ def grid(self, visible=True): self._config(state)["grid"]["show_major_y"] = bool(visible) self.figure.apply_settings(state) - def legend(self, visible=True): + def legend(self, visible=True, *, loc=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'): + if loc is not None: + raise ValueError('bounded legends support visibility only; loc is available on Cartesian charts') return self.figure.configure_bounded(kind, legend=bool(visible)) 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 corner is not None: + legend['corner'] = corner self.figure.apply_settings(state) diff --git a/python/figgyplot/styles.py b/python/figgyplot/styles.py index 7306d84..9856553 100644 --- a/python/figgyplot/styles.py +++ b/python/figgyplot/styles.py @@ -255,7 +255,7 @@ def validate(self): _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,category_title=True,value_title=True,legend_labels='json'),'text/bounded') + _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 @@ -342,6 +342,10 @@ def axis_scale(axis,owner,key): 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) @@ -521,7 +525,14 @@ def apply(figure, token, *, scales=False, text=False, size_scale=1.0, skipped.append('category encodings exhausted; supply explicit styles or split/group categories') if kind: chart=state[kind] - if kind in a.get('bounded',{}):_merge(chart,a['bounded'][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') @@ -597,10 +608,8 @@ def apply(figure, token, *, scales=False, text=False, size_scale=1.0, 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: - chart['title']=p['title'] - if chart.get('chart_title'): - from .figure import _segments - chart['chart_title']['text']['segments']=_segments(p['title']) + 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] diff --git a/tests/test_blind_api_findings.py b/tests/test_blind_api_findings.py new file mode 100644 index 0000000..321ead0 --- /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(), {'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_interaction_parity.py b/tests/test_interaction_parity.py index f53d6ae..34b9c5a 100644 --- a/tests/test_interaction_parity.py +++ b/tests/test_interaction_parity.py @@ -47,6 +47,63 @@ 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' +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): From 9bd77e1cab5f19cbf92f6761ae53a03242238fff Mon Sep 17 00:00:00 2001 From: youo he Date: Wed, 28 Oct 2026 02:59:58 +0000 Subject: [PATCH 14/23] Compact chart settings exports without losing accepted state --- README.md | 9 + docs/CHART_TYPES.md | 8 + docs/RENDERER_018_FEATURES.md | 21 ++ .../compact-codegen-20261028.json | 352 ++++++++++++++++++ python/figgyplot/_codegen.py | 134 ++++++- python/figgyplot/figure.py | 72 +++- tests/test_compact_codegen.py | 112 ++++++ tests/test_interaction_parity.py | 39 ++ 8 files changed, 724 insertions(+), 23 deletions(-) create mode 100644 docs/measurements/compact-codegen-20261028.json create mode 100644 tests/test_compact_codegen.py diff --git a/README.md b/README.md index 68010aa..b1a211e 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,15 @@ 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** use compact output by default. +Unchanged renderer defaults are omitted, rich text is written as strings or +styled runs, and repeated colors are named once. Replay restores omitted fields +from renderer defaults before applying the export, so unrelated target edits do +not leak through. Panel geometry, stable IDs, explicit tick policies and rich +formatting are preserved; resident arrays are not uploaded again. Recreate the +plotted series at the same DPI with a compatible renderer version before replay. +Use `fig.to_code(compact=False)` to inspect expanded settings dictionaries. + Ranges, ticks and titles: - Property-panel edits and `fig.update("axis_bottom", major_spacing=2)` share diff --git a/docs/CHART_TYPES.md b/docs/CHART_TYPES.md index 5843ba0..85e66bb 100644 --- a/docs/CHART_TYPES.md +++ b/docs/CHART_TYPES.md @@ -237,6 +237,14 @@ 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 diff --git a/docs/RENDERER_018_FEATURES.md b/docs/RENDERER_018_FEATURES.md index 12dab50..fffbf73 100644 --- a/docs/RENDERER_018_FEATURES.md +++ b/docs/RENDERER_018_FEATURES.md @@ -123,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/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/python/figgyplot/_codegen.py b/python/figgyplot/_codegen.py index 159c2d8..a699c00 100644 --- a/python/figgyplot/_codegen.py +++ b/python/figgyplot/_codegen.py @@ -2,26 +2,120 @@ 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 _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): 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 +124,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 +139,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 +156,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 +174,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 +282,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 +306,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/figure.py b/python/figgyplot/figure.py index c018817..8b6c71b 100644 --- a/python/figgyplot/figure.py +++ b/python/figgyplot/figure.py @@ -498,6 +498,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() @@ -507,7 +512,8 @@ def apply_settings(self, settings, *, expected_revision=None, reset=False): raise ValueError('settings must be a document mapping') if reset: settings = copy.deepcopy(settings) - settings["config"] = _merge(copy.deepcopy(self._default_config), settings["config"]) + from ._codegen import expand_config + settings['config'] = expand_config(settings['config'], self._default_config) settings = copy.deepcopy(settings) previous = self.get_settings() if settings.get('schema_version') == 1 and 'identity' not in settings: @@ -832,13 +838,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: @@ -848,6 +859,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) @@ -862,12 +878,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) @@ -878,6 +899,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) @@ -895,14 +921,17 @@ 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", *, 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 omits renderer defaults and groups rich-text runs, restoring + defaults before replay so unrelated target edits do not leak through. + ``compact=False`` emits expanded dictionaries for diagnostics. """ 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) def select(self, x, y, *, edit_area=False): with self._lock: @@ -1131,6 +1160,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): @@ -1138,13 +1190,19 @@ 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): diff --git a/tests/test_compact_codegen.py b/tests/test_compact_codegen.py new file mode 100644 index 0000000..8e681ef --- /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() + 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() + 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) + 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_interaction_parity.py b/tests/test_interaction_parity.py index 34b9c5a..1e534b0 100644 --- a/tests/test_interaction_parity.py +++ b/tests/test_interaction_parity.py @@ -47,6 +47,45 @@ 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 'reset=True' 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() == fig.get_settings() + 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 'reset=True' 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()==fig.get_settings()\nother.close()") + + def assert_blind_trial_title_pixels(image): assert image.shape == (500,700,4) rgb = image[..., :3].astype(int) From 56a06698a29d7808bc1ba8705fdf2795c44f8261 Mon Sep 17 00:00:00 2001 From: youo he Date: Wed, 28 Oct 2026 05:41:13 +0000 Subject: [PATCH 15/23] Expose public plotting objects and Matplotlib-style API exports --- README.md | 19 +- docs/MATPLOTLIB_API.md | 131 ++ docs/README.md | 1 + .../measurements/matplotlib-api-20261028.json | 300 +++ pyproject.toml | 2 +- python/figgyplot/_api_codegen.py | 705 +++++++ python/figgyplot/_chart_artist.py | 196 ++ python/figgyplot/_chart_text.py | 2 +- python/figgyplot/_charts.py | 1 + python/figgyplot/_codegen.py | 9 +- python/figgyplot/_colors.py | 155 ++ python/figgyplot/_features.py | 126 +- python/figgyplot/_matplotlib.py | 1795 +++++++++++++++++ python/figgyplot/figure.py | 262 ++- python/figgyplot/pyplot.py | 3 + .../skills/figgyplot-review/SKILL.md | 26 + python/figgyplot/ticker.py | 66 + src/lib.rs | 12 + tests/test_blind_api_findings.py | 2 +- tests/test_chart_coverage.py | 2 +- tests/test_chart_styles.py | 4 +- tests/test_codegen.py | 4 +- tests/test_compact_codegen.py | 6 +- tests/test_editor_properties.py | 4 +- tests/test_figure.py | 2 +- tests/test_interaction_parity.py | 9 +- tests/test_issue_fixes.py | 6 +- tests/test_matplotlib_api.py | 563 ++++++ tests/test_radial.py | 8 +- tests/test_renderer_018_compat.py | 2 +- tests/test_renderer_018_features.py | 6 +- tests/test_shared_gpu.py | 2 +- 32 files changed, 4320 insertions(+), 111 deletions(-) create mode 100644 docs/MATPLOTLIB_API.md create mode 100644 docs/measurements/matplotlib-api-20261028.json create mode 100644 python/figgyplot/_api_codegen.py create mode 100644 python/figgyplot/_chart_artist.py create mode 100644 python/figgyplot/_colors.py create mode 100644 python/figgyplot/_matplotlib.py create mode 100644 python/figgyplot/ticker.py create mode 100644 tests/test_matplotlib_api.py diff --git a/README.md b/README.md index b1a211e..375d791 100644 --- a/README.md +++ b/README.md @@ -285,14 +285,17 @@ 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** use compact output by default. -Unchanged renderer defaults are omitted, rich text is written as strings or -styled runs, and repeated colors are named once. Replay restores omitted fields -from renderer defaults before applying the export, so unrelated target edits do -not leak through. Panel geometry, stable IDs, explicit tick policies and rich -formatting are preserved; resident arrays are not uploaded again. Recreate the -plotted series at the same DPI with a compatible renderer version before replay. -Use `fig.to_code(compact=False)` to inspect expanded settings dictionaries. +`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 and supported subset](docs/MATPLOTLIB_API.md) for object +handles, units, coordinate conventions and explicit limitations. Ranges, ticks and titles: diff --git a/docs/MATPLOTLIB_API.md b/docs/MATPLOTLIB_API.md new file mode 100644 index 0000000..0658132 --- /dev/null +++ b/docs/MATPLOTLIB_API.md @@ -0,0 +1,131 @@ +# 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. Bounded charts + accept an explicit increasing value range. Their automatic 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 `AutoMinorLocator`, + `LogLocator` and `SignificantFormatter` for renderer-supported rules. +- 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. Renderer-only +materials, decorations and per-element overrides that lack public setters are +kept in a clearly marked advanced block. This boundary is explicit; the library +does not claim full Matplotlib API coverage. 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 and source/destination native pixels. diff --git a/docs/README.md b/docs/README.md index 5a13709..265ef8c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ | --- | --- | | [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 | diff --git a/docs/measurements/matplotlib-api-20261028.json b/docs/measurements/matplotlib-api-20261028.json new file mode 100644 index 0000000..301b928 --- /dev/null +++ b/docs/measurements/matplotlib-api-20261028.json @@ -0,0 +1,300 @@ +{ + "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": "66f798376027d891ef702b16f0c12f2838814fbb3dbba991b7fdc6849e65dfb2", + "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", + "desktop.py", + "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", + "widget.py" + ], + "matplotlib_contract_cases": 52, + "installed_regression_cases": 868, + "preflight_cases": 134, + "copy_and_native_typing_cases": 9, + "ci_collection": 1461, + "public_data_scenes": 20, + "frozen_png_matches": 20, + "scene_replays": [ + { + "path": "figures/01_class_profiles.png", + "api_bytes": 9145, + "api_lines": 116, + "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": 12495, + "api_lines": 190, + "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": 4536, + "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": 2558, + "api_lines": 36, + "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": 2198, + "api_lines": 31, + "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": 2400, + "api_lines": 33, + "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": 5350, + "api_lines": 73, + "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": 2647, + "api_lines": 38, + "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": 2679, + "api_lines": 38, + "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": 2227, + "api_lines": 30, + "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": 3041, + "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": 1982, + "api_lines": 28, + "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" +} diff --git a/pyproject.toml b/pyproject.toml index 28adc73..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", "jsonschema>=4.18,<5"] +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/_api_codegen.py b/python/figgyplot/_api_codegen.py new file mode 100644 index 0000000..67185d4 --- /dev/null +++ b/python/figgyplot/_api_codegen.py @@ -0,0 +1,705 @@ +"""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 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, + ) + from ._charts import family + from ._native import font_family_available + + 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, + } + 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 .figure import text_segments + + 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") + changed = any(before.get(k) != after.get(k) for k in keys) + 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 repr( + _code_tree({"segments": rich["segments"]}, segments_name)["segments"] + ) + + 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"] + if len(panels) > 1: + 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 + emit(f"{ax}.set_{dim}lim({low!r}, {high!r})", force=True) + emit( + f"{ax}.set_{dim}scale({'log' if node['scale'] == 'Logarithmic' else 'linear'!r})", + 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}))" + ) + elif node["scale"] == "Logarithmic": + emit( + f"{ax}.{dim}axis.set_major_locator({ticker_names['LogLocator']}(base={10 ** node['major_spacing']!r}))" + ) + 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})") + if node["scale"] == "Linear": + emit( + f"{axis}.set_major_locator({ticker_names['MultipleLocator']}({node['major_spacing']!r}))" + ) + elif node["scale"] == "Logarithmic": + 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"] + notation = { + "Decimal": "plain", + "Scientific": "scientific", + "Power": "power", + }[label["format"]] + emit( + f"{axis}.set_major_formatter({ticker_names['SignificantFormatter']}({label['significant_digits']}, style={notation!r}))" + ) + 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'] and 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_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("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 + + marker = {v: k for k, v in _MARKERS.items()}.get( + scatter["point_shape"], scatter["point_shape"] + ) + emit( + f"{artist}.set_markersize({_points(scatter['point_size'] * 2, dpi)!r})", + force=True, + ) + emit(f"{artist}.set_marker({marker!r})") + emit( + f"{artist}.set_facecolor({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, + ) + 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): + emit(f"{artist}.set_cmap({bar['colormap'].lower()!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, + ) + 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"]] + if legend["visible"] or rich["segments"]: + 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)})") + if legend["offset_x"] or legend["offset_y"]: + emit( + f"{ax}.get_legend().set_offset({(_points(legend['offset_x'], dpi), _points(legend['offset_y'], dpi))!r})" + ) + # 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")), + ("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) + for binding in ("matrix", "components", "err_x", "err_y"): + advanced.pop(binding, None) + if advanced: + patch = ( + series_patch.setdefault(spec["series_id"], {}) + .setdefault("render_type", {}) + .setdefault(kind, {}) + ) + from .figure import _merge + + _merge(patch, advanced) + # Colorbar axis scale/spacing are independent of the plotted X/Y axes. + # Keep their current exact API until a full public colorbar-axis adapter is + # available, including logarithmic policy and independently moved bars. + 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)) + if cfg.get("colorbar"): + patch["colorbar"] = copy.deepcopy(cfg["colorbar"]) + 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("# Figgy-specific state without a public Matplotlib counterpart.") + 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)})" + ) + 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 "" + 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} + 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})") + 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 repr(_code_tree({"segments": rich["segments"]}, runs)["segments"]) + ) + 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)})") + 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"]) + } + 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 "" + ) + 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/_chart_artist.py b/python/figgyplot/_chart_artist.py new file mode 100644 index 0000000..04297e3 --- /dev/null +++ b/python/figgyplot/_chart_artist.py @@ -0,0 +1,196 @@ +"""Named controls for bounded charts whose materials have no Matplotlib peer.""" + +import copy +import json + +from ._matplotlib import _number + + +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"]) + for item in baseline["slices"]: + 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_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 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 + } + ) + ) diff --git a/python/figgyplot/_chart_text.py b/python/figgyplot/_chart_text.py index 1aa17fc..40c6533 100644 --- a/python/figgyplot/_chart_text.py +++ b/python/figgyplot/_chart_text.py @@ -21,7 +21,7 @@ def title_options(chart): def set_title_text(chart, text=None, *, segments=None): from .figure import _segments, _text title = title_options(chart) - title['text']['segments'] = _segments(str(text)) if segments is None else copy.deepcopy(segments) + 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] diff --git a/python/figgyplot/_charts.py b/python/figgyplot/_charts.py index 57e134b..460d2f3 100644 --- a/python/figgyplot/_charts.py +++ b/python/figgyplot/_charts.py @@ -42,6 +42,7 @@ def _bounded(self, kind, categories, series, **properties): 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') with fig._lock: diff --git a/python/figgyplot/_codegen.py b/python/figgyplot/_codegen.py index a699c00..4a104d2 100644 --- a/python/figgyplot/_codegen.py +++ b/python/figgyplot/_codegen.py @@ -88,7 +88,14 @@ def expand_config(patch, defaults): return config -def settings_to_code(settings, defaults, dpi=120, variable="fig", *, compact=True): +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 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/_features.py b/python/figgyplot/_features.py index a07341b..6b57d6c 100644 --- a/python/figgyplot/_features.py +++ b/python/figgyplot/_features.py @@ -56,11 +56,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 @@ -72,6 +76,50 @@ 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())) + return _text(body['text']) if body.get('text') else '' + def _set_text_property(self,key,value): + state=self.figure.get_settings();body=next(iter(self._find(state)[2]['kind'].values())) + if body.get('text') is None:raise AttributeError('this annotation has no text') + body['text'][key]=value;self.figure.apply_settings(state) + def set_text(self,value): + from .figure import _segments + self._set_text_property('segments',_segments(value)) + def set_fontsize(self,value): + from ._matplotlib import _number + self._set_text_property('font_size',_number(value,'fontsize',positive=True)*self.figure.dpi/72) + def get_fontsize(self): + return next(iter(self._find(self.figure.get_settings())[2]['kind'].values()))['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 ._matplotlib import _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 @@ -173,7 +221,7 @@ def radar(self, categories, values, *, labels=None, colors=None, **properties): return self._bounded('radar', cats, series, **properties) @_style_runtime.styled - def annotate(self, kind, *, id=None, **properties): + 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 @@ -203,6 +251,48 @@ def annotate(self, kind, *, id=None, **properties): 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 _number,Transform + 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) @@ -213,18 +303,28 @@ def axvline(self, x=0, *, color='black', linewidth=1., id=None): 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')) @_style_runtime.styled - def text(self, x, y, text, *, coords='data', fontsize=11, color='black', id=None): + 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 _number,Transform + 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), 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): @@ -240,7 +340,7 @@ 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., @@ -249,7 +349,7 @@ def arrow(self, x, y, dx, dy, *, coords='data', color='black', linewidth=1., 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/_matplotlib.py b/python/figgyplot/_matplotlib.py new file mode 100644 index 0000000..0b78d3b --- /dev/null +++ b/python/figgyplot/_matplotlib.py @@ -0,0 +1,1795 @@ +"""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 + + +@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 _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 + + +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)], value) + else: + rich["segments"] = _segments(value) + 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) + ) + fig.apply_settings(state) + return self + + 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 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" + ), + ) + + 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"]] + return [ + cfg[k] + for k in ( + ("bottom_x", "top_x") + if self.dimension == "x" + else ("left_y", "right_y") + ) + ] + + def set_major_locator(self, locator): + from .ticker import AutoLocator, LogLocator, MultipleLocator + from . import _document + + fig = self.axes.figure + state = fig.get_settings() + nodes = self._nodes(state) + if isinstance(locator, AutoLocator): + low, high = nodes[0]["min"], nodes[0]["max"] + return getattr(self.axes, "set_" + self.dimension + "lim")(low, high) + if isinstance(locator, LogLocator): + if nodes[0]["scale"] != "Logarithmic": + raise ValueError("LogLocator requires a logarithmic axis") + for node in nodes: + node["major_spacing"] = math.log10(locator.base) + return fig.apply_settings(state) + if not hasattr(locator, "tick_values"): + raise TypeError("locator must provide tick_values(vmin,vmax)") + if nodes[0]["scale"] != "Linear": + raise NotImplementedError("MultipleLocator requires a linear axis") + values = np.asarray( + locator.tick_values(nodes[0]["min"], nodes[0]["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" + ) + 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, False, 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 + + return MultipleLocator( + self._nodes(self.axes.figure.get_settings())[0]["major_spacing"] + ) + + def set_tick_auto(self, automatic=True): + """Figgy extension: keep the current spacing but choose future auto/fixed ticks.""" + 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, + 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_major_formatter(self, formatter): + from .ticker import SignificantFormatter + + 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={ + "plain": "Decimal", + "scientific": "Scientific", + "power": "Power", + }[formatter.style], + significant_digits=formatter.precision, + ) + fig.apply_settings(state) + + +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_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 ArtistMethods: + 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 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() + 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") + for ax in self.figure.axes: + if any(s["series_id"] == self.id for s in ax._owned_series(state)): + return ax._config(state)["colorbar"] + raise ValueError("artist has no owning Axes") + + 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: + @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 in ("categorical", "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 ("categorical", "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 Matplotlib names; reject unsupported options.""" + from .figure import _color, _LINES + + if axis not in ("x", "y", "both") or which not in ("major", "minor", "both"): + raise ValueError("invalid axis or which") + 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", + } + if set(kwargs) - allowed: + raise ValueError( + "unsupported tick parameters: " + + ", ".join(sorted(set(kwargs) - allowed)) + ) + fig = self.figure + fig._require_cartesian() + state = fig.get_settings() + cfg = self._config(state) + unit = fig.dpi / 72 + sides = sides or [ + s + for s in ("bottom", "top", "left", "right") + if axis == "both" or (axis == "x") == (s in ("bottom", "top")) + ] + for side in sides: + 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] + ) + if self.overlay_id and axis == "y" and side == "left": + continue + label = node["label_style"] + if reset: + base = fig._default_config[key] + 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": + if value not in ("in", "out", "inout"): + raise ValueError("direction must be in, out or inout") + node["tick"] = {"in": "Inside", "out": "Outside", "inout": "Both"}[ + value + ] + elif name == "length": + length = _number(value, "length") * unit + if length < 0: + raise ValueError("length must be nonnegative") + if which in ("major", "both"): + node["major_tick_length"] = length + if which in ("minor", "both"): + node["minor_tick_length"] = length + elif name in ("color", "colors"): + color = _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"] = color + elif name == "labelcolor": + label["color"] = _color(value) + elif name == "width": + width = _number(value, "width") * unit + 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"] = ( + _number(value, "labelsize", positive=True) * unit + ) + elif name == "labelfontfamily": + label["label_font"] = fig._check_font(value) + elif name == "pad": + raise NotImplementedError( + "independent tick padding is not supported by this renderer" + ) + 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"] = fig._default_config[key]["tick"] + if "colors" in kwargs: + label["color"] = _color(kwargs["colors"]) + for dimension in ("x", "y"): + if axis not in (dimension, "both"): + continue + for kind in ("major", "minor"): + if which not in (kind, "both"): + continue + grid = cfg["grid"] + 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"] = _color(value) + elif field == "alpha": + grid[prefix + "_color"]["a"] = _number(value, "grid_alpha") + elif field == "width": + grid[prefix + "_width"] = ( + _number(value, "grid_linewidth") * unit + ) + else: + grid[prefix + "_style"] = _LINES.get(value, value) + 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"] + + +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): + handle = ColorbarText(self.axes, "colorbar") + handle.set(text=label, **kwargs) + return handle + + def set_clim(self, *args, **kwargs): + self.mappable.set_clim(*args, **kwargs) + + def remove(self): + fig = self.axes.figure + state = fig.get_settings() + self.axes._config(state)["colorbar"]["visible"] = False + fig.apply_settings(state) + + +class ColorbarAxis(Axis): + def _nodes(self, state): + return [self.axes._config(state)["colorbar"]["axis"]] + + @property + def label(self): + return ColorbarText(self.axes, "colorbar") + + def set_tick_params(self, which="major", reset=False, **kwargs): + fig = self.axes.figure + state = fig.get_settings() + cfg = self.axes._config(state) + axis = cfg["colorbar"]["axis"] + side = cfg["colorbar"]["side"] + # Reuse the same public tick argument translator on an isolated model + # view; the transaction changes only the actual shared colorbar axis. + key = "right_y" if side in ("Left", "Right") else "bottom_x" + proxy = _StateFigure(state, fig._default_config, fig.dpi) + ax = proxy._axes_for(self.axes.subplot_id) + ax._config(proxy._state)[key] = copy.deepcopy(axis) + ax._tick_params( + axis="y" if key == "right_y" else "x", + which=which, + reset=reset, + sides=["right" if key == "right_y" else "bottom"], + **kwargs, + ) + cfg["colorbar"]["axis"] = ax._config(proxy._state)[key] + 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: + 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") + mappable._colorbar() + owners = [ + a + for a in self.axes + if any( + s["series_id"] == mappable.id + for s in a._owned_series(self.get_settings()) + ) + ] + owner = owners[0] + 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") + with self.batch_update(): + state = self.get_settings() + 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/figure.py b/python/figgyplot/figure.py index 8b6c71b..f6dcb87 100644 --- a/python/figgyplot/figure.py +++ b/python/figgyplot/figure.py @@ -16,10 +16,12 @@ 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"} +_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"} _LINES = {"-": "Solid", "--": "Dash", ":": "Dot", "-.": "DashDot"} _LEGEND_LOCATIONS = {'upper left': 'TopLeft', 'upper right': 'TopRight', @@ -37,7 +39,19 @@ def _legend_corner(value): 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): @@ -84,6 +98,10 @@ def _warn_external(message, category): def _segments(text): # The renderer's own plain-text segments, including its mapping of # Unicode superscript/subscript characters (e.g. "10⁻³") to styled ones. + 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))) @@ -123,7 +141,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): @@ -137,10 +155,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: @@ -181,7 +208,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 @@ -199,6 +226,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 @@ -223,6 +251,7 @@ 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() @@ -242,6 +271,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'] @@ -358,8 +392,14 @@ 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. @@ -524,9 +564,13 @@ def apply_settings(self, settings, *, expected_revision=None, reset=False): 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 = copy.deepcopy(settings) + return self self._native.apply_settings(json.dumps(settings, allow_nan=False)) accepted = json.loads(self._native.settings_json()) if 'identity' in accepted: @@ -536,6 +580,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): @@ -627,6 +673,8 @@ 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: @@ -729,9 +777,7 @@ def update(self, target, *, _axes=None, **properties): 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.""" @@ -921,17 +967,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", *, compact=True): + 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 omits renderer defaults and groups rich-text runs, restoring - defaults before replay so unrelated target edits do not leak through. - ``compact=False`` emits expanded dictionaries for diagnostics. + 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, compact=compact) + 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: @@ -987,6 +1035,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()) @@ -1092,10 +1141,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() @@ -1228,7 +1279,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 @@ -1295,6 +1346,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()): @@ -1322,8 +1374,8 @@ def _set_bounds(self, dimension, low, high): state = self.figure.get_settings() kind = _family(state) if kind: - value_axis = 'x' if kind == 'boxplot' and state[kind]['direction'] == 'Horizontal' else 'y' - if kind not in ('boxplot', 'radar') or dimension != value_axis: + value_axis = 'x' if kind in ('categorical','boxplot') and state[kind]['direction'] == 'Horizontal' else 'y' + if kind not in ('categorical','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) @@ -1358,6 +1410,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) @@ -1402,6 +1455,7 @@ def donut(self, values, *, inner_radius=0.5, **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: @@ -1457,9 +1511,10 @@ def _add(self, x, y, render_type, label=None, id=None, extra_columns=(), config= 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() @@ -1501,10 +1556,10 @@ def _add_fill_pair(self, x, lower, upper, line, label, sid): fig._next_series += 2 _style_runtime._mark_used(fig) fig._changed() - return Artist(fig, sid) + return fig._artist_for(sid) @_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): + 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 @@ -1513,6 +1568,19 @@ def plot(self, x, y=None, fmt=None, *, color=None, linewidth=1.5, linestyle="-", 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: @@ -1528,7 +1596,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 @@ -1543,8 +1611,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'): @@ -1561,11 +1630,12 @@ def plot(self, x, y=None, fmt=None, *, color=None, linewidth=1.5, linestyle="-", @_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): + 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 s is marker area in points squared. An array s maps values through - size_domain/size_range (diameters in points), using area or radius scale. + 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=...) @@ -1575,11 +1645,30 @@ def scatter(self, x, y, *, s=16, color=None, c=None, cmap='viridis', vmin=None, 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: @@ -1588,13 +1677,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: @@ -1658,7 +1753,7 @@ def set_title(self, text, **kwargs): support up to 512 rich characters / eight lines, independently of their category and value labels. Text set before construction is preserved. """ - return self.figure.update("chart_title", _axes=self, text=text, **kwargs) + return self._set_label('title', text, kwargs) def set_xlabel(self, text, **kwargs): """Name the horizontal axis, before or after adding the first chart. @@ -1667,7 +1762,7 @@ def set_xlabel(self, text, **kwargs): 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.figure.update("axis_title_bottom", _axes=self, text=text, **kwargs) + return self._set_label('xlabel', text, kwargs) def set_ylabel(self, text, **kwargs): """Name the vertical axis (the category axis on horizontal bars/boxes). @@ -1676,16 +1771,27 @@ def set_ylabel(self, text, **kwargs): single-line and share label typography; Cartesian titles support fontsize, color and font. On twinx this edits only the overlay's title. """ - 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) + 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() @@ -1719,21 +1825,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) @@ -1799,17 +1903,31 @@ def invert_yaxis(self): """ return self._invert_axis('y') - def grid(self, visible=True): + 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, *, loc=None): + 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. @@ -1819,9 +1937,11 @@ def legend(self, visible=True, *, loc=None): """ kind = _family(self.figure.get_settings()) if kind in ('categorical', 'boxplot', 'radar'): - if loc is not None: + 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') - return self.figure.configure_bounded(kind, legend=bool(visible)) + 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 @@ -1836,9 +1956,33 @@ def legend(self, visible=True, *, loc=None): json.dumps(self._owned_series(state)), json.dumps(legend["content"]), json.dumps(colorbar["colormap"]) if colorbar else None)) 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 cdc6c3b..5043adc 100644 --- a/python/figgyplot/pyplot.py +++ b/python/figgyplot/pyplot.py @@ -72,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/skills/figgyplot-review/SKILL.md b/python/figgyplot/skills/figgyplot-review/SKILL.md index 4e6a95b..0c81df5 100644 --- a/python/figgyplot/skills/figgyplot-review/SKILL.md +++ b/python/figgyplot/skills/figgyplot-review/SKILL.md @@ -14,6 +14,32 @@ 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. +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. + +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 diff --git a/python/figgyplot/ticker.py b/python/figgyplot/ticker.py new file mode 100644 index 0000000..3e9b61f --- /dev/null +++ b/python/figgyplot/ticker.py @@ -0,0 +1,66 @@ +"""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", + ): + raise ValueError( + "precision must be 1..15; style plain, scientific or power" + ) diff --git a/src/lib.rs b/src/lib.rs index 93b4a11..90cef5c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1960,6 +1960,7 @@ 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)?)?; @@ -2069,6 +2070,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 { diff --git a/tests/test_blind_api_findings.py b/tests/test_blind_api_findings.py index 321ead0..a4ecaac 100644 --- a/tests/test_blind_api_findings.py +++ b/tests/test_blind_api_findings.py @@ -116,7 +116,7 @@ def test_title_text_tokens_and_code_replay_preserve_rich_text(backend, method): 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(), {'fig': target}) + 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()) 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_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 index 8e681ef..464cac8 100644 --- a/tests/test_compact_codegen.py +++ b/tests/test_compact_codegen.py @@ -39,7 +39,7 @@ def test_compact_document_replay_replaces_dirty_defaults_exactly(backend, scene) for overlay in config.get('overlays', []): overlay['y_axis']['line_width'] = 9 target.apply_settings(dirty) - code = source.to_code() + 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 @@ -72,7 +72,7 @@ def build(fig, offset): 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() + 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}) @@ -92,7 +92,7 @@ def test_styled_text_runs_and_unicode_replay_without_character_dictionary_dumps( 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) + 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() 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_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_interaction_parity.py b/tests/test_interaction_parity.py index 1e534b0..d133664 100644 --- a/tests/test_interaction_parity.py +++ b/tests/test_interaction_parity.py @@ -63,12 +63,13 @@ def test_compact_python_copy_in_both_hosts_replays_the_complete_document(tmp_pat inputs = Inputs(handle) inputs.click('copy') code = subprocess.check_output(['xclip','-selection','clipboard','-o'],text=True,timeout=3) - assert code == fig.to_code() and 'reset=True' in code + 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() == fig.get_settings() + 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: @@ -80,10 +81,10 @@ def test_compact_python_copy_in_both_hosts_replays_the_complete_document(tmp_pat page.evaluate(ROOT+'.flush()') execute('await fig.sync()') code=page.locator('textarea[data-code]').input_value() - assert code and 'reset=True' in code + 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()==fig.get_settings()\nother.close()") + 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): 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_matplotlib_api.py b/tests/test_matplotlib_api.py new file mode 100644 index 0000000..1b803a9 --- /dev/null +++ b/tests/test_matplotlib_api.py @@ -0,0 +1,563 @@ +"""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 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 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) 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_renderer_018_compat.py b/tests/test_renderer_018_compat.py index 44bbc8d..c2c134b 100644 --- a/tests/test_renderer_018_compat.py +++ b/tests/test_renderer_018_compat.py @@ -52,7 +52,7 @@ def test_heatmap_legend_updates_colormap_and_replays_without_losing_text(backend after = source.get_settings()['config']['legend']['content']['segments'] assert before != after assert 'Temperature' in ''.join(segment['text'] for segment in after) - exec(source.to_code(), {'fig': target}) + exec(source.to_code(mode='exact'), {'fig': target}) assert target.get_settings() == source.get_settings() if backend == 'native': assert np.array_equal(source.render_rgba(), target.render_rgba()) diff --git a/tests/test_renderer_018_features.py b/tests/test_renderer_018_features.py index 66f4688..a56dafa 100644 --- a/tests/test_renderer_018_features.py +++ b/tests/test_renderer_018_features.py @@ -52,7 +52,7 @@ def test_features_round_trip_and_replay_without_reupload(backend, feature): source.apply_settings(state) assert source.revision == revision uploads = target.info.get('column_uploads') - exec(source.to_code(), {'fig': target}) + exec(source.to_code(mode='exact'), {'fig': target}) assert target.get_settings() == state assert target.info.get('column_uploads') == uploads if backend == 'native': @@ -267,7 +267,7 @@ def test_mixed_panel_styles_replay_identically_at_multiple_export_scales(order): ax = figure.subplots['line'].axes ax.plot([0, 1, 2], [1, 2, 1], id='signal') ax.set_draw_style('sketch') - exec(source.to_code(), {'fig': target}) + exec(source.to_code(mode='exact'), {'fig': target}) assert source.get_settings() == target.get_settings() for scale in (1, 1.25, 2): first, second = source.render_rgba(scale=scale), target.render_rgba(scale=scale) @@ -330,7 +330,7 @@ def test_tick_intent_replays_by_owner_and_rejects_invalid_metadata(backend): state=fig.get_settings() assert state['identity']['tick_modes']['second']=={'bottom_x':'Fixed'} assert state['identity']['tick_modes']['pressure']=={'y_axis':'Fixed'} - namespace={'fig':fig};exec(fig.to_code(),namespace) + namespace={'fig':fig};exec(fig.to_code(mode='exact'),namespace) assert fig.get_settings()==state for owner,key,mode in [('missing','bottom_x','Auto'),('second','y_axis','Auto'),('pressure','y_axis','Unknown')]: bad=copy.deepcopy(state) 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()) From 547a1ab8a5851bcc2c61878b26bf25983eb7acca Mon Sep 17 00:00:00 2001 From: youo he Date: Wed, 28 Oct 2026 07:50:44 +0000 Subject: [PATCH 16/23] Harden public API export and isolate editor startup failures --- PYPI_README.md | 14 +- README.md | 13 +- docs/MATPLOTLIB_API.md | 12 +- .../measurements/matplotlib-api-20261028.json | 92 +++++--- python/figgyplot/_api_codegen.py | 59 +++-- python/figgyplot/_codegen.py | 20 ++ python/figgyplot/_matplotlib.py | 127 +++++++---- python/figgyplot/editor.js | 31 ++- python/figgyplot/editor.py | 4 +- python/figgyplot/figure.py | 2 +- python/figgyplot/widget.py | 4 +- src/editor.rs | 2 +- src/radial_editor.rs | 2 +- tests/jupyter_harness.py | 8 + tests/test_browser.py | 27 ++- tests/test_feature_visual_browser.py | 7 + tests/test_matplotlib_api.py | 214 ++++++++++++++++++ tests/test_radial_desktop.py | 13 +- 18 files changed, 540 insertions(+), 111 deletions(-) diff --git a/PYPI_README.md b/PYPI_README.md index c98d740..ff75b30 100644 --- a/PYPI_README.md +++ b/PYPI_README.md @@ -106,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. @@ -115,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 375d791..641ced2 100644 --- a/README.md +++ b/README.md @@ -320,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 @@ -339,7 +338,7 @@ 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. diff --git a/docs/MATPLOTLIB_API.md b/docs/MATPLOTLIB_API.md index 0658132..512671c 100644 --- a/docs/MATPLOTLIB_API.md +++ b/docs/MATPLOTLIB_API.md @@ -68,8 +68,10 @@ belong to the parent panel; subplot annotations are rejected. 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 `AutoMinorLocator`, - `LogLocator` and `SignificantFormatter` for renderer-supported rules. + 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. @@ -129,3 +131,9 @@ 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 and source/destination native pixels. + +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 remain explicit +advanced settings when they cannot be expressed by the public subset. diff --git a/docs/measurements/matplotlib-api-20261028.json b/docs/measurements/matplotlib-api-20261028.json index 301b928..107400f 100644 --- a/docs/measurements/matplotlib-api-20261028.json +++ b/docs/measurements/matplotlib-api-20261028.json @@ -4,7 +4,7 @@ "reference_matplotlib": "3.11.2", "supported_contract": "docs/MATPLOTLIB_API.md", "runtime_dependency": "NumPy only; tested in a separate environment without Matplotlib", - "wheel_sha256": "66f798376027d891ef702b16f0c12f2838814fbb3dbba991b7fdc6849e65dfb2", + "wheel_sha256": "c4ab4052e9f463ea799598cd32652ff0f2ee4ffb1947cc6f9a9a62280a235e88", "installed_source_files_checked": [ "__init__.py", "_api_codegen.py", @@ -23,7 +23,10 @@ "_review_summary.py", "_style_presets.py", "_style_runtime.py", + "assets/figgy.js", "desktop.py", + "editor.css", + "editor.js", "editor.py", "figure.py", "notebook.py", @@ -34,20 +37,21 @@ "skills/figgyplot-review/SKILL.md", "styles.py", "ticker.py", + "webgpu.js", "widget.py" ], - "matplotlib_contract_cases": 52, - "installed_regression_cases": 868, + "matplotlib_contract_cases": 86, + "initial_installed_regression_cases": 868, "preflight_cases": 134, "copy_and_native_typing_cases": 9, - "ci_collection": 1461, + "ci_collection": 1497, "public_data_scenes": 20, "frozen_png_matches": 20, "scene_replays": [ { "path": "figures/01_class_profiles.png", - "api_bytes": 9145, - "api_lines": 116, + "api_bytes": 8777, + "api_lines": 112, "exact_bytes": 16401, "uses_advanced_block": true, "api_config_series_pixels_equal": true, @@ -58,8 +62,8 @@ }, { "path": "figures/02_record_heatmaps.png", - "api_bytes": 12495, - "api_lines": 190, + "api_bytes": 12127, + "api_lines": 186, "exact_bytes": 29163, "uses_advanced_block": true, "api_config_series_pixels_equal": true, @@ -70,7 +74,7 @@ }, { "path": "figures/03_pca_overlap.png", - "api_bytes": 4536, + "api_bytes": 4432, "api_lines": 58, "exact_bytes": 6740, "uses_advanced_block": true, @@ -94,8 +98,8 @@ }, { "path": "plots/01_score_distribution.png", - "api_bytes": 2558, - "api_lines": 36, + "api_bytes": 1496, + "api_lines": 28, "exact_bytes": 2252, "uses_advanced_block": false, "api_config_series_pixels_equal": true, @@ -106,8 +110,8 @@ }, { "path": "plots/02_lunch_and_score.png", - "api_bytes": 2198, - "api_lines": 31, + "api_bytes": 1224, + "api_lines": 25, "exact_bytes": 1983, "uses_advanced_block": false, "api_config_series_pixels_equal": true, @@ -130,8 +134,8 @@ }, { "path": "plots/01_consumption_history.png", - "api_bytes": 2400, - "api_lines": 33, + "api_bytes": 1338, + "api_lines": 25, "exact_bytes": 1852, "uses_advanced_block": false, "api_config_series_pixels_equal": true, @@ -154,8 +158,8 @@ }, { "path": "plots/03_level_and_within_year_range.png", - "api_bytes": 5350, - "api_lines": 73, + "api_bytes": 4423, + "api_lines": 68, "exact_bytes": 6161, "uses_advanced_block": true, "api_config_series_pixels_equal": true, @@ -166,8 +170,8 @@ }, { "path": "artifacts/01_strength_distribution.png", - "api_bytes": 2647, - "api_lines": 38, + "api_bytes": 1585, + "api_lines": 30, "exact_bytes": 2317, "uses_advanced_block": false, "api_config_series_pixels_equal": true, @@ -190,8 +194,8 @@ }, { "path": "artifacts/03_cement_strength.png", - "api_bytes": 2679, - "api_lines": 38, + "api_bytes": 1758, + "api_lines": 33, "exact_bytes": 2458, "uses_advanced_block": false, "api_config_series_pixels_equal": true, @@ -202,8 +206,8 @@ }, { "path": "artifacts/04_exact_age_summaries.png", - "api_bytes": 2227, - "api_lines": 30, + "api_bytes": 1211, + "api_lines": 23, "exact_bytes": 2753, "uses_advanced_block": false, "api_config_series_pixels_equal": true, @@ -238,7 +242,7 @@ }, { "path": "plots/03_calories_sodium.png", - "api_bytes": 3041, + "api_bytes": 2315, "api_lines": 49, "exact_bytes": 4067, "uses_advanced_block": false, @@ -250,8 +254,8 @@ }, { "path": "plots/01_salary_vs_hits.png", - "api_bytes": 1982, - "api_lines": 28, + "api_bytes": 1006, + "api_lines": 22, "exact_bytes": 2035, "uses_advanced_block": false, "api_config_series_pixels_equal": true, @@ -296,5 +300,39 @@ "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" + "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 + }, + "final_focused_suite": { + "cases": 274, + "passed": 274, + "failures": 0, + "skipped": 0 + } } diff --git a/python/figgyplot/_api_codegen.py b/python/figgyplot/_api_codegen.py index 67185d4..b832dae 100644 --- a/python/figgyplot/_api_codegen.py +++ b/python/figgyplot/_api_codegen.py @@ -49,7 +49,7 @@ def settings_to_api_code(settings, defaults, dpi=120, variable="fig"): SignificantFormatter, ) from ._charts import family - from ._native import font_family_available + from ._native import font_family_available, canonical_style_state_json if ( not isinstance(variable, str) @@ -91,6 +91,15 @@ def emit(statement, force=False): after = model._state keys = ("config", "series", "background") 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) @@ -244,26 +253,35 @@ def text(target, method, node): if node["inverted"]: low, high = high, low emit(f"{axis}.set_view_interval({low!r}, {high!r})") - if node["scale"] == "Linear": - emit( - f"{axis}.set_major_locator({ticker_names['MultipleLocator']}({node['major_spacing']!r}))" - ) - elif node["scale"] == "Logarithmic": - emit( - f"{axis}.set_major_locator({ticker_names['LogLocator']}(base={10 ** node['major_spacing']!r}))" - ) + # Independent opposite-axis scales have no public setter yet. + # Leave their scale and spacing together in the advanced residual. + modeled_scale = eval(axis, namespace)._nodes(model.get_settings())[0][ + "scale" + ] + if node["scale"] == modeled_scale: + if node["scale"] == "Linear": + emit( + f"{axis}.set_major_locator({ticker_names['MultipleLocator']}({node['major_spacing']!r}))" + ) + elif node["scale"] == "Logarithmic": + 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"] - notation = { - "Decimal": "plain", - "Scientific": "scientific", - "Power": "power", - }[label["format"]] - emit( - f"{axis}.set_major_formatter({ticker_names['SignificantFormatter']}({label['significant_digits']}, style={notation!r}))" + notation = ( + {"Decimal": "plain", "Scientific": "scientific", "Power": "power"}.get( + label["format"] + ) + if isinstance(label["format"], str) + else None ) + if notation is not None: + emit( + f"{axis}.set_major_formatter({ticker_names['SignificantFormatter']}({label['significant_digits']}, style={notation!r}))" + ) args = [ f"labelsize={_points(label['font_size'], dpi)!r}", f"labelcolor={color(label['color'])!r}", @@ -326,11 +344,13 @@ def text(target, method, node): marker = {v: k for k, v in _MARKERS.items()}.get( scatter["point_shape"], 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) emit( f"{artist}.set_markersize({_points(scatter['point_size'] * 2, dpi)!r})", force=True, ) - emit(f"{artist}.set_marker({marker!r})") emit( f"{artist}.set_facecolor({color(scatter['point_color'])!r})", force=True ) @@ -371,7 +391,10 @@ def text(target, method, node): ) bar = owner._config(settings)["colorbar"] if isinstance(bar["colormap"], str): - emit(f"{artist}.set_cmap({bar['colormap'].lower()!r})", force=True) + 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, diff --git a/python/figgyplot/_codegen.py b/python/figgyplot/_codegen.py index 4a104d2..372d58f 100644 --- a/python/figgyplot/_codegen.py +++ b/python/figgyplot/_codegen.py @@ -9,6 +9,26 @@ import pprint +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) diff --git a/python/figgyplot/_matplotlib.py b/python/figgyplot/_matplotlib.py index 0b78d3b..cf73a1a 100644 --- a/python/figgyplot/_matplotlib.py +++ b/python/figgyplot/_matplotlib.py @@ -62,6 +62,48 @@ def _number(value, name, *, positive=False): return value +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.""" @@ -435,53 +477,24 @@ def _nodes(self, state): ] if self.dimension == "y" and self.axes.overlay_id: return [self.axes._overlay(state)["y_axis"]] - return [ - cfg[k] - for k in ( - ("bottom_x", "top_x") - if self.dimension == "x" - else ("left_y", "right_y") - ) - ] + 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, LogLocator, MultipleLocator + from .ticker import AutoLocator from . import _document fig = self.axes.figure state = fig.get_settings() nodes = self._nodes(state) - if isinstance(locator, AutoLocator): - low, high = nodes[0]["min"], nodes[0]["max"] - return getattr(self.axes, "set_" + self.dimension + "lim")(low, high) - if isinstance(locator, LogLocator): - if nodes[0]["scale"] != "Logarithmic": - raise ValueError("LogLocator requires a logarithmic axis") - for node in nodes: - node["major_spacing"] = math.log10(locator.base) - return fig.apply_settings(state) - if not hasattr(locator, "tick_values"): - raise TypeError("locator must provide tick_values(vmin,vmax)") - if nodes[0]["scale"] != "Linear": - raise NotImplementedError("MultipleLocator requires a linear axis") - values = np.asarray( - locator.tick_values(nodes[0]["min"], nodes[0]["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" - ) + step = _locator_spacing(locator, nodes[0], self) for node in nodes: node["major_spacing"] = step key = ( @@ -499,7 +512,13 @@ def set_major_locator(self, locator): 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, False, spacing=step + state, + self.axes.subplot_id, + self.axes.overlay_id, + key, + isinstance(locator, AutoLocator), + paired=self.side is None, + spacing=step, ) fig.apply_settings(state) @@ -526,10 +545,13 @@ def set_ticklabel_offset(self, offset): fig.apply_settings(state) def get_major_locator(self): - from .ticker import MultipleLocator + from .ticker import MultipleLocator, LogLocator - return MultipleLocator( - self._nodes(self.axes.figure.get_settings())[0]["major_spacing"] + 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): @@ -1516,6 +1538,23 @@ class ColorbarAxis(Axis): def _nodes(self, state): return [self.axes._config(state)["colorbar"]["axis"]] + def set_major_locator(self, locator): + fig = self.axes.figure + state = fig.get_settings() + node = self._nodes(state)[0] + node["major_spacing"] = _locator_spacing(locator, node, self) + 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") 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 f6dcb87..a7d461c 100644 --- a/python/figgyplot/figure.py +++ b/python/figgyplot/figure.py @@ -1258,7 +1258,7 @@ def text_segments(text, **style): 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 diff --git a/python/figgyplot/widget.py b/python/figgyplot/widget.py index c31816b..a23fd25 100644 --- a/python/figgyplot/widget.py +++ b/python/figgyplot/widget.py @@ -120,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'], diff --git a/src/editor.rs b/src/editor.rs index 3462e6e..1f9c1ca 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -594,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::() }) diff --git a/src/radial_editor.rs b/src/radial_editor.rs index d9a5d1d..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) diff --git a/tests/jupyter_harness.py b/tests/jupyter_harness.py index e34fa6e..a298a50 100644 --- a/tests/jupyter_harness.py +++ b/tests/jupyter_harness.py @@ -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/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_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_matplotlib_api.py b/tests/test_matplotlib_api.py index 1b803a9..59c251d 100644 --- a/tests/test_matplotlib_api.py +++ b/tests/test_matplotlib_api.py @@ -561,3 +561,217 @@ def test_markers_can_be_added_to_a_resident_line_through_artist_set(backend): ] == [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_radial_desktop.py b/tests/test_radial_desktop.py index 2fd6b5c..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 @@ -177,7 +178,8 @@ def test_radial_native_edit_copy_selection_and_no_readback(tmp_path): if result.returncode==0 and copied==expected:break assert time.monotonic() Date: Wed, 28 Oct 2026 08:48:59 +0000 Subject: [PATCH 17/23] Preserve notebook property drafts across delayed acknowledgements --- docs/EDITOR_COMMANDS.md | 8 ++ .../measurements/matplotlib-api-20261028.json | 21 ++++- python/figgyplot/webgpu.js | 89 +++++++++++++++---- tests/jupyter_harness.py | 2 +- tests/test_harness_diagnostics.py | 38 ++++++++ tests/test_numeric_formatting.py | 58 ++++++++++++ 6 files changed, 195 insertions(+), 21 deletions(-) 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/measurements/matplotlib-api-20261028.json b/docs/measurements/matplotlib-api-20261028.json index 107400f..a04c487 100644 --- a/docs/measurements/matplotlib-api-20261028.json +++ b/docs/measurements/matplotlib-api-20261028.json @@ -4,7 +4,7 @@ "reference_matplotlib": "3.11.2", "supported_contract": "docs/MATPLOTLIB_API.md", "runtime_dependency": "NumPy only; tested in a separate environment without Matplotlib", - "wheel_sha256": "c4ab4052e9f463ea799598cd32652ff0f2ee4ffb1947cc6f9a9a62280a235e88", + "wheel_sha256": "b5ad585cf96f62917521efefa510d533afbfbac199b4e0b6e4f9b657eba402e4", "installed_source_files_checked": [ "__init__.py", "_api_codegen.py", @@ -42,9 +42,9 @@ ], "matplotlib_contract_cases": 86, "initial_installed_regression_cases": 868, - "preflight_cases": 134, + "preflight_cases": 135, "copy_and_native_typing_cases": 9, - "ci_collection": 1497, + "ci_collection": 1502, "public_data_scenes": 20, "frozen_png_matches": 20, "scene_replays": [ @@ -327,12 +327,25 @@ } ], "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 + "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/python/figgyplot/webgpu.js b/python/figgyplot/webgpu.js index e39f576..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,7 +85,7 @@ 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. @@ -482,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;}); @@ -550,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 ?? ''; @@ -726,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(''); @@ -745,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)=>{ @@ -764,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??[]; @@ -1032,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)});} @@ -1041,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{ diff --git a/tests/jupyter_harness.py b/tests/jupyter_harness.py index a298a50..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(); } diff --git a/tests/test_harness_diagnostics.py b/tests/test_harness_diagnostics.py index 3f34628..b7e479f 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,37 @@ 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 + import figgyplot + source=Path(figgyplot.__file__).with_name('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_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()') From b78a39a3028b636ac0313bf6bb973cf82a73ec30 Mon Sep 17 00:00:00 2001 From: youo he Date: Wed, 28 Oct 2026 08:52:23 +0000 Subject: [PATCH 18/23] Run property rebase preflight without an installed wheel --- tests/test_harness_diagnostics.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_harness_diagnostics.py b/tests/test_harness_diagnostics.py index b7e479f..d88fb30 100644 --- a/tests/test_harness_diagnostics.py +++ b/tests/test_harness_diagnostics.py @@ -217,8 +217,7 @@ def test_capture_requires_current_surface_completion_and_propagates_display_erro def test_property_draft_rebase_preserves_unrelated_values_and_rejects_conflicts(): from pathlib import Path - import figgyplot - source=Path(figgyplot.__file__).with_name('webgpu.js').read_text()+'\nexport {propertyDraft,rebasePropertyDraft};' + 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')); From 0b903124e959f435968ae2a6a56245b48d901253 Mon Sep 17 00:00:00 2001 From: youo he Date: Wed, 28 Oct 2026 11:01:26 +0000 Subject: [PATCH 19/23] Expose scoped appearance APIs across supported chart features --- README.md | 8 +- docs/MATPLOTLIB_API.md | 150 +++- .../public-appearance-api-20261028.json | 293 +++++++ python/figgyplot/__init__.py | 7 + python/figgyplot/_annotation_api.py | 195 +++++ python/figgyplot/_annotation_codegen.py | 59 ++ python/figgyplot/_api_codegen.py | 772 ++++++++++++++++-- python/figgyplot/_appearance.py | 156 ++++ python/figgyplot/_artist_appearance.py | 623 ++++++++++++++ python/figgyplot/_chart_artist.py | 437 +++++++++- python/figgyplot/_charts.py | 6 +- python/figgyplot/_features.py | 32 +- python/figgyplot/_field_api.py | 165 ++++ python/figgyplot/_markers.py | 25 + python/figgyplot/_matplotlib.py | 460 ++++++++++- python/figgyplot/_richtext.py | 100 +++ python/figgyplot/figure.py | 31 +- .../skills/figgyplot-review/SKILL.md | 22 + python/figgyplot/ticker.py | 81 +- tests/test_public_appearance.py | 733 +++++++++++++++++ 20 files changed, 4250 insertions(+), 105 deletions(-) create mode 100644 docs/measurements/public-appearance-api-20261028.json create mode 100644 python/figgyplot/_annotation_api.py create mode 100644 python/figgyplot/_annotation_codegen.py create mode 100644 python/figgyplot/_appearance.py create mode 100644 python/figgyplot/_artist_appearance.py create mode 100644 python/figgyplot/_field_api.py create mode 100644 python/figgyplot/_markers.py create mode 100644 python/figgyplot/_richtext.py create mode 100644 tests/test_public_appearance.py diff --git a/README.md b/README.md index 641ced2..bb07f8c 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ 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 and supported subset](docs/MATPLOTLIB_API.md) for object +[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: @@ -378,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 diff --git a/docs/MATPLOTLIB_API.md b/docs/MATPLOTLIB_API.md index 512671c..6b5d55b 100644 --- a/docs/MATPLOTLIB_API.md +++ b/docs/MATPLOTLIB_API.md @@ -119,10 +119,10 @@ 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. Renderer-only -materials, decorations and per-element overrides that lack public setters are -kept in a clearly marked advanced block. This boundary is explicit; the library -does not claim full Matplotlib API coverage. Style constants are shortened only +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. @@ -135,5 +135,143 @@ Matplotlib properties and source/destination native pixels. 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 remain explicit -advanced settings when they cannot be expressed by the public subset. +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. +`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/measurements/public-appearance-api-20261028.json b/docs/measurements/public-appearance-api-20261028.json new file mode 100644 index 0000000..2354413 --- /dev/null +++ b/docs/measurements/public-appearance-api-20261028.json @@ -0,0 +1,293 @@ +{ + "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": 119, + "matplotlib_api_cases": 86, + "installed_focused_passed": 934, + "installed_focused_ui_skips": 5, + "clean_preflight_passed": 135, + "native_jupyter_clipboard_and_typing_passed": 9, + "full_ci_collection": 1621, + "relaxed_threshold_or_recovery_oracles": 0, + "numpy_only_runtime_without_matplotlib": true, + "paired_wheel_source_files": 41, + "final_public_and_matplotlib_passed": 205, + "additional_fillstyle_cases": 2 + }, + "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" + ], + "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": 85 + }, + { + "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": 57 + }, + { + "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": 32 + }, + { + "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": 37 + }, + { + "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": 29 + }, + { + "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": 57 + }, + { + "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": 37 + }, + { + "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": 50 + }, + { + "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": 32 + }, + { + "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": 91 + }, + { + "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": 34 + }, + { + "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 + } + ] +} diff --git a/python/figgyplot/__init__.py b/python/figgyplot/__init__.py index 5b3ccbe..17e809c 100644 --- a/python/figgyplot/__init__.py +++ b/python/figgyplot/__init__.py @@ -9,3 +9,10 @@ __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..74ac522 --- /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 ._matplotlib import _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 ._matplotlib import _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 ._matplotlib import _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 ._matplotlib import _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 ._matplotlib import _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 ._matplotlib import _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 index b832dae..7b026a7 100644 --- a/python/figgyplot/_api_codegen.py +++ b/python/figgyplot/_api_codegen.py @@ -39,6 +39,35 @@ def _color_literal(value): 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 @@ -47,6 +76,7 @@ def settings_to_api_code(settings, defaults, dpi=120, variable="fig"): LogLocator, AutoMinorLocator, SignificantFormatter, + TimestampFormatter, ) from ._charts import family from ._native import font_family_available, canonical_style_state_json @@ -69,6 +99,7 @@ def settings_to_api_code(settings, defaults, dpi=120, variable="fig"): "LogLocator": LogLocator, "AutoMinorLocator": AutoMinorLocator, "SignificantFormatter": SignificantFormatter, + "TimestampFormatter": TimestampFormatter, } ticker_names = { name: ("_fg_" + name if variable == name else name) for name in ticker_classes @@ -77,7 +108,25 @@ def settings_to_api_code(settings, defaults, dpi=120, variable="fig"): 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" @@ -119,9 +168,33 @@ def rich_arg(rich): text = _text(rich) if rich["segments"] == _segments(text): return repr(text) - return repr( - _code_tree({"segments": rich["segments"]}, segments_name)["segments"] - ) + 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 @@ -253,40 +326,29 @@ def text(target, method, node): if node["inverted"]: low, high = high, low emit(f"{axis}.set_view_interval({low!r}, {high!r})") - # Independent opposite-axis scales have no public setter yet. - # Leave their scale and spacing together in the advanced residual. - modeled_scale = eval(axis, namespace)._nodes(model.get_settings())[0][ - "scale" - ] - if node["scale"] == modeled_scale: - if node["scale"] == "Linear": - emit( - f"{axis}.set_major_locator({ticker_names['MultipleLocator']}({node['major_spacing']!r}))" - ) - elif node["scale"] == "Logarithmic": - 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}))" + f"{axis}.set_scale({'log' if node['scale'] == 'Logarithmic' else 'linear'!r})" ) - label = node["label_style"] - notation = ( - {"Decimal": "plain", "Scientific": "scientific", "Power": "power"}.get( - label["format"] + if node["scale"] == "Linear": + emit( + f"{axis}.set_major_locator({ticker_names['MultipleLocator']}({node['major_spacing']!r}))" ) - if isinstance(label["format"], str) - else None - ) - if notation is not None: + else: emit( - f"{axis}.set_major_formatter({ticker_names['SignificantFormatter']}({label['significant_digits']}, style={notation!r}))" + 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'] and label['visible'])!r}", + f"label{side}={bool(label['label_visible'])!r}", ] if node["tick"] != "None": args.append( @@ -299,6 +361,7 @@ def text(target, method, node): 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})" @@ -330,6 +393,11 @@ def text(target, method, node): 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", @@ -341,18 +409,21 @@ def text(target, method, node): scatter = body["scatter"] from .figure import _MARKERS - marker = {v: k for k, v in _MARKERS.items()}.get( - scatter["point_shape"], scatter["point_shape"] - ) + 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}.set_facecolor({color(scatter['point_color'])!r})", force=True + f"{artist}.markers.set(color={color(scatter['point_color'])!r})", + force=True, ) if "bar" in body: bar = body["bar"] @@ -376,6 +447,122 @@ def text(target, method, node): 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") @@ -403,6 +590,27 @@ def text(target, method, node): 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})", + 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: @@ -419,30 +627,235 @@ def text(target, method, node): "BottomLeft": "lower left", "BottomRight": "lower right", }[legend["corner"]] - if legend["visible"] or rich["segments"]: - 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)})") + 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}.get_legend().set_offset({(_points(legend['offset_x'], dpi), _points(legend['offset_y'], dpi))!r})" + 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})" + ) + 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 @@ -465,7 +878,17 @@ def text(target, method, node): kind, body = next(iter(spec["render_type"].items())) advanced = copy.deepcopy(body) for part, keys in [ - ("line", ("line_color", "line_width", "line_style", "interpolation")), + ( + "line", + ( + "line_color", + "line_width", + "line_style", + "interpolation", + "fill", + "fill_color", + ), + ), ("scatter", ("point_color", "point_size", "point_shape")), ( "bar", @@ -494,8 +917,36 @@ def text(target, method, node): 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"], {}) @@ -505,9 +956,8 @@ def text(target, method, node): from .figure import _merge _merge(patch, advanced) - # Colorbar axis scale/spacing are independent of the plotted X/Y axes. - # Keep their current exact API until a full public colorbar-axis adapter is - # available, including logarithmic policy and independently moved bars. + # 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"]] + [ @@ -515,8 +965,6 @@ def text(target, method, node): ] for index, (baseline, cfg) in enumerate(zip(restored_panels, panels)): patch = config_patch if index == 0 else _prune_empty(_diff(baseline, cfg)) - if cfg.get("colorbar"): - patch["colorbar"] = copy.deepcopy(cfg["colorbar"]) if index and patch: panel_patches.append((index, patch)) if config_patch or series_patch: @@ -525,7 +973,7 @@ def text(target, method, node): parts.append("config=" + fmt(config_patch)) if series_patch: parts.append("series=" + fmt(series_patch)) - lines.append("# Figgy-specific state without a public Matplotlib counterpart.") + 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"] @@ -538,6 +986,15 @@ def text(target, method, node): 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 ( @@ -567,6 +1024,19 @@ def _bounded_api_code(settings, defaults, dpi, variable): 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 @@ -607,6 +1077,40 @@ def emit(code, force=False): 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 = ( @@ -667,11 +1171,7 @@ def emit(code, force=False): for segment in rebuilt: segment[flag] = True styles.append(f"{prop}={value!r}") - value = ( - repr(plain) - if rebuilt == rich["segments"] - else repr(_code_tree({"segments": rich["segments"]}, runs)["segments"]) - ) + value = repr(plain) if rebuilt == rich["segments"] else rich_arg(rich) args = [ value, f"fontsize={_points(rich['font_size'], dpi)!r}", @@ -685,6 +1185,141 @@ def emit(code, force=False): 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] @@ -698,6 +1333,11 @@ def appearance_only(value): 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")} @@ -719,6 +1359,8 @@ def appearance_only(value): 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" diff --git a/python/figgyplot/_appearance.py b/python/figgyplot/_appearance.py new file mode 100644 index 0000000..95b7024 --- /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 ._matplotlib import _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..539598f --- /dev/null +++ b/python/figgyplot/_artist_appearance.py @@ -0,0 +1,623 @@ +"""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 ._matplotlib import _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 ._matplotlib import _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 ._features 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 = next( + ax + for ax in fig.axes + if any( + s["series_id"] == self.artist.id for s in ax._owned_series(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 ._matplotlib import _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 ._matplotlib import _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 ._features 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 = next( + ax + for ax in fig.axes + if any( + s["series_id"] == self.artist.id for s in ax._owned_series(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 index 04297e3..0598052 100644 --- a/python/figgyplot/_chart_artist.py +++ b/python/figgyplot/_chart_artist.py @@ -5,6 +5,39 @@ from ._matplotlib import _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. @@ -40,7 +73,11 @@ def reset_style(self): ) if self.kind == "radial": baseline["slices"] = copy.deepcopy(old["slices"]) - for item in baseline["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: @@ -53,6 +90,16 @@ def reset_style(self): 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 @@ -77,6 +124,199 @@ def edit(chart): 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 @@ -194,3 +434,198 @@ def set_gap(self, points): } ) ) + + +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/_charts.py b/python/figgyplot/_charts.py index 460d2f3..f5a530d 100644 --- a/python/figgyplot/_charts.py +++ b/python/figgyplot/_charts.py @@ -20,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: diff --git a/python/figgyplot/_features.py b/python/figgyplot/_features.py index 6b57d6c..28d6778 100644 --- a/python/figgyplot/_features.py +++ b/python/figgyplot/_features.py @@ -19,6 +19,19 @@ def colorbar(config, values, cmap, vmin, vmax): _set_colorbar(config,cmap,low,high) +_COLOR_UNSET = object() + + +def update_color_scale(config, *, cmap=_COLOR_UNSET, vmin=None, vmax=None): + """Shared mutation for mapped artists and standalone color-scale objects.""" + from .figure import _colormap + from ._matplotlib import _number + bar=config['colorbar'] + if cmap is not _COLOR_UNSET:bar['colormap']=_colormap(cmap) + if vmin is not None:bar['axis']['min']=_number(vmin,'vmin') + if vmax is not None:bar['axis']['max']=_number(vmax,'vmax') + + def _set_colorbar(config,cmap,low,high): """Update the shared scale without resetting accepted colorbar appearance.""" from .figure import _colormap @@ -31,7 +44,10 @@ def _set_colorbar(config,cmap,low,high): config['colorbar'] = bar -class Annotation: +from ._annotation_api import AnnotationEditing + + +class Annotation(AnnotationEditing): def __init__(self, figure, id): self.figure, self.id = figure, id @@ -86,19 +102,21 @@ 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())) - return _text(body['text']) if body.get('text') else '' + 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())) - if body.get('text') is None:raise AttributeError('this annotation has no text') - body['text'][key]=value;self.figure.apply_settings(state) + 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)) + self._set_text_property('segments',_segments(value, dpi=self.figure.dpi)) def set_fontsize(self,value): from ._matplotlib import _number self._set_text_property('font_size',_number(value,'fontsize',positive=True)*self.figure.dpi/72) def get_fontsize(self): - return next(iter(self._find(self.figure.get_settings())[2]['kind'].values()))['text']['font_size']*72/self.figure.dpi + 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 @@ -316,7 +334,7 @@ def text(self, x, y, text, *, transform=None, coords='data', fontsize=11, color= 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(text), font_size=_number(fontsize,'fontsize',positive=True)*self.figure.dpi/72, color=_color(color)) + 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 diff --git a/python/figgyplot/_field_api.py b/python/figgyplot/_field_api.py new file mode 100644 index 0000000..37e2da9 --- /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 ._matplotlib import _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 ._matplotlib import _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 ._matplotlib import _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 index cf73a1a..1179af6 100644 --- a/python/figgyplot/_matplotlib.py +++ b/python/figgyplot/_matplotlib.py @@ -240,9 +240,11 @@ def set(self, **properties): if family(state): from ._chart_text import set_title_text - set_title_text(state[family(state)], value) + set_title_text( + state[family(state)], segments=_segments(value, dpi=fig.dpi) + ) else: - rich["segments"] = _segments(value) + rich["segments"] = _segments(value, dpi=fig.dpi) elif key in ("fontweight", "fontstyle"): if key == "fontweight": if value not in ("normal", "bold", 400, 700): @@ -333,6 +335,38 @@ def set_layout_height(self, height): 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 @@ -431,6 +465,10 @@ def label(self): ), ) + @property + def line(self): + return AxisLine(self) + def set_label_text(self, label, **kwargs): self.label.set(text=label, **kwargs) return self.label @@ -601,8 +639,46 @@ def set_minor_locator(self, locator): 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 + from .ticker import SignificantFormatter, TimestampFormatter if not isinstance(formatter, SignificantFormatter): raise NotImplementedError( @@ -612,16 +688,30 @@ def set_major_formatter(self, formatter): state = fig.get_settings() for node in self._nodes(state): node["label_style"].update( - format={ + 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 @@ -686,6 +776,19 @@ def _node(self, state): 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) @@ -735,7 +838,50 @@ def set_offset(self, 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) @@ -745,6 +891,88 @@ def _spec(self, state): def _body(self, state): return next(iter(self._spec(state)["render_type"].values())) + @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 @@ -993,6 +1221,107 @@ def opacity(node): 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 ._features 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.""" @@ -1524,15 +1853,106 @@ def set_label(self, label, **kwargs): handle.set(text=label, **kwargs) return handle - def set_clim(self, *args, **kwargs): - self.mappable.set_clim(*args, **kwargs) + def _edit(self, operation): + fig = self.axes.figure + state = fig.get_settings() + operation(self.axes._config(state)["colorbar"]) + 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 ._features import update_color_scale - def remove(self): fig = self.axes.figure state = fig.get_settings() - self.axes._config(state)["colorbar"]["visible"] = False + update_color_scale(self.axes._config(state), vmin=vmin, vmax=vmax) fig.apply_settings(state) + def set_cmap(self, cmap): + from ._features 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 _nodes(self, state): @@ -1599,6 +2019,30 @@ def tick_params(self, axis="both", **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 diff --git a/python/figgyplot/_richtext.py b/python/figgyplot/_richtext.py new file mode 100644 index 0000000..62de227 --- /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 ._matplotlib import _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/figure.py b/python/figgyplot/figure.py index a7d461c..d32793f 100644 --- a/python/figgyplot/figure.py +++ b/python/figgyplot/figure.py @@ -22,7 +22,7 @@ _COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b"] _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"} +_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'} @@ -95,9 +95,14 @@ 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') @@ -177,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"]) @@ -623,7 +631,7 @@ def update(self, target, *, _axes=None, **properties): title = _chart_text.title_options(chart) for key, value in properties.items(): if key == 'text': - _chart_text.set_title_text(chart, value) + _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) @@ -681,14 +689,11 @@ def update(self, target, *, _axes=None, **properties): 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) + from ._features import update_color_scale + update_color_scale(cfg, **{key:value}) else: raise ValueError(f"unsupported series property: {key}") else: @@ -711,7 +716,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'") @@ -1492,7 +1497,7 @@ def _add(self, x, y, render_type, label=None, id=None, extra_columns=(), config= 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) + rich["segments"] = _segments(label, dpi=fig.dpi) spec["label"] = rich spec,document=_style_runtime.series(fig,document,spec,self.subplot_id) try: @@ -1530,7 +1535,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], diff --git a/python/figgyplot/skills/figgyplot-review/SKILL.md b/python/figgyplot/skills/figgyplot-review/SKILL.md index 0c81df5..181e390 100644 --- a/python/figgyplot/skills/figgyplot-review/SKILL.md +++ b/python/figgyplot/skills/figgyplot-review/SKILL.md @@ -24,6 +24,28 @@ 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 diff --git a/python/figgyplot/ticker.py b/python/figgyplot/ticker.py index 3e9b61f..939e91b 100644 --- a/python/figgyplot/ticker.py +++ b/python/figgyplot/ticker.py @@ -60,7 +60,86 @@ def __init__(self, precision=3, style="plain"): "plain", "scientific", "power", + "degrees", + "radians", ): raise ValueError( - "precision must be 1..15; style plain, scientific or power" + "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/tests/test_public_appearance.py b/tests/test_public_appearance.py new file mode 100644 index 0000000..023a4bb --- /dev/null +++ b/tests/test_public_appearance.py @@ -0,0 +1,733 @@ +"""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) From d0bb35cd0f9eda18ae6bd7be98a9bd440b5c58de Mon Sep 17 00:00:00 2001 From: youo he Date: Wed, 28 Oct 2026 11:43:49 +0000 Subject: [PATCH 20/23] Restore scales and editor selection in public Python replay --- .../public-appearance-api-20261028.json | 53 ++++++++---- python/figgyplot/_api_codegen.py | 42 ++++++---- tests/test_desktop_host.py | 20 ++++- tests/test_public_appearance.py | 83 +++++++++++++++++++ 4 files changed, 162 insertions(+), 36 deletions(-) diff --git a/docs/measurements/public-appearance-api-20261028.json b/docs/measurements/public-appearance-api-20261028.json index 2354413..a1ec0ed 100644 --- a/docs/measurements/public-appearance-api-20261028.json +++ b/docs/measurements/public-appearance-api-20261028.json @@ -19,24 +19,29 @@ "All annotation geometries, labels, frames and mixed coordinates" ], "validation": { - "public_appearance_cases": 119, + "public_appearance_cases": 131, "matplotlib_api_cases": 86, - "installed_focused_passed": 934, + "installed_focused_passed": 948, "installed_focused_ui_skips": 5, "clean_preflight_passed": 135, "native_jupyter_clipboard_and_typing_passed": 9, - "full_ci_collection": 1621, + "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": 205, - "additional_fillstyle_cases": 2 + "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" + "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", @@ -89,7 +94,7 @@ "exact_full_ssot_pixels_equal": true, "no_source_uploads": true, "raw_settings_calls": 0, - "api_lines": 85 + "api_lines": 87 }, { "domain": "acoustics", @@ -100,7 +105,7 @@ "exact_full_ssot_pixels_equal": true, "no_source_uploads": true, "raw_settings_calls": 0, - "api_lines": 57 + "api_lines": 58 }, { "domain": "acoustics", @@ -122,7 +127,7 @@ "exact_full_ssot_pixels_equal": true, "no_source_uploads": true, "raw_settings_calls": 0, - "api_lines": 32 + "api_lines": 33 }, { "domain": "education", @@ -133,7 +138,7 @@ "exact_full_ssot_pixels_equal": true, "no_source_uploads": true, "raw_settings_calls": 0, - "api_lines": 37 + "api_lines": 39 }, { "domain": "education", @@ -155,7 +160,7 @@ "exact_full_ssot_pixels_equal": true, "no_source_uploads": true, "raw_settings_calls": 0, - "api_lines": 29 + "api_lines": 30 }, { "domain": "energy", @@ -177,7 +182,7 @@ "exact_full_ssot_pixels_equal": true, "no_source_uploads": true, "raw_settings_calls": 0, - "api_lines": 57 + "api_lines": 58 }, { "domain": "materials", @@ -188,7 +193,7 @@ "exact_full_ssot_pixels_equal": true, "no_source_uploads": true, "raw_settings_calls": 0, - "api_lines": 37 + "api_lines": 38 }, { "domain": "materials", @@ -210,7 +215,7 @@ "exact_full_ssot_pixels_equal": true, "no_source_uploads": true, "raw_settings_calls": 0, - "api_lines": 50 + "api_lines": 51 }, { "domain": "materials", @@ -221,7 +226,7 @@ "exact_full_ssot_pixels_equal": true, "no_source_uploads": true, "raw_settings_calls": 0, - "api_lines": 32 + "api_lines": 39 }, { "domain": "nutrition", @@ -254,7 +259,7 @@ "exact_full_ssot_pixels_equal": true, "no_source_uploads": true, "raw_settings_calls": 0, - "api_lines": 91 + "api_lines": 92 }, { "domain": "sports", @@ -265,7 +270,7 @@ "exact_full_ssot_pixels_equal": true, "no_source_uploads": true, "raw_settings_calls": 0, - "api_lines": 34 + "api_lines": 43 }, { "domain": "sports", @@ -289,5 +294,17 @@ "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/python/figgyplot/_api_codegen.py b/python/figgyplot/_api_codegen.py index 7b026a7..94c2151 100644 --- a/python/figgyplot/_api_codegen.py +++ b/python/figgyplot/_api_codegen.py @@ -261,14 +261,13 @@ def text(target, method, node): else: emit(f"{ax} = {variable}.axes[0]") area = cfg["chart_area"] - if len(panels) > 1: - 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) + 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") @@ -280,20 +279,23 @@ def text(target, method, node): 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) - emit( - f"{ax}.set_{dim}scale({'log' if node['scale'] == 'Logarithmic' else 'linear'!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}))" + 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}))" + 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"]) @@ -593,7 +595,7 @@ def text(target, method, node): 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})", + 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: @@ -795,7 +797,8 @@ def text(target, method, node): ) emit(f"{scale}.set_visible({bar['visible']!r})") emit( - f"{axis}.set_scale({'log' if node['scale'] == 'Logarithmic' else 'linear'!r})" + f"{axis}.set_scale({'log' if node['scale'] == 'Logarithmic' else 'linear'!r})", + force=True, ) emit(f"{axis}.set_inverted({node['inverted']!r})") locator = ( @@ -965,6 +968,13 @@ def text(target, method, node): ] 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: 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_public_appearance.py b/tests/test_public_appearance.py index 023a4bb..9c6fc2a 100644 --- a/tests/test_public_appearance.py +++ b/tests/test_public_appearance.py @@ -731,3 +731,86 @@ def test_sparse_fillstyle_can_restore_shared_shape_inheritance(backend): 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) From 6048a2d5babe4ef8ee20a7c4ebf57ff78a261957 Mon Sep 17 00:00:00 2001 From: youo he Date: Wed, 28 Oct 2026 14:57:23 +0000 Subject: [PATCH 21/23] Preserve plotting intent and exact state through wrapper APIs --- docs/MATPLOTLIB_API.md | 20 +- .../blind-api-regressions-20261028.json | 59 ++++++ python/figgyplot/_api_codegen.py | 18 +- python/figgyplot/_document.py | 13 +- python/figgyplot/_features.py | 13 ++ python/figgyplot/_matplotlib.py | 41 +++- python/figgyplot/figure.py | 19 +- src/lib.rs | 31 +++ tests/test_interaction_parity.py | 107 +++++++++++ tests/test_matplotlib_api.py | 2 + tests/test_round3_api_regressions.py | 178 ++++++++++++++++++ 11 files changed, 479 insertions(+), 22 deletions(-) create mode 100644 docs/measurements/blind-api-regressions-20261028.json create mode 100644 tests/test_round3_api_regressions.py diff --git a/docs/MATPLOTLIB_API.md b/docs/MATPLOTLIB_API.md index 6b5d55b..e6ce329 100644 --- a/docs/MATPLOTLIB_API.md +++ b/docs/MATPLOTLIB_API.md @@ -60,9 +60,11 @@ belong to the parent panel; subplot annotations are rejected. ## Supported subset and extensions -- Tuple, partial and descending Cartesian limits are accepted. Bounded charts - accept an explicit increasing value range. Their automatic ranges are not - exposed by the current renderer, so getters do not invent limits. +- 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 @@ -130,7 +132,17 @@ are never rounded by export. 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 and source/destination native pixels. +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. diff --git a/docs/measurements/blind-api-regressions-20261028.json b/docs/measurements/blind-api-regressions-20261028.json new file mode 100644 index 0000000..71255b2 --- /dev/null +++ b/docs/measurements/blind-api-regressions-20261028.json @@ -0,0 +1,59 @@ +{ + "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." + }, + "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.", + "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/python/figgyplot/_api_codegen.py b/python/figgyplot/_api_codegen.py index 94c2151..d457828 100644 --- a/python/figgyplot/_api_codegen.py +++ b/python/figgyplot/_api_codegen.py @@ -138,7 +138,7 @@ def emit(statement, force=False): before = model._state exec(statement, namespace) after = model._state - keys = ("config", "series", "background") + 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 @@ -990,6 +990,22 @@ def text(target, method, node): 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 diff --git a/python/figgyplot/_document.py b/python/figgyplot/_document.py index a9805c9..9f3b17a 100644 --- a/python/figgyplot/_document.py +++ b/python/figgyplot/_document.py @@ -134,13 +134,22 @@ def set_tick_mode(state, panel_id, overlay_id, side, automatic, *, paired=True, 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, key): + if automatic is not None: + meta.setdefault('tick_modes',{}).setdefault(member,{})[key]='Auto' if automatic else 'Fixed' + else: + modes=meta.get('tick_modes',{}) + if member in modes: + modes[member].pop(key,None) + if not modes[member]:modes.pop(member) + if not modes:meta.pop('tick_modes',None) for member in tick_owners(meta,owner,key): - meta.setdefault('tick_modes',{}).setdefault(member,{})[key]='Auto' if automatic else 'Fixed' + assign(member,key) 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' + 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): diff --git a/python/figgyplot/_features.py b/python/figgyplot/_features.py index 28d6778..90d0f04 100644 --- a/python/figgyplot/_features.py +++ b/python/figgyplot/_features.py @@ -22,6 +22,18 @@ def colorbar(config, values, cmap, vmin, vmax): _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): """Shared mutation for mapped artists and standalone color-scale objects.""" from .figure import _colormap @@ -30,6 +42,7 @@ def update_color_scale(config, *, cmap=_COLOR_UNSET, vmin=None, vmax=None): if cmap is not _COLOR_UNSET:bar['colormap']=_colormap(cmap) if vmin is not None:bar['axis']['min']=_number(vmin,'vmin') if vmax is not None:bar['axis']['max']=_number(vmax,'vmax') + fit_colorbar_title(config) def _set_colorbar(config,cmap,low,high): diff --git a/python/figgyplot/_matplotlib.py b/python/figgyplot/_matplotlib.py index 1179af6..19b792e 100644 --- a/python/figgyplot/_matplotlib.py +++ b/python/figgyplot/_matplotlib.py @@ -593,7 +593,11 @@ def get_major_locator(self): ) def set_tick_auto(self, automatic=True): - """Figgy extension: keep the current spacing but choose future auto/fixed ticks.""" + """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 @@ -619,7 +623,7 @@ def set_tick_auto(self, automatic=True): self.axes.subplot_id, self.axes.overlay_id, key, - bool(automatic), + None if automatic is None else bool(automatic), paired=False, ) fig.apply_settings(state) @@ -1551,7 +1555,7 @@ def get_xlim(self): kind = family(state) if kind: if ( - kind in ("categorical", "boxplot") + kind == "boxplot" and state[kind]["direction"] == "Horizontal" and state[kind]["value_range"] is not None ): @@ -1570,7 +1574,7 @@ def get_ylim(self): kind = family(state) if kind: if ( - kind in ("categorical", "boxplot", "radar") + kind in ("boxplot", "radar") and state[kind].get("direction", "Vertical") == "Vertical" and state[kind]["value_range"] is not None ): @@ -1837,6 +1841,16 @@ class ColorbarText(Text): def _node(self, state): return self.axes._config(state)["colorbar"]["axis"]["title_option"] + def set(self, **properties): + from ._features import fit_colorbar_title + + fig = self.axes.figure + with fig.batch_update(): + super().set(**properties) + state = fig.get_settings() + fit_colorbar_title(self.axes._config(state)) + fig.apply_settings(state) + class Colorbar: """The shared color scale of scalar-mapped artists on one panel.""" @@ -1849,14 +1863,16 @@ def ax(self): return ColorbarAxes(self.axes) def set_label(self, label, **kwargs): - handle = ColorbarText(self.axes, "colorbar") - handle.set(text=label, **kwargs) - return handle + return self.ax.yaxis.set_label_text(label, **kwargs) def _edit(self, operation): + from ._features import fit_colorbar_title + fig = self.axes.figure state = fig.get_settings() - operation(self.axes._config(state)["colorbar"]) + config = self.axes._config(state) + operation(config["colorbar"]) + fit_colorbar_title(config) fig.apply_settings(state) return self @@ -1955,14 +1971,21 @@ def remove(self): 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 ._features 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): @@ -1999,6 +2022,8 @@ def set_tick_params(self, which="major", reset=False, **kwargs): **kwargs, ) cfg["colorbar"]["axis"] = ax._config(proxy._state)[key] + from ._features import fit_colorbar_title + fit_colorbar_title(cfg) fig.apply_settings(state) diff --git a/python/figgyplot/figure.py b/python/figgyplot/figure.py index d32793f..296e9b5 100644 --- a/python/figgyplot/figure.py +++ b/python/figgyplot/figure.py @@ -859,11 +859,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): @@ -1379,8 +1379,13 @@ def _set_bounds(self, dimension, low, high): state = self.figure.get_settings() kind = _family(state) if kind: - value_axis = 'x' if kind in ('categorical','boxplot') and state[kind]['direction'] == 'Horizontal' else 'y' - if kind not in ('categorical','boxplot','radar') or dimension != value_axis: + 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: raise ValueError(f'{kind} does not support an adjustable {dimension} value axis') state[kind]['value_range'] = [low, high] self.figure.apply_settings(state) diff --git a/src/lib.rs b/src/lib.rs index 90cef5c..d68e391 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1970,6 +1970,7 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { 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)?)?; @@ -2054,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 { diff --git a/tests/test_interaction_parity.py b/tests/test_interaction_parity.py index d133664..458bbf7 100644 --- a/tests/test_interaction_parity.py +++ b/tests/test_interaction_parity.py @@ -3406,3 +3406,110 @@ def check_pixels(image): 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_matplotlib_api.py b/tests/test_matplotlib_api.py index 59c251d..07631c0 100644 --- a/tests/test_matplotlib_api.py +++ b/tests/test_matplotlib_api.py @@ -282,6 +282,7 @@ def test_colorbar_public_handle_keeps_mappable_and_scale_connected(backend): 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) @@ -324,6 +325,7 @@ def test_default_export_uses_public_calls_and_replays_styles_and_pixels( 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 diff --git a/tests/test_round3_api_regressions.py b/tests/test_round3_api_regressions.py new file mode 100644 index 0000000..34d8b67 --- /dev/null +++ b/tests/test_round3_api_regressions.py @@ -0,0 +1,178 @@ +"""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) + if policy in ('fixed', 'mixed'): + ax.xaxis.set_major_locator(MultipleLocator(200)) + 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() + 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') + if fixed: + ax.xaxis.set_major_locator(LogLocator(100)) + before = fig.get_settings() + 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}) From d3ec3dc785fdecc7eb3c69fab7bd8f71e3f3bafd Mon Sep 17 00:00:00 2001 From: youo he Date: Wed, 28 Oct 2026 15:28:39 +0000 Subject: [PATCH 22/23] Verify code replay retains accepted automatic tick spacing --- .../blind-api-regressions-20261028.json | 8 ++++++-- tests/test_round3_api_regressions.py | 15 ++++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/measurements/blind-api-regressions-20261028.json b/docs/measurements/blind-api-regressions-20261028.json index 71255b2..3ac1d8a 100644 --- a/docs/measurements/blind-api-regressions-20261028.json +++ b/docs/measurements/blind-api-regressions-20261028.json @@ -27,7 +27,11 @@ }, "preflight_cases_passed": 135, "full_ci_collection_count": 1683, - "full_ci_result": "Reported separately by the required PR checks for the exact pushed commit." + "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", @@ -46,7 +50,7 @@ "no_column_reuploads": true }, "oracles": { - "state": "Entire accepted snapshots including tick modes and explicit backgrounds; no ignored snapshot fields.", + "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." diff --git a/tests/test_round3_api_regressions.py b/tests/test_round3_api_regressions.py index 34d8b67..65bf828 100644 --- a/tests/test_round3_api_regressions.py +++ b/tests/test_round3_api_regressions.py @@ -108,13 +108,20 @@ def test_public_code_preserves_tick_intent_without_replanning_current_ticks(back 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_major_locator(MultipleLocator(200)) + 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}) @@ -155,9 +162,11 @@ def test_public_log_axis_replay_preserves_policy_and_current_decade_layout(backe ax = fig.axes[0] ax.plot([1, 10, 1000], [1, 2, 4], id='signal') ax.set_xlim(1, 1000); ax.set_xscale('log') - if fixed: - ax.xaxis.set_major_locator(LogLocator(100)) + 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 From a395f8191e79afa0efbf08889e845cb7246a6702 Mon Sep 17 00:00:00 2001 From: youo he Date: Wed, 28 Oct 2026 17:31:45 +0000 Subject: [PATCH 23/23] Unify wrapper edit paths and bound repeated snapshot and layout work --- docs/MATPLOTLIB_API.md | 5 +- .../wrapper-edit-paths-20261028.json | 151 ++++++++++ python/figgyplot/_annotation_api.py | 12 +- python/figgyplot/_appearance.py | 2 +- python/figgyplot/_artist_appearance.py | 28 +- python/figgyplot/_chart_artist.py | 2 +- python/figgyplot/_charts.py | 2 +- python/figgyplot/_color_scale.py | 68 +++++ python/figgyplot/_document.py | 111 ++++--- python/figgyplot/_features.py | 58 +--- python/figgyplot/_field_api.py | 6 +- python/figgyplot/_matplotlib.py | 239 +++------------ python/figgyplot/_richtext.py | 2 +- python/figgyplot/_ticks.py | 153 ++++++++++ python/figgyplot/_validation.py | 13 + python/figgyplot/figure.py | 86 +++--- scripts/benchmark_wrapper_edits.py | 115 +++++++ tests/test_wrapper_edit_paths.py | 280 ++++++++++++++++++ 18 files changed, 979 insertions(+), 354 deletions(-) create mode 100644 docs/measurements/wrapper-edit-paths-20261028.json create mode 100644 python/figgyplot/_color_scale.py create mode 100644 python/figgyplot/_ticks.py create mode 100644 python/figgyplot/_validation.py create mode 100644 scripts/benchmark_wrapper_edits.py create mode 100644 tests/test_wrapper_edit_paths.py diff --git a/docs/MATPLOTLIB_API.md b/docs/MATPLOTLIB_API.md index e6ce329..ef218f4 100644 --- a/docs/MATPLOTLIB_API.md +++ b/docs/MATPLOTLIB_API.md @@ -239,7 +239,10 @@ 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. +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 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/python/figgyplot/_annotation_api.py b/python/figgyplot/_annotation_api.py index 74ac522..92161c7 100644 --- a/python/figgyplot/_annotation_api.py +++ b/python/figgyplot/_annotation_api.py @@ -4,7 +4,7 @@ def anchor(xy, coords): - from ._matplotlib import _number + from ._validation import number as _number x, y = (_number(v, "coordinate") for v in xy) if coords == "axes fraction": @@ -52,7 +52,7 @@ def set_style( ): """Edit a reference-line/arrow stroke or a span fill; lengths are points.""" from .figure import _color, _LINES - from ._matplotlib import _number + from ._validation import number as _number def edit(name, body): if name == "Span": @@ -92,7 +92,7 @@ def set_bbox( ): """Set/remove a text frame. Padding is in points, independent of font size.""" from .figure import _color - from ._matplotlib import _number + from ._validation import number as _number def edit(name, body): if name != "Text": @@ -122,7 +122,7 @@ def edit(name, body): return self._edit(edit) def set_value(self, value): - from ._matplotlib import _number + from ._validation import number as _number def edit(name, body): if name != "ReferenceLine": @@ -132,7 +132,7 @@ def edit(name, body): return self._edit(edit) def set_bounds(self, low, high): - from ._matplotlib import _number + from ._validation import number as _number def edit(name, body): if name != "Span": @@ -152,7 +152,7 @@ def set_label( ): """Set optional text on a reference line or arrow; None removes it.""" from .figure import _color, _segments - from ._matplotlib import _number + from ._validation import number as _number def edit(name, body): if name not in ("Arrow", "ReferenceLine"): diff --git a/python/figgyplot/_appearance.py b/python/figgyplot/_appearance.py index 95b7024..37e1447 100644 --- a/python/figgyplot/_appearance.py +++ b/python/figgyplot/_appearance.py @@ -88,7 +88,7 @@ def convert(value, kind, dpi, *, decode=False): from .figure import _color, _material - from ._matplotlib import _number + from ._validation import number as _number if decode: if kind == "color": diff --git a/python/figgyplot/_artist_appearance.py b/python/figgyplot/_artist_appearance.py index 539598f..86fbdbe 100644 --- a/python/figgyplot/_artist_appearance.py +++ b/python/figgyplot/_artist_appearance.py @@ -69,7 +69,7 @@ class BinStyle: def _style(value, part, dpi, decode=False): from .figure import _color, _MARKERS - from ._matplotlib import _number + from ._validation import number as _number _, cls, fields, _ = _PARTS[part] if not decode and not isinstance(value, cls): @@ -229,7 +229,7 @@ def __getattr__(self, role): def _domain(figure, column, domain): - from ._matplotlib import _number + from ._validation import number as _number if domain is None: bounds = json.loads(figure._native.column_extents_json()).get(column) @@ -364,7 +364,7 @@ def get_overrides(self): def map_color(self, source, *, clim=None, cmap=None): if self.part != "markers": raise AttributeError("color-value mapping requires point markers") - from ._features import _set_colorbar + from ._color_scale import _set_colorbar fig = self.artist.figure state = fig.get_settings() @@ -374,13 +374,7 @@ def map_color(self, source, *, clim=None, cmap=None): else: column = _column(source, fig) low, high = _domain(fig, column, clim) - owner = next( - ax - for ax in fig.axes - if any( - s["series_id"] == self.artist.id for s in ax._owned_series(state) - ) - ) + 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) @@ -391,7 +385,7 @@ def map_color(self, source, *, clim=None, cmap=None): 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 ._matplotlib import _number + from ._validation import number as _number fig = self.artist.figure state = fig.get_settings() @@ -540,7 +534,7 @@ def set( units=None, ): from .figure import _color - from ._matplotlib import _number + from ._validation import number as _number fig = self.artist.figure state = fig.get_settings() @@ -580,7 +574,7 @@ def set( return self def map_color(self, source, *, clim=None, cmap=None): - from ._features import _set_colorbar + from ._color_scale import _set_colorbar fig = self.artist.figure state = fig.get_settings() @@ -609,13 +603,7 @@ def map_color(self, source, *, clim=None, cmap=None): name = _column(source, fig) style["color_map"] = {"Column": name} low, high = _domain(fig, name, clim) - owner = next( - ax - for ax in fig.axes - if any( - s["series_id"] == self.artist.id for s in ax._owned_series(state) - ) - ) + 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) diff --git a/python/figgyplot/_chart_artist.py b/python/figgyplot/_chart_artist.py index 0598052..c03dec9 100644 --- a/python/figgyplot/_chart_artist.py +++ b/python/figgyplot/_chart_artist.py @@ -3,7 +3,7 @@ import copy import json -from ._matplotlib import _number +from ._validation import number as _number _FORMATS = dict( value="Value", diff --git a/python/figgyplot/_charts.py b/python/figgyplot/_charts.py index f5a530d..a654c86 100644 --- a/python/figgyplot/_charts.py +++ b/python/figgyplot/_charts.py @@ -253,7 +253,7 @@ def contour(self, *args, levels=7, color='black', linewidth=1, cmap='viridis', interior levels. ``filled=True`` adds upstream banded field rendering. """ from .figure import _array, _color - from ._features import _set_colorbar + from ._color_scale import _set_colorbar if len(args) == 1: z = np.asarray(args[0]) if z.ndim != 2: 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/_document.py b/python/figgyplot/_document.py index 9f3b17a..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,36 +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 - def assign(member, key): + 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,{})[key]='Auto' if automatic else 'Fixed' + meta.setdefault('tick_modes', {}).setdefault(member, {})[axis] = 'Auto' if automatic else 'Fixed' else: - modes=meta.get('tick_modes',{}) + modes = meta.get('tick_modes', {}) if member in modes: - modes[member].pop(key,None) - if not modes[member]:modes.pop(member) - if not modes:meta.pop('tick_modes',None) - for member in tick_owners(meta,owner,key): - assign(member,key) - 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):assign(member,peer) + 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): @@ -177,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) @@ -277,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]), @@ -289,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 90d0f04..7f52578 100644 --- a/python/figgyplot/_features.py +++ b/python/figgyplot/_features.py @@ -8,53 +8,7 @@ from . import _style_runtime -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., 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): - """Shared mutation for mapped artists and standalone color-scale objects.""" - from .figure import _colormap - from ._matplotlib import _number - bar=config['colorbar'] - if cmap is not _COLOR_UNSET:bar['colormap']=_colormap(cmap) - if vmin is not None:bar['axis']['min']=_number(vmin,'vmin') - if vmax is not None:bar['axis']['max']=_number(vmax,'vmax') - fit_colorbar_title(config) - - -def _set_colorbar(config,cmap,low,high): - """Update the shared scale without resetting accepted colorbar appearance.""" - from .figure import _colormap - from ._native import default_colorbar_json - 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 +from ._color_scale import colorbar from ._annotation_api import AnnotationEditing @@ -126,7 +80,7 @@ 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 ._matplotlib import _number + 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 @@ -140,7 +94,7 @@ def get_position(self): 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 ._matplotlib import _number + 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())) @@ -315,7 +269,8 @@ def annotate(self, text, xy=None, xytext=None, xycoords='data', textcoords=None, return note def _annotation_anchor(self,xy,coords): - from ._matplotlib import _number,Transform + 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') @@ -343,7 +298,8 @@ def text(self, x, y, text, *, transform=None, coords='data', fontsize=11, color= from .figure import _color, _segments if coords not in ('data', 'ratio'): raise ValueError('coords must be data or ratio') - from ._matplotlib import _number,Transform + 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']) diff --git a/python/figgyplot/_field_api.py b/python/figgyplot/_field_api.py index 37e2da9..6e2a477 100644 --- a/python/figgyplot/_field_api.py +++ b/python/figgyplot/_field_api.py @@ -33,7 +33,7 @@ def _edit(self, operation): return self def set_levels(self, levels): - from ._matplotlib import _number + from ._validation import number as _number values = [_number(v, "contour level") for v in levels] return self._edit(lambda node: node.update(levels=values)) @@ -43,7 +43,7 @@ def get_levels(self): def set_style(self, *, color=None, linewidth=None, linestyle=None, colors=...): from .figure import _color, _LINES - from ._matplotlib import _number + from ._validation import number as _number def edit(node): if color is not None: @@ -81,7 +81,7 @@ def set_labels( visible=None removes label configuration. Lengths use points. """ from .figure import _color - from ._matplotlib import _number + from ._validation import number as _number from .ticker import SignificantFormatter def edit(node): diff --git a/python/figgyplot/_matplotlib.py b/python/figgyplot/_matplotlib.py index 19b792e..45183e9 100644 --- a/python/figgyplot/_matplotlib.py +++ b/python/figgyplot/_matplotlib.py @@ -8,6 +8,8 @@ import numpy as np +from ._validation import number as _number + @dataclass(frozen=True) class Bbox: @@ -53,15 +55,6 @@ def canvas_size(state): ) -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 - - def _locator_spacing(locator, node, axes): """Interpret a locator once, independently of plot/colorbar ownership.""" from .ticker import AutoLocator, LogLocator, MultipleLocator @@ -283,9 +276,13 @@ def set(self, **properties): 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) @@ -895,6 +892,11 @@ def _spec(self, state): 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 @@ -1173,6 +1175,9 @@ def get_cmap(self): 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 @@ -1180,10 +1185,7 @@ def _colorbar(self): or body.get("style", {}).get("color_map") ): raise AttributeError("artist is not scalar-mapped") - for ax in self.figure.axes: - if any(s["series_id"] == self.id for s in ax._owned_series(state)): - return ax._config(state)["colorbar"] - raise ValueError("artist has no owning Axes") + return self._panel_axes(state) def set_alpha(self, value): alpha = _number(value, "alpha") @@ -1310,7 +1312,7 @@ def get_colorbar(self): def add_colorbar(self, *, cmap="viridis", vmin=0, vmax=1, label=None): """Add an explicit standalone scale; existing data colors are retained.""" - from ._features import _set_colorbar + from ._color_scale import _set_colorbar with self.figure.batch_update(): state = self.figure.get_settings() @@ -1662,150 +1664,27 @@ def tick_params(self, axis="both", which="major", reset=False, **kwargs): def _tick_params( self, axis="both", which="major", reset=False, sides=None, **kwargs ): - """Style ticks in points using Matplotlib names; reject unsupported options.""" - from .figure import _color, _LINES + """Style ticks in points using the shared axis option translator.""" + from ._charts import family + from ._ticks import apply_tick_params - if axis not in ("x", "y", "both") or which not in ("major", "minor", "both"): - raise ValueError("invalid axis or which") - 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", - } - if set(kwargs) - allowed: - raise ValueError( - "unsupported tick parameters: " - + ", ".join(sorted(set(kwargs) - allowed)) - ) fig = self.figure - fig._require_cartesian() 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) - unit = fig.dpi / 72 sides = sides or [ - s - for s in ("bottom", "top", "left", "right") - if axis == "both" or (axis == "x") == (s in ("bottom", "top")) + side for side in ("bottom", "top", "left", "right") + if axis == "both" or (axis == "x") == (side in ("bottom", "top")) ] + targets = [] for side in sides: - 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] - ) if self.overlay_id and axis == "y" and side == "left": continue - label = node["label_style"] - if reset: - base = fig._default_config[key] - 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": - if value not in ("in", "out", "inout"): - raise ValueError("direction must be in, out or inout") - node["tick"] = {"in": "Inside", "out": "Outside", "inout": "Both"}[ - value - ] - elif name == "length": - length = _number(value, "length") * unit - if length < 0: - raise ValueError("length must be nonnegative") - if which in ("major", "both"): - node["major_tick_length"] = length - if which in ("minor", "both"): - node["minor_tick_length"] = length - elif name in ("color", "colors"): - color = _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"] = color - elif name == "labelcolor": - label["color"] = _color(value) - elif name == "width": - width = _number(value, "width") * unit - 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"] = ( - _number(value, "labelsize", positive=True) * unit - ) - elif name == "labelfontfamily": - label["label_font"] = fig._check_font(value) - elif name == "pad": - raise NotImplementedError( - "independent tick padding is not supported by this renderer" - ) - 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"] = fig._default_config[key]["tick"] - if "colors" in kwargs: - label["color"] = _color(kwargs["colors"]) - for dimension in ("x", "y"): - if axis not in (dimension, "both"): - continue - for kind in ("major", "minor"): - if which not in (kind, "both"): - continue - grid = cfg["grid"] - 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"] = _color(value) - elif field == "alpha": - grid[prefix + "_color"]["a"] = _number(value, "grid_alpha") - elif field == "width": - grid[prefix + "_width"] = ( - _number(value, "grid_linewidth") * unit - ) - else: - grid[prefix + "_style"] = _LINES.get(value, value) + 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) @@ -1841,15 +1720,10 @@ class ColorbarText(Text): def _node(self, state): return self.axes._config(state)["colorbar"]["axis"]["title_option"] - def set(self, **properties): - from ._features import fit_colorbar_title + def _finish_edit(self, state): + from ._color_scale import fit_colorbar_title - fig = self.axes.figure - with fig.batch_update(): - super().set(**properties) - state = fig.get_settings() - fit_colorbar_title(self.axes._config(state)) - fig.apply_settings(state) + fit_colorbar_title(self.axes._config(state)) class Colorbar: @@ -1866,7 +1740,7 @@ def set_label(self, label, **kwargs): return self.ax.yaxis.set_label_text(label, **kwargs) def _edit(self, operation): - from ._features import fit_colorbar_title + from ._color_scale import fit_colorbar_title fig = self.axes.figure state = fig.get_settings() @@ -1879,7 +1753,7 @@ def _edit(self, operation): 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 ._features import update_color_scale + from ._color_scale import update_color_scale fig = self.axes.figure state = fig.get_settings() @@ -1887,7 +1761,7 @@ def set_clim(self, vmin=None, vmax=None): fig.apply_settings(state) def set_cmap(self, cmap): - from ._features import update_color_scale + from ._color_scale import update_color_scale fig = self.axes.figure state = fig.get_settings() @@ -1979,7 +1853,7 @@ def _nodes(self, state): return [self.axes._config(state)["colorbar"]["axis"]] def set_major_locator(self, locator): - from ._features import fit_colorbar_title + from ._color_scale import fit_colorbar_title fig = self.axes.figure state = fig.get_settings() @@ -2003,26 +1877,18 @@ 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) - axis = cfg["colorbar"]["axis"] - side = cfg["colorbar"]["side"] - # Reuse the same public tick argument translator on an isolated model - # view; the transaction changes only the actual shared colorbar axis. - key = "right_y" if side in ("Left", "Right") else "bottom_x" - proxy = _StateFigure(state, fig._default_config, fig.dpi) - ax = proxy._axes_for(self.axes.subplot_id) - ax._config(proxy._state)[key] = copy.deepcopy(axis) - ax._tick_params( - axis="y" if key == "right_y" else "x", - which=which, - reset=reset, - sides=["right" if key == "right_y" else "bottom"], - **kwargs, + 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, ) - cfg["colorbar"]["axis"] = ax._config(proxy._state)[key] - from ._features import fit_colorbar_title fit_colorbar_title(cfg) fig.apply_settings(state) @@ -2152,22 +2018,13 @@ def colorbar(self, mappable, ax=None, *, label=None, **kwargs): ) if mappable.figure is not self: raise ValueError("mappable belongs to another Figure") - mappable._colorbar() - owners = [ - a - for a in self.axes - if any( - s["series_id"] == mappable.id - for s in a._owned_series(self.get_settings()) - ) - ] - owner = owners[0] - 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") 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) diff --git a/python/figgyplot/_richtext.py b/python/figgyplot/_richtext.py index 62de227..bd22140 100644 --- a/python/figgyplot/_richtext.py +++ b/python/figgyplot/_richtext.py @@ -26,7 +26,7 @@ class TextRun: def _segments(self, dpi): from .figure import _color - from ._matplotlib import _number + from ._validation import number as _number if not isinstance(self.text, str) or self.script not in ( "normal", 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/figure.py b/python/figgyplot/figure.py index 296e9b5..16f4cf9 100644 --- a/python/figgyplot/figure.py +++ b/python/figgyplot/figure.py @@ -558,14 +558,14 @@ 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') + # Detach exactly once; validation and batch staging own this copy. + settings = copy.deepcopy(settings) if reset: - settings = copy.deepcopy(settings) from ._codegen import expand_config settings['config'] = expand_config(settings['config'], self._default_config) - settings = copy.deepcopy(settings) 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 @@ -577,7 +577,7 @@ def apply_settings(self, settings, *, expected_revision=None, reset=False): 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 = copy.deepcopy(settings) + self._style_batch = settings return self self._native.apply_settings(json.dumps(settings, allow_nan=False)) accepted = json.loads(self._native.settings_json()) @@ -623,48 +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_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 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 @@ -692,11 +687,14 @@ def update(self, target, *, _axes=None, **properties): 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')): - from ._features import update_color_scale - update_color_scale(cfg, **{key: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": @@ -777,7 +775,7 @@ 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): @@ -1651,7 +1649,7 @@ def scatter(self, x, y, *, s=16, color=None, c=None, cmap='viridis', vmin=None, choose automatic ticks; Figure.update('axis_bottom', major_spacing=...) sets regular spacing. Arbitrary set_xticks/set_yticks are not provided. """ - from ._features import colorbar + 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') @@ -1748,7 +1746,7 @@ 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") - from ._features import _set_colorbar + from ._color_scale import _set_colorbar with self.figure._lock: config = self._config(self.figure.get_settings()) _set_colorbar(config,cmap,low,high) 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/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