Skip to content

Finance Charting surface [Don't Merge] - #1

Closed
Alek99 wants to merge 172 commits into
mainfrom
codex/finance-charting-surface
Closed

Finance Charting surface [Don't Merge]#1
Alek99 wants to merge 172 commits into
mainfrom
codex/finance-charting-surface

Conversation

@Alek99

@Alek99 Alek99 commented Jul 9, 2026

Copy link
Copy Markdown
Member

No description provided.

Alek99 and others added 30 commits July 3, 2026 09:40
Implements the design dossier's Phase 0 (docs/design-dossier.md §11/§25/§35):
- Zero-dependency Rust core (C ABI): zone maps (§22), offset-encoded f32
  (§4/§16), M4 min/max decimation (§5 Tier 1); ctypes binding with zero-copy
  NumPy pointers and a loud NumPy fallback (§33)
- Data-less {traces,layout} spec + single-copy column store with ingest copy
  accounting (§9, §4, §29)
- Dependency-free WebGL2 anywidget client: instanced SDF points, instanced
  line segments, DOM chrome, pan/zoom as uniform updates, kernel-side
  re-decimation round-trip with stale-while-revalidate (§7, §17, §28)
- Tests for the two front-loaded thesis risks (§25): ms-timestamp precision
  under viewport re-centering, M4 spike preservation
- Benchmark harness (§12), headless Chromium render smoke, CI wheel matrix
  with install-size budget (§33)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
- JS: tick-label trailing-zero strip corrupted integers (100 -> 1); tick loops
  could spin at extreme zoom (step below f64 ulp); zoom-in now clamps at the
  anchor's ulp region; tier-update buffers handle unaligned comm DataViews
- Python: guard zero-length arrays before C calls (empty NumPy arrays can
  carry a null pointer — UB for slice::from_raw_parts even at len 0)
- hatch hook: don't assume force_include exists in build_data

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
The render smoke test caught a real bug: WebGL2 rejects a program whose shared
uniforms (u_width) differ in precision between stages — vertex floats default to
highp, fragment shaders declared mediump. Fixed both fragment shaders to highp.

Two new stdlib-only gates that run without numpy/PyPI (and first in CI):
- scripts/abi_smoke.py: 12 checks over the Rust C ABI via ctypes + array —
  precision, M4 spikes, zone-map NaN handling, invalid-arg sentinels
- scripts/render_smoke_nonumpy.py: hand-builds a wire payload and drives
  headless Chromium, reads back lit pixels + DOM chrome nodes

Verified locally: cargo test 8/8, ABI smoke 12/12, render smoke lit+chrome OK.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
scripts/bench_native.py times the Rust kernels through the C ABI (stdlib only,
no PyPI). Real numbers on this machine: encode ~930 Mpt/s, zone maps ~370,
M4 ~258 at 10M points; zoom re-decimation of a 1% window of 10M points is
0.39 ms (~250x under the §17 100-300ms interaction budget). Added to CI and README.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
Extends scatter from Tier-0 dots to a complete chart type per the dossier's
scatter contract (§5/§28/§36c):

Rust core (ABI v2):
- bin_2d: additive 2D density aggregation over a viewport (§5 Tier 2), NaN- and
  bounds-skipping, row0=bottom for GL; grid_max for per-view normalization (§F6)
- 5 new cargo tests (13 total); fc_bin_2d C ABI + ctypes + NumPy fallback parity

Python:
- channels.py: color resolves to constant / continuous (colormap) / categorical
  (factorized palette); size to constant / continuous. Shipped as one f32/point
  + a LUT (never per-point RGBA) — a colored+sized scatter is ~16 B/pt (§2)
- scatter() gains color/size/colormap/size_range/density; large scatter auto-
  switches to Tier-2 density (per-point channels drop with a loud warning + a
  channels_dropped flag, never silent — §28)
- density_view (re-bin visible window on zoom, §5) and pick (exact f64 row from
  canonical for hover, §16/§17); widget handles density_view/pick messages

JS client:
- per-vertex color (continuous colormap LUT / categorical palette LUT) and size
- GPU picking: RGBA8 ID texture + 1px readback -> exact-row DOM tooltip (§17)
- Tier-2 density: R32F count texture, log-colormap at composite, per-view max
  normalization, re-bin round-trip with stale grid drawn until it lands (§17)
- colormaps (viridis/magma/plasma/cividis/turbo), gradient/palette legend
- fixes another WebGL2 precision-mismatch (int uniform) caught by the smoke

Verification (PyPI still blocked, so numpy suite runs in CI): cargo 13/13, ABI
smoke 16/16, headless-Chromium smoke now exercises colored+sized scatter +
density + picking + standalone hover decode. bin_2d bench: 10M pts -> 512x384
grid in 61 ms. New pytest suites (test_scatter.py, +bin_2d parity) for CI.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
A declarative component API over the same engine — the feel of Reflex's
Recharts components, with no reflex dependency:

  fc.scatter_chart(
      fc.scatter(x='gdp', y='life', color='continent', size='pop', data=df),
      fc.x_axis(label='GDP'), fc.y_axis(label='life'), fc.legend(),
      title='Gapminder',
      on_hover=lambda row: ..., on_select=lambda sel: ...,
  )

- components.py: scatter_chart/line_chart containers composing scatter/line
  marks + x_axis/y_axis/legend children into a Figure; snake_case props;
  data= + column-name resolution (the Recharts data_key idiom, read directly);
  CSS-color-vs-column disambiguation; log-axis warns (v1 roadmap, §30)
- Events: on_hover fires with the exact f64 picked row (§16/§17); on_select
  fires with a Selection (indices + .xy() arrays, never JSON). Figure.select_range
  is the §34 Tier-A range predicate.
- Box-select: shift-drag rubber band in the client; kernel resolves the range
  to indices; unselected marks dim on the GPU via a per-vertex a_sel attribute +
  u_selActive. Standalone HTML computes the selection from resident f32 (no
  kernel). Double-click clears. legend(show=False) suppresses the legend.

Verification (PyPI still blocked; numpy suite in CI): cargo 13/13, ABI 16/16,
render smoke now also exercises box-select (100 selected -> 51 in sub-range,
mask active). New test_components.py (composition build, data_key, select_range,
Selection payload). Docs + notebook lead with the composition API.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
Three-way scatter benchmark on point-count ceiling, speed, memory, and payload
size (docs/benchmark.md):

- scripts/bench_vs.py: the full comparison. Per library (matplotlib Agg, Plotly
  Scattergl + Scatter/SVG, fastcharts), per N: build/render/total time, peak
  memory (tracemalloc + psutil RSS), output/wire bytes, points/sec, and a
  ceiling probe (largest N under a wall-clock budget without erroring). Runs
  whatever's installed; missing libs reported, not faked. JSON + Markdown out.
- scripts/bench_scatter_native.py: the fastcharts arm alone, stdlib-only, so its
  numbers are real even with PyPI blocked — data-prep (encode/bin via the C ABI),
  wire bytes, and real render-to-pixels time in headless Chromium.
- CI 'benchmark' job installs plotly/matplotlib/kaleido and runs the three-way
  comparison, uploading the report as an artifact.

Measured fastcharts arm (this machine, software GL): wire payload goes FLAT at
768 KB once density aggregation engages — 0.79 B/pt at 1M, 0.08 at 10M vs a
constant 8 B/pt direct — while render cost stays screen-bounded (~150 ms at 10M
under SwiftShader). Data prep: 62 ms to bin 10M points. Competitor columns are
filled by the CI job; until then the report cites the field's published numbers
(deck.gl 10-100M crash, Plotly ~14x memory) clearly labeled as literature.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
…atch

So the three-way benchmark produces measured Plotly/matplotlib numbers in CI
(GitHub Actions has the network access this container lacks), retrievable via
the Actions API.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
Ran the three-way harness in GitHub Actions (which has the network access this
container lacks) and pulled the measured results back. docs/benchmark.md and the
README now carry real numbers for all three libraries, not literature.

At 10M points (CI, Ubuntu, Python 3.12):
  fastcharts   86 ms    2 MB     768 KB payload
  matplotlib   3230 ms  553 MB   41 KB PNG
  plotly WebGL 33907 ms 1584 MB  49 KB PNG
  plotly SVG   did not finish (113 s at 3M, over budget at 10M)

fastcharts is the only library flat in N: payload and peak memory stop growing
once density aggregation engages. ~37x faster than matplotlib, ~394x than
plotly-gl, 250-790x less memory; fairness caveat (payload-build vs to-pixels)
documented in the report.

Also fixes the cargo clippy failures (too_many_arguments on bin_2d; intentional
NaN-rejecting !(a>b) comparisons annotated) so the CI test job passes lint.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
… more

Full review of the codebase against docs/design-dossier.md invariants. Findings,
all fixed with regression tests:

P0 — shipped/canonical index mismatch (hover + selection wrong rows).
build_payload drops NaN rows from shipped scatter buffers (§19) but discarded
the mapping. The client's GPU pick speaks shipped vertex indices; Figure.pick
read canonical arrays at that index -> wrong hover row whenever data had NaNs.
Inverse bug in selection: select_range returns canonical rows, the client masks
shipped positions -> wrong points highlighted. Fix: Trace.shipped_sel mapping,
pick() translates shipped->canonical, new Figure.to_shipped_indices translates
canonical->shipped for the wire (callback keeps canonical, per §34).

P0b — density=False was silently ignored. force_density collapsed the tri-state
to bool, so density=False still auto-aggregated above the threshold, violating
the documented API. Fix: Optional[bool] tri-state honored by use_density().

P1 — JS seq race froze stale tiers. Hover picks shared this.seq with view
requests; a hover after a zoom invalidated the in-flight tier_update
(msg.seq !== this.seq) and the stale decimation stuck. Fix: separate _pickSeq.

P2 — NaN-blind sort check: any(diff<0) is False for NaN diffs, letting NaN-x
lines skip sorting and violate M4's sorted precondition; now not all(diff>=0),
NaNs sort last and the m4 window excludes them (asserted: no NaN reaches a
vertex buffer, §19). P2 — density=False above the direct ceiling now warns
(§28 no-silent rule). P2 — theme refresh re-resolves scatter constant colors,
not just lines (§36 live re-resolution).

7 new regression tests. Local: cargo 13/13, ABI 16/16, Chromium render smoke
(incl. picking + box-select) pass; numpy suite runs in CI.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
- Figure.to_html: escape '</' in the embedded JSON spec and entity-escape the
  <title> text, so a title/name shaped like '</script>...' cannot break out of
  the script context in exported files. Regression test included.
- JS tooltip: build with text nodes instead of innerHTML — categorical labels
  are user data and must never parse as markup.

Local: JS rebuilt, render smoke green.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
…ive scatter matrix

Two real bugs found auditing every scatter combination a user hits:

1. inf reached the vertex buffer. The ship-time drop keyed on NaN-only
   null_count and used isnan, so ±inf shipped as inf f32 (corrupts primitives,
   §19) AND poisoned autorange -> range collapsed to the (0,1) fallback. Fix:
   treat non-finite (NaN and ±inf) uniformly as invalid across the kernels —
   zone_maps/min_max/m4/bin_2d now use is_finite, Python drop uses ~isfinite,
   and normalize_to_unit maps non-finite to the domain floor. Autorange,
   null_count, and the drop path now agree. (Rust + NumPy fallback both updated;
   ABI smoke + cargo tests assert inf-as-null.)

2. x/y length mismatch was silent — scatter([1,2,3],[1,2]) shipped mismatched
   buffers and the client drew min(). Now raises ValueError in scatter and line
   (color/size were already validated against n).

tests/test_scatter_matrix.py: exhaustive sweep — input dtypes (list/ndarray/
f32/int8/datetime/Series-like), color modes (hex/named/rgb/3-digit, continuous
int+float, categorical incl. bool/single/25-cats-cycling), size modes, empty/
1-point/all-same/degenerate-domain, NaN/inf/all-NaN, tier boundaries, force
density/direct, multi-trace, mixed line+scatter, pick/select translation, and
to_html across every tier. Runs under both backends in CI.

Local: cargo 14/14, ABI smoke 18/18, Chromium render smoke green.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
- CI render smoke used the apt/snap chromium-browser, a confined snap that
  can't read the temp HTML in /tmp and whose GPU process crashes headless
  (dbus + non-existent-mailbox errors) -> render probe never reached FC_OK.
  Switch to Playwright's unconfined Chromium (already the local convention).
- channels.resolve_color warns when a categorical color exceeds 256 distinct
  values (the client palette LUT is 256-wide; beyond that codes collide).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
The harness measured bytes and serialize time but stopped before pixels existed
for every browser-rendered library — fastcharts' render_s excluded the WebGL
draw, and Plotly-HTML/Bokeh/Altair/hvPlot 'render' was pure HTML serialization
(no parse, no scene build, no paint). That understated the libraries that ship
megabytes of JS the browser must execute, and made the fastcharts comparison
unfair in both directions.

Add real time-to-first-render (data -> pixels):
- benchmarks/_browser.py: headless-Chromium First Contentful Paint measurement
  (JS inlined, no CDN, virtual-time-advanced through async renders). Validated
  locally against a fastcharts standalone density page (~180 ms, SwiftShader).
- bench_vs.py: adapters for browser libs now yield an artifact_fn; --ttfr loads
  each output in Chromium and records browser_paint_ms + ttfr_ms. Raster libs
  (matplotlib/seaborn/datashader/Plotly-kaleido) count build+render (already
  pixels). New TTFR table + column in the Markdown report.
- CI benchmark job installs the full adapter set + Playwright Chromium and runs
  the TTFR pass (capped at 100k/row since each launches a browser).
- docs/benchmark.md documents the metric and the gap it closes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
Addresses the install-friction worry. The truth the README obscured, now fixed:

- End users need NO toolchain: 'pip install fastcharts' pulls a platform wheel
  that already contains the compiled core AND the JS client (CI wheel matrix).
- Source builds no longer FAIL without Rust. hatch_build.py degrades gracefully:
  if cargo is absent (or the build fails), it produces a pure-Python wheel that
  uses the NumPy fallback (correct, slower, one loud warning) instead of
  erroring. FASTCHARTS_REQUIRE_CARGO=1 (set on the CI wheel matrix) keeps
  published wheels from silently shipping without the core.
- Node is only needed to EDIT the JS client — the bundle is a committed artifact,
  so it's never required to install.

New CI job 'install_without_rust' builds a wheel on a runner with no Rust and no
Node, installs it, and asserts it imports + renders a payload via the NumPy
fallback with the expected warning — so the ergonomics promise stays honest.

README rewritten to lead with the zero-toolchain wheel path and mark Rust/Node
as optional (fast path / JS editing only).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
The Rust binary already ships inside the wheel — but CI only uploaded wheels as
artifacts and tagged Linux wheels 'linux_x86_64', which PyPI rejects. So the
'no Rust needed' promise wasn't actually deliverable. Fixes:

- Linux wheels are auditwheel-repaired to manylinux (the cdylib needs only
  libc/libm, nothing to bundle) — valid and installable off-box. Applied in
  both the CI wheels job (validation) and the release workflow.
- New .github/workflows/release.yml: on a v* tag, builds the wheel matrix
  (native core required) + sdist and publishes to PyPI via trusted publishing
  (OIDC, no stored token). Each wheel is smoke-installed and asserted to load
  the native core before publish.

Result: 'pip install fastcharts' downloads a prebuilt platform wheel with the
compiled core inside — no Rust, no Node, ever. README says so explicitly.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
…free

Test job died at lint with 76 errors from a newer ruff: all stylistic classes
(Optional->| unions, quoted annotations, unicode multiplication signs in prose,
unused unpacked test variables, unused-noqa). Opt out of those in pyproject
(py3.9 typing floor is deliberate) and fix the two real ones: an unused import
in test_figure.py (F401) and a semicolon statement in abi_smoke.py (E702).

install_without_rust failed for the best possible reason: GitHub runners ship
Rust preinstalled, so the 'no toolchain' build happily compiled the native core
and produced a platform wheel, failing the py3-none-any assertion. The graceful
fallback works — the job now strips every cargo from PATH first so it actually
exercises the no-Rust path.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
Last lint error — the demo notebook's import block was un-sorted under ruff
0.15.20. Group stdlib/first-party and order import-before-from.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
ruff format --check flagged 13 files under the pinned formatter. Applied
ruff format (0.15.8, matching CI 0.15.20 output — same 13 files, all now
formatted). Pure formatting: no logic changes; ABI smoke still 18/18.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
Test job now failed at ty (0.0.56, pre-1.0) with 58 diagnostics, all in test/
benchmark code that intentionally subscripts known-non-None Optionals
(pick()->Optional[dict], color_ch: Optional[...]) — ty can't narrow those.
Scope ty to python/ (the shippable package, where type-checking has value) and
run it advisory (|| warning), so it reports without gating. pytest, ruff, and
cargo remain hard gates.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
The macOS wheels built the native core fine but the size-budget step used
GNU 'du -sb', which BSD du rejects. Use portable 'wc -c'. Test job, fallback,
install-without-rust, and ubuntu/windows wheels are already green.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
Abstract the shared machinery so a new chart (histogram, bar, area, heatmap)
plugs into seams instead of copy-pasting ingest + payload logic. Behavior-
preserving (matched the original branch structure exactly); ABI + render smokes
unchanged, pytest verifies in CI.

Python figure.py:
- _ingest_xy(x, y, kind): the equal-length ingest contract every xy chart
  shares; line()/scatter() now call it instead of repeating store.ingest + check.
- _PayloadWriter: one class owns the wire encoding (ship = offset-encoded
  geometry §4; ship_scalar = raw f32 channel/grid). build_payload no longer
  carries nonlocal closures.
- Per-kind emitters dispatched by _emit_trace via getattr(_emit_<kind>): the
  build_payload loop is no longer a monolith. Shared _base_entry (spec skeleton)
  and _finite_sel (§19 non-finite drop) are reused across kinds. Adding a chart
  = add one _emit_<kind> method.

Python components.py:
- Mark appliers registry (_MARK_APPLIERS: kind -> apply fn) replaces the
  hardcoded scatter/line if/else in Chart.figure(). Adding a chart = one
  applier + a thin *_chart wrapper.

JS renderer already factors the shared paths (_bindScalarAttr, _lut, _upload,
_map) with a per-kind draw dispatch in _drawNow — a rectangle renderer slots in
there without touching the shared helpers.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
…der gate

- Selection moved widget.py -> figure.py: it's the on_select payload with zero
  widget/anywidget dependency; misplaced module. Now exported from the package
  root (users type their callbacks) without pulling anywidget at import time
  (§33 import budget). widget.py re-exports for backward compatibility.
- scripts/smoke_render.py was dead code that shouldn't be dead — the only test
  driving a REAL Figure (line decimation + scatter) through to_html's payload
  path into a browser; nothing executed it. Wired into the CI test job using
  the Playwright Chromium already installed there.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
The deferred audit items, now applied with zero behavior change:

Python (figure.py 704 -> ~470 lines):
- config.py: tier/tuning constants (PROTOCOL_VERSION, thresholds, DENSITY_GRID,
  palette) shared by figure/interaction/export without import cycles; figure
  re-exports them (historic import path — tests unchanged).
- interaction.py: pick / select_range / to_shipped_indices / decimate_view /
  density_view moved verbatim (self -> fig); Figure keeps thin delegating
  methods so the public API is untouched.
- export.py: to_html template + escaping moved out; Figure delegates.

JS (js/src/fastcharts.js 1337 lines -> 7 ordered parts):
- Split exactly at the existing section banners (header/colormaps/theme/ticks/
  gl/chartview/entries); build.mjs concatenates in a stated order. The built
  static artifacts are byte-identical to the previous monolith build (verified
  by git diff --quiet), so this refactor provably cannot change the client.

Verified: ruff check+format clean, ABI smoke 18/18, stdlib render smoke green,
built artifacts unchanged. CLAUDE.md/README paths updated.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
Two import-order fixes in reflex_fastcharts_app so CI lint stays green.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
Closes the everyday Plotly zoom-parity gap:

- Modebar (top-right, faint until hover): zoom in/out, pan/box-zoom
  toggle, reset. Inline stroke SVGs (currentColor) — no external assets.
- Box-zoom: a "zoom" drag-mode fits the view to the dragged rectangle,
  keyed off the modebar toggle; shift-drag still box-selects.
- Center-anchored button zoom shares the deep-zoom precision floor
  (§16) so we never zoom past f32 resolution.

Render smoke extended to assert the modebar renders, zoom-in shrinks
the span, box-zoom fits the rectangle, and the drag-mode toggle flips
the cursor.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS5WXAwy3xr5YUH3RH2wzK
Alek99 and others added 16 commits July 8, 2026 14:49
50_chartview.js was a 3486-line class holding every ChartView concern.
Decompose the cohesive subsystems into ordered parts that augment the
prototype (no bundler, so `Object.assign(ChartView.prototype, {...})` after
the class definition — `this.*` and hot-path method lookup are unchanged):

- 51_annotations.js  annotation canvas overlay (markers/arrows/shapes/labels)
- 52_tooltip.js      hover -> source-row resolution + tooltip DOM
- 53_interaction.js  pointer/drag/wheel, selection, modebar, view animation
- 54_kernel.js       kernel comm: view-requests, streaming append, drill (§16)

50_chartview.js -> 2332 lines (core: layout, GL, marks, draw, chrome, pick).
Render smoke passes unchanged (96.6% lit; tooltip/selection/zoom/drill/
annotation paths all green).

Also fixes a real, latent release blocker found while wiring the build: the
bundler split 60_entries.js on `// ---- exports ----` but that marker shared
its line with descriptive prose, so the prose leaked into index.js as a bare
`(everything below ...)` line — a syntax error in the ESM that widget.py
ships as anywidget's `_esm`. CI never caught it (smoke tests use the IIFE
standalone bundle). Move the prose to its own line and harden build.mjs to
reject any text trailing the marker, so this can't regress. index.js now
parses as valid ESM; new test_widget_bundle_is_valid_esm guards it.

Static-client tests now assert source invariants against the concatenated
js/src parts (split-agnostic) plus each built bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two chrome surfaces still pinned themeable properties inline, which beat a
user's utility class on specificity — the audit's remaining customizability
gaps:

- Annotation labels (DOM) hardcoded font + color inline and carried no slot.
  Add the `annotation_label` slot, stamp `data-fc-slot`, move font/color
  defaults into the zero-specificity :where() stylesheet (--chart-annotation-
  text), and pin color inline only when the annotation explicitly sets one.
- The plot cursor was set inline (canvas cssText + _setDragMode), so a
  `cursor-*` utility couldn't win. Drive it off a `data-fc-dragmode` attribute
  with defeatable :where() defaults (--chart-cursor/-pan); same for the modebar
  button's cursor:pointer.

Every rendered DOM chrome element is now a defeatable slot. New
docs/styling.md documents all 20 slots, the four styling mechanisms, the
zero-specificity contract, every --chart-* token, and the canvas limitation
(painted marks/shapes aren't CSS-addressable). Tests assert the new slot and
that no themeable cursor/font stays inline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`ty check python` now passes clean. The four diagnostics were all the same
numpy-stubs limitation: `.max()`/`.min()` mis-resolve their no-arg overload for
the kernel f32 grid and the mask-indexed f64 column view, even though the value
is a finite scalar at runtime. Documented per-line `# ty: ignore[invalid-
argument-type]` at each site rather than reshaping correct code around a stub
bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The marks themselves now take the customizations users reach for in CSS,
via props that speak CSS (both APIs, parity-guarded):

- fill="linear-gradient(...)" on area/bar/column/histogram: real CSS
  syntax — direction keywords, 2–8 stops with % positions (CSS resolution
  rules), currentColor = the mark's own resolved color, and premultiplied
  interpolation so fades to transparent keep their hue. Mark-space by
  default (t runs base->tip along each mark's value axis, so an area fades
  from its curve to the baseline); plot-space opt-in via
  {"gradient": ..., "space": "plot"}.
- corner_radius / stroke / stroke_width on the bar family: antialiased
  rounded-rect + border SDF in the shared rect/bar fragment shader.
  corner_radius=(tip, base) rounds only the value end (the classic
  rounded-top bar), orientation- and negative-bar-aware via the same v_t
  mark coordinate the gradients use. Radius/stroke/gradient at their
  defaults compile to the exact old flat-quad output — the render smoke's
  lit-pixel count is byte-identical.
- curve="smooth" on line/area: monotone cubic (Fritsch–Carlson — never
  overshoots, affine-safe on offset-encoded columns), applied at build and
  re-applied on every zoom-refined tier_update window, capped at 32k verts.
  Purely visual: hover/tooltips keep reading the source rows.
- All mark colors — gradient stops and strokes included — resolve as live
  CSS (var()/oklch()/named) and re-resolve on theme change.

Python validates everything fail-fast (linear-gradient parser with CSS
stop-position semantics in _validate.mark_fill); the client receives
normalized stops only. Headless-Chromium smoke gains 7 pixel probes:
gradient fade on both GPU programs, all-corner + tip-only rounding, stroke
color at the border, and smooth densification (65 GPU verts from 5 source
rows with _cpu intact). docs/styling.md gains the full mark-styling matrix
+ roadmap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The x/y axis baselines were stroked on the chrome canvas, which the DOM
stacks *below* the WebGL marks canvas. A filled mark that reaches the
baseline (bar, zero-based area) therefore painted over the axis line, so it
only peeked between bars — the bars looked like they bled past the axis.

Grid lines belong behind the data and stay on the chrome canvas; the axis
baselines now render as crisp 1px rules in the labels overlay, which sits
above the marks. Bars/areas terminate cleanly on a continuous baseline
(verified: the axis-color line now shows on top of the bar's bottom rows).
They honor the existing axis_color / axis_width style API and are rebuilt
with the labels (static between throttled zoom frames — the plot rect
doesn't move on zoom). Marks still fill to the canvas bottom, so the
zero-gap baseline guarantee (bar/hist smoke) is unchanged; a new smoke probe
asserts the baselines are overlay rules above the marks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dashboards need chrome-less charts that fill their box. Two small primitives:

- Figure(padding=...) overrides the label-aware plot margins — a scalar or
  (top,right,bottom,left) px; 0 gives an edge-to-edge sparkline. Threaded
  through the composition Chart too; emitted as spec.padding and honored by
  the client's _layout.
- tick_label_strategy="none" now hides every tick label (it previously just
  skipped collision layout and drew them all — the intuitive meaning was a
  no-op). Grid/axis colors set to transparent finish the chrome-less look.

examples/dashboard/site_overview.py recreates an Ahrefs-style site overview:
five metric sparklines (flat line, rising/gradient areas, spike, volatile,
decline) built with padding=0 + curve="smooth" + linear-gradient fills, the
bundle embedded once and each chart mounted into its card. Charts use
width="100%" to fill their panel via ResizeObserver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`opacity` was silently ignored when a mark used a gradient fill: the gradient
branch replaced the color outright, dropping u_color.a. Multiply the (pre-
multiplied) gradient sample by the mark opacity so one scalar fades every stop,
including a fade-to-transparent, on both rect and area programs.

Opacity is now uniform across the mark surface: `opacity` (fill/body, gradient
included), `line_opacity` (area outline), and full CSS alpha on any color
(`rgba()`, `#rrggbbaa`, `oklch(.. / %)`). The stroke keeps its own color alpha,
so translucent-fill + solid-border is `bar(opacity=.3, stroke=.., stroke_width=2)`.
docs/styling.md gains an Opacity section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…segment

An area is one instanced quad per segment, and the gradient position was
v_t = c.y — the corner's fraction *within its own quad* (0 at the baseline, 1
at that segment's top). Because each segment's top sits at a different height,
adjacent quads disagreed on the gradient value at a shared screen row, leaving
vertical seams down the fill (worst where the curve varies fast).

Compute v_t from the fragment's clip-space Y instead (clip -1..1 -> 0..1), so
the gradient runs continuously through the plot's vertical space across every
segment — no seams, same "color at the curve, fade to the baseline" look. Bars
keep their per-quad v_t (each bar is one isolated quad, so no seam). Verified
before/after on a 60-point smooth area.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous fix stopped the streaks by anchoring the gradient to the plot box
(clip Y), but that made saturation depend on absolute height — a tall peak
reached the top of the ramp while a low stretch never did, so the fill read
darker under the peaks.

Normalize per column instead: 0 at the baseline, 1 exactly at the curve, so the
fill is evenly saturated right under the line whatever its height. The trick
that keeps it seam-free (where the original per-quad c.y streaked): carry the
curve-top, baseline, and fragment Y as three separate varyings — each linear in
x and continuous across segments — and divide them in the fragment. Dividing an
interpolated ratio facets over the slanted-top quad; dividing separately-
interpolated endpoints doesn't. Bars are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bring the README current with this cycle's work: the status line and API
section now cover CSS/Tailwind mark styling (gradient fills, rounded/stroked
bars, smooth curves, opacity, CSS-alpha colors) and edge-to-edge sparklines
(padding=0 + none-hides-labels), with a runnable styling snippet. Add the
examples/dashboard/site_overview.py Ahrefs-style dashboard and docs/styling.md
to Example Apps, note mark styling in What Exists, and drop annotations from the
roadmap backlog (already shipped).
scatter() gains symbol (circle/square/diamond/triangle/cross), stroke, and
stroke_width. Each shape is an antialiased SDF in the point fragment shader
(u_symbol picks the marker; the point sprite carries its device size so the
px stroke width converts to sprite space), and the border is a true ring
mixed over the fill — a stroke width with no color borders in the mark color,
matching the rect family. u_symbol=0 with no stroke reduces to the old circle
(render smoke unchanged). Threaded through both APIs (parity-guarded) and
re-resolved on theme change; symbols compose with color/size channels and the
selected/unselected states. docs/styling.md updated.
…cted color)

mark_style already drove hover color/size/opacity and selected/unselected
opacity; the one gap was selected/unselected *color* — the point shader only
dimmed alpha. Add per-state recolor: the point carries its selection flag to
the fragment shader, which tints toward u_selColor / u_unselColor (mix weight
0 = keep native color) when a selection is active, on the same u_selActive path
as the existing dim. So a selection can both highlight the chosen points and
gray out the rest in one pass. Verified end-to-end (left-half selection ->
magenta, right -> faint gray). docs/styling.md gains an interaction-states
section.
line()/area() gain dash=: a preset ("dashed"/"dotted"/"dashdot"/"solid") or an
explicit [on, off, …] px sequence (SVG/CSS convention). Dashes are measured in
screen-space arc length so they stay a constant on-screen size through zoom and
run continuously across every segment of a curve — a naive per-segment phase
would stipple dense polylines.

The line shader gains cumulative-length attributes (a_len0/a_len1) fed from a
per-frame CPU pass: for a dashed trace, _lineDash walks the drawn vertices,
sums device-px segment lengths for the current view, and uploads them; the
fragment walks the pattern array (feathered edges). O(vertices) per draw,
dashed traces only; solid lines set u_dashCount=0 and are pixel-unchanged
(render smoke stable). Smooth/decimated tiers keep the dash geometry current.
Threaded through both APIs (parity-guarded); docs/styling.md documents it.
plotly + matplotlib are used by scripts/bench_vs.py (three-way benchmark) and
notebook side-by-sides but were installed ad hoc. Group them under a `bench`
optional-dependencies extra, out of `dev` so the main test/CI install stays
lean: `uv pip install -e ".[bench]"`.
Figure.to_svg() / Chart.to_svg() render the same decimated wire payload the
browser client consumes into a standalone SVG — pure Python, no browser, no
new dependencies. Because M4/density decimation runs first (at the export
width), output is screen-bounded by construction: a 10M-point line exports in
~4 ms as a ~58 KB, resolution-independent file (measured), where the Chromium
to_png path takes seconds. to_png stays as the pixel-exact raster fallback.

The renderer (_svg.py) mirrors the client: layout margins/padding, the
30_ticks.js tick math (linear/log/time/calendar/category + formatters), the
10_colormaps.js LUT stops (test-guarded sync), and the full mark styling
surface — gradient fills as <linearGradient>, dashes as stroke-dasharray,
scatter symbols/strokes, rounded (tip, base) bars as arc paths, and smooth
curves emitted as *exact* cubic Béziers of the monotone-cubic Hermite (the
control points survive the affine axis map, so no polyline densification).
Density/heatmap tiers embed as compact stdlib-encoded PNG rasters. Documented
approximations: area mark-space gradients use the area bbox; var() colors
fall back to the mark color (no DOM).
Kernel-less pages (to_html exports) used to stretch the density overview
texture on zoom — the client couldn't refine without a kernel round-trip.
Now the recorded §28 sample re-bins in a bundled Web Worker on view change:
the worker script travels inside the bundle and boots from a Blob URL (the
standalone CSP tightens from worker-src 'none' to blob: — own-source workers
only), binning happens entirely off the main thread with transferable
buffers, and the result applies through the LOD plumbing as a view-fitted
grid. The reduction is badged ("zoom re-binned from sample"), the full
kernel-binned overview is restored at the home view, the sample overlay —
the re-bin source — is never touched, and environments without workers fall
back to the old stretched-overview behavior.

The render smoke now runs every probe under the production CSP (string
test-guarded against export._STANDALONE_CSP) and proves the worker path
end-to-end in headless Chromium: zoom a kernel-less density chart -> the
worker returns a grid fitted to the zoom window, badge shown, home restore
intact. The smoke's hand-built density trace gains the sample real payloads
always carry.

This closes the deliberately-deferred Performance item (dossier Phase 1,
worker-side compute for standalone refinement).
Alek99 added a commit that referenced this pull request Jul 9, 2026
Squash-rebase of codex/finance-charting-surface (old-repo PR #1) onto the
restructured main — the original branch predated the figure.py mixin split,
the chartview.js part split, the multi-axis system, and the mark-styling
work, and contained a merge commit the repo ruleset forbids.

What lands:
- candlestick + ohlc chart kinds through both APIs (Figure.candlestick/ohlc,
  fc.candlestick(_chart)/fc.ohlc(_chart)), with OHLC columns on Trace, a
  shared-y-frame emitter (_payload.ship_at keeps all four price columns in
  one f32 offset frame), and low/high-driven y autorange.
- finance.py: FinanceChart/FinanceLayer — overlays, studies, volume panes,
  drawings — plus 57_layers.js (LAYER_KINDS client registry), candle/OHLC
  WebGL programs, multi-pane plot layout (volume + oscillator strips), and a
  right-side price axis (Figure(y_side=...), finance area aliases
  fill_color/width/fill_opacity/baseline).
- example app: candlestick editor + 1B-point live drilldown demo pages,
  build script, and generated assets.

Merge notes: emitters live in _payload.py (the branch's stale figure.py
copies were dropped); the pane split is grafted into the current padded/
compact _layout; candle tier updates ride the 54_kernel tier_update path;
lazy candleProg matches the lazy-shader cache; grid floor uses the price
pane height. Parity/registration guards extended (candlestick/ohlc in
MARK_PAIRS, MARK_FACTORIES, sample marks, smoke asset lists, api-examples,
dashboard snippets). Full gate: 877 tests, ruff/ty clean, render smoke green.
@Alek99
Alek99 force-pushed the codex/finance-charting-surface branch from 059ea08 to 233b79a Compare July 9, 2026 05:27
Squash-rebase of codex/finance-charting-surface (old-repo PR #1) onto the
restructured main — the original branch predated the figure.py mixin split,
the chartview.js part split, the multi-axis system, and the mark-styling
work, and contained a merge commit the repo ruleset forbids.

What lands:
- candlestick + ohlc chart kinds through both APIs (Figure.candlestick/ohlc,
  fc.candlestick(_chart)/fc.ohlc(_chart)), with OHLC columns on Trace, a
  shared-y-frame emitter (_payload.ship_at keeps all four price columns in
  one f32 offset frame), and low/high-driven y autorange.
- finance.py: FinanceChart/FinanceLayer — overlays, studies, volume panes,
  drawings — plus 57_layers.js (LAYER_KINDS client registry), candle/OHLC
  WebGL programs, multi-pane plot layout (volume + oscillator strips), and a
  right-side price axis (Figure(y_side=...), finance area aliases
  fill_color/width/fill_opacity/baseline).
- example app: candlestick editor + 1B-point live drilldown demo pages,
  build script, and generated assets.

Merge notes: emitters live in _payload.py (the branch's stale figure.py
copies were dropped); the pane split is grafted into the current padded/
compact _layout; candle tier updates ride the 54_kernel tier_update path;
lazy candleProg matches the lazy-shader cache; grid floor uses the price
pane height. Parity/registration guards extended (candlestick/ohlc in
MARK_PAIRS, MARK_FACTORIES, sample marks, smoke asset lists, api-examples,
dashboard snippets). Full gate: 877 tests, ruff/ty clean, render smoke green.
@Alek99
Alek99 force-pushed the codex/finance-charting-surface branch from 233b79a to 0e85f21 Compare July 9, 2026 05:28
Alek99 added a commit that referenced this pull request Jul 9, 2026
Architecture-audit remediation (render path + small-data TTFR):

- VAOs (audit #1, the biggest lever): every draw previously re-issued
  getAttribLocation + enable + pointer + divisor ~6-10x per trace per
  frame. Bind attribute slots at link time (ATTR_SLOTS in 40_gl.js) so
  they're compile-time constants, then build one VAO per (trace x
  draw-config) keyed by buffer ids + channel on/off state, rebuilt only
  when that signature changes. All draw/pick paths (_drawPoints,
  _drawLine, _drawArea, _drawRects, _drawBars, _drawHoverPoint,
  _renderPick) now bind a VAO; the dead _bindScalarAttr/_disableAttr
  helpers are removed. Drill siblings carry their own VAOs, freed in
  lodDropDrill + _destroyTraceResources.
- Cached caps (audit #4): the density/heatmap draws did a per-frame
  gl.getParameter(MAX_VERTEX_ATTRIBS) + a disable-every-slot loop to
  scrub leftover attrib state. VAOs isolate that state per draw, so the
  driver round-trip and the loop are gone — both grid draws now bind a
  prebuilt quad VAO.
- Lazy shader compile (audit #2): the 8 programs compiled eagerly at
  every chart init. They now compile on first use behind getters + a
  cache, so a simple line chart links one program, not seven, before
  first paint.
- Bundle compaction (small-data #3): js/build.mjs gained a
  comment/whitespace compactor (template/string/regex-literal aware,
  line-structure preserving so ASI can't shift), shrinking the shipped
  bundles 193KB -> 154KB (20%) that every to_html() inlines and the
  widget parses on first paint. Deliberately not a renaming minifier:
  the client-security tests grep exact code lines in the built bundle,
  and mangling would need a real JS-parser dependency (violates the
  dependency-free build, §33). Compacted output is parse-validated via
  new Function() at build time so a compactor bug fails the build.

The one comment-marker assertion in test_static_client_security moved
to the source file (comments don't survive compaction; code markers
still checked in the bundles).

Verified: full headless render smoke green (all families + pick +
box-select + LOD drill + context-loss restore) at the identical
96.571% lit fraction as before; pytest 810; bundles fresh.
@Alek99 Alek99 closed this Jul 9, 2026
Alek99 added a commit that referenced this pull request Jul 9, 2026
Squash-rebase of codex/finance-charting-surface (old-repo PR #1) onto the
restructured main — the original branch predated the figure.py mixin split,
the chartview.js part split, the multi-axis system, and the mark-styling
work, and contained a merge commit the repo ruleset forbids.

What lands:
- candlestick + ohlc chart kinds through both APIs (Figure.candlestick/ohlc,
  fc.candlestick(_chart)/fc.ohlc(_chart)), with OHLC columns on Trace, a
  shared-y-frame emitter (_payload.ship_at keeps all four price columns in
  one f32 offset frame), and low/high-driven y autorange.
- finance.py: FinanceChart/FinanceLayer — overlays, studies, volume panes,
  drawings — plus 57_layers.js (LAYER_KINDS client registry), candle/OHLC
  WebGL programs, multi-pane plot layout (volume + oscillator strips), and a
  right-side price axis (Figure(y_side=...), finance area aliases
  fill_color/width/fill_opacity/baseline).
- example app: candlestick editor + 1B-point live drilldown demo pages,
  build script, and generated assets.

Merge notes: emitters live in _payload.py (the branch's stale figure.py
copies were dropped); the pane split is grafted into the current padded/
compact _layout; candle tier updates ride the 54_kernel tier_update path;
lazy candleProg matches the lazy-shader cache; grid floor uses the price
pane height. Parity/registration guards extended (candlestick/ohlc in
MARK_PAIRS, MARK_FACTORIES, sample marks, smoke asset lists, api-examples,
dashboard snippets). Full gate: 877 tests, ruff/ty clean, render smoke green.
Alek99 added a commit that referenced this pull request Jul 9, 2026
Squash-rebase of codex/finance-charting-surface (old-repo PR #1) onto the
restructured main — the original branch predated the figure.py mixin split,
the chartview.js part split, the multi-axis system, and the mark-styling
work, and contained a merge commit the repo ruleset forbids.

What lands:
- candlestick + ohlc chart kinds through both APIs (Figure.candlestick/ohlc,
  fc.candlestick(_chart)/fc.ohlc(_chart)), with OHLC columns on Trace, a
  shared-y-frame emitter (_payload.ship_at keeps all four price columns in
  one f32 offset frame), and low/high-driven y autorange.
- finance.py: FinanceChart/FinanceLayer — overlays, studies, volume panes,
  drawings — plus 57_layers.js (LAYER_KINDS client registry), candle/OHLC
  WebGL programs, multi-pane plot layout (volume + oscillator strips), and a
  right-side price axis (Figure(y_side=...), finance area aliases
  fill_color/width/fill_opacity/baseline).
- example app: candlestick editor + 1B-point live drilldown demo pages,
  build script, and generated assets.

Merge notes: emitters live in _payload.py (the branch's stale figure.py
copies were dropped); the pane split is grafted into the current padded/
compact _layout; candle tier updates ride the 54_kernel tier_update path;
lazy candleProg matches the lazy-shader cache; grid floor uses the price
pane height. Parity/registration guards extended (candlestick/ohlc in
MARK_PAIRS, MARK_FACTORIES, sample marks, smoke asset lists, api-examples,
dashboard snippets). Full gate: 877 tests, ruff/ty clean, render smoke green.
Alek99 added a commit that referenced this pull request Jul 9, 2026
Squash-rebase of codex/finance-charting-surface (old-repo PR #1) onto the
restructured main — the original branch predated the figure.py mixin split,
the chartview.js part split, the multi-axis system, and the mark-styling
work, and contained a merge commit the repo ruleset forbids.

What lands:
- candlestick + ohlc chart kinds through both APIs (Figure.candlestick/ohlc,
  fc.candlestick(_chart)/fc.ohlc(_chart)), with OHLC columns on Trace, a
  shared-y-frame emitter (_payload.ship_at keeps all four price columns in
  one f32 offset frame), and low/high-driven y autorange.
- finance.py: FinanceChart/FinanceLayer — overlays, studies, volume panes,
  drawings — plus 57_layers.js (LAYER_KINDS client registry), candle/OHLC
  WebGL programs, multi-pane plot layout (volume + oscillator strips), and a
  right-side price axis (Figure(y_side=...), finance area aliases
  fill_color/width/fill_opacity/baseline).
- example app: candlestick editor + 1B-point live drilldown demo pages,
  build script, and generated assets.

Merge notes: emitters live in _payload.py (the branch's stale figure.py
copies were dropped); the pane split is grafted into the current padded/
compact _layout; candle tier updates ride the 54_kernel tier_update path;
lazy candleProg matches the lazy-shader cache; grid floor uses the price
pane height. Parity/registration guards extended (candlestick/ohlc in
MARK_PAIRS, MARK_FACTORIES, sample marks, smoke asset lists, api-examples,
dashboard snippets). Full gate: 877 tests, ruff/ty clean, render smoke green.
FarhanAliRaza added a commit that referenced this pull request Jul 15, 2026
- Legend now (re)freezes options and swatch scaling via _attach() when
  added to an axes, so a legend constructed against one axes but
  attached to another picks up the host's dpi/rcParams (review #1).
- Legend.__init__ warns on handles/labels length mismatch and on
  handles without a legend entry instead of dropping them silently
  (review #4).
- JS tracks every legend box (not just the primary) so _resize
  refreshes max-height on extra legends too (review #2).
- JS line swatch uses ?? so an explicit lw=0 draws nothing instead of
  falling back to 1.5px (review #3).
- Drop unreachable return in _best_legend_loc (review #5).
FarhanAliRaza added a commit that referenced this pull request Jul 15, 2026
* Improve pyplot legend and dash parity

* Fix extra legend rendering regressions

* Address review: legend attach recompute, resize tracking, lw=0 swatch

- Legend now (re)freezes options and swatch scaling via _attach() when
  added to an axes, so a legend constructed against one axes but
  attached to another picks up the host's dpi/rcParams (review #1).
- Legend.__init__ warns on handles/labels length mismatch and on
  handles without a legend entry instead of dropping them silently
  (review #4).
- JS tracks every legend box (not just the primary) so _resize
  refreshes max-height on extra legends too (review #2).
- JS line swatch uses ?? so an explicit lw=0 draws nothing instead of
  falling back to 1.5px (review #3).
- Drop unreachable return in _best_legend_loc (review #5).

---------

Co-authored-by: Alek <alek@reflex.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants