diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b95ee02e..fc7e3561 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,11 @@ jobs: echo "using $CHROME" python3 scripts/render_smoke_nonumpy.py "$CHROME" + - name: Polar GLSL parity smoke (stdlib + Chromium) + run: | + CHROME=$(node -e "console.log(require('playwright').chromium.executablePath())") + python3 scripts/polar_parity_smoke.py "$CHROME" + - name: Streaming append smoke (stdlib + Chromium) run: | CHROME=$(node -e "console.log(require('playwright').chromium.executablePath())") @@ -196,6 +201,11 @@ jobs: CHROME=$(node -e "console.log(require('playwright').chromium.executablePath())") .venv/bin/python scripts/smoke_render.py "$CHROME" + - name: Polar phase 6/7 live examples (Chromium + native exporters) + run: | + CHROME=$(node -e "console.log(require('playwright').chromium.executablePath())") + .venv/bin/python scripts/polar_phase7_smoke.py "$CHROME" + - name: Browser lifecycle smoke (Chromium) run: | CHROME=$(node -e "console.log(require('playwright').chromium.executablePath())") diff --git a/CHANGELOG.md b/CHANGELOG.md index ef1ff3e0..e81dcd30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,130 @@ in the README). ## [Unreleased] +### Added +- Completed the phase-6/7 polar depth surface: `xy.polar_chart` now admits + heatmap, contour, and error-bar traces alongside line/scatter/area/bar; the + heatmap uses a fragment-stage polar inverse in the browser and the matching + bounded inverse raster for static export. Polar axes add partial-sector + layout, `hole`/radial origin, categorical theta, log/symlog radius, and + polygonal `grid_shape="linear"` rings. `xy.pyplot` exposes degree-based + `set/get_thetamin`, `set/get_thetamax`, and radial + `set/get_rorigin`. Generic segment/mesh marks, polar rule/band annotations, + LOD, facets/animation, and angular navigation/selection remain deferred. +- CodSpeed coverage for the polar coordinate system + (`benchmarks/test_codspeed_polar.py`, a new `polar_coordinate_system` benchmark + category): payload prep for a polar line, a wind rose and a pie, plus SVG, + native-PNG and polar-heatmap export. The polar increment previously moved no + benchmark at all, so the wedge-flattening cost and the polar payload path were + invisible to CI. The collected row count is now gated against + `spec/benchmarks/methodology.md` §8, so a renamed or deleted benchmark cannot + silently leave a stale row in the CodSpeed dashboard. + +### Fixed +- Polar customization now stays consistent across the browser, SVG, and native + raster renderers: point-anchored annotations use the joint `(theta, r)` + projection, explicit chart padding survives the polar layout pass, gradient + fills reach native raster wedges, and annular sectors honor rounded corners + and strokes. +- `radar_chart(fill=False)` now renders area children as styled outlines, + translating their line color, width, opacity, curve, and dash props instead + of passing incompatible area props to the line renderer. +- Repeated data updates no longer leak GPU buffers. Trace teardown walked a + hand-kept list of geometry buffer names, so every rebuilt trace — each + state-driven update, each append that could not patch in place, each animated + spec swap — orphaned its style, direct-RGBA colour, stroke, corner-radius, + LOD-blend and dashed-line-length buffers. All three teardown paths (trace, + drill window, sample overlay) now read one shared `TRACE_GPU_BUFFERS` list, + pinned against the build paths by a test so a new channel cannot reintroduce + the leak. +- Chart titles reserve the lines they actually wrap into. The browser wrapped a + long title while layout measured one line, so a compact Wind Rose title lost + about 10 px off the top of the canvas. Titles now wrap at one shared width in + all three renderers, and the browser caps the title element at that same width. + Single-line titles are unchanged. +- A polar figure with a legend reserves a gutter for it and places it there, + instead of overlaying the disc. A default `upper right` legend covered a wind + rose's north-east sectors and its outer radial tick label; a disc inscribed in + its rect has no free corner to overlay. The gutter is 22% of the canvas width, + clamped to 120-200 px — derived from the canvas so all three renderers reserve + the identical box, and wide enough to hold an ordinary row rather than + ellipsize it. Compact widths take a 64 px band beneath the disc instead. An + authored `anchor` or four-tuple `padding` still wins. +- Compact vertical colorbars keep their two extreme tick labels, restacked above + and below the gradient. Collapsing them hid every number, leaving an unlabelled + gradient; only the interior ladder, the rotated title and the text-free minor + ticks drop now, and the box's own `title`/ARIA text still names the scale. + Stacking is what makes it free: a side gutter wide enough for `0.25` would cost + 36 px of the plot width the compact collapse exists to protect. +- A time-valued radial axis autoranges from its data instead of from epoch zero, + which had squeezed every modern instant into a hairline ring at the rim. An + explicit `r_axis(margin=)` restores the outer pad it used to discard. +- `theta_axis(format=...)` now wins over the built-in degree/radian tick text in + every renderer, instead of shipping and being overwritten. +- A zero-width bar is legal and draws nothing, like `line_width=0`. A 0% progress + ring, an empty category in aggregated data, and a hand-rolled wedge at zero all + produce one, and each used to fail with "bar width must be positive". Negative + and non-finite widths are still refused. +- Data animations no longer rebuild the whole tick-label DOM on every frame; they + use the same 80 ms cadence view animations use, and force one settled rebuild + when the transition ends. +- A device-pixel-ratio change (browser zoom, or a window moving between displays) + now rescales the per-instance stroke widths and corner radii that are baked in + device pixels, so authored strokes and wedge corners keep their intended size + across a zoom. The DPR handler stays synchronous: a DPR change with no container + resize has no later event to piggyback on. A trace whose CPU style/radius mirror + no longer spans every row on the GPU — which is what a streaming tail append + leaves behind — is skipped rather than repaired in place, so the existing + append-time rebuild still does the renormalizing for it. +- `xy.pie_chart` appears in the generated chart-factory API reference alongside + the other polar compositions. +- A legend row too wide for its box wraps instead of growing a horizontal + scrollbar. The box is capped at `--xy-legend-max-width`, but its grid columns + were `max-content` and refused to shrink, so an over-wide row overflowed and + `overflow:auto` answered sideways — hiding the label it was meant to show. + Columns are now `minmax(0, max-content)` and the inline axis never scrolls. + Vertical scrolling is unchanged, and rows carry their full name in + `title`/ARIA for the ones the height cap clips. +- `xy.pie_chart` no longer prints the same number twice. Values that already sum + to 100 — how most pie data arrives — made `show_values` and `show_percent` + collide, so `[40, 30, 20, 10]` rendered `Direct 40 (40%)`: a legend row that + reads as repeated text, and long enough to overflow the box. The share keeps + the unit and the bare value is dropped, decided once for the whole pie so rows + stay uniform. + ### Changed +- Default tooltips now lead with the hovered series name, and the radial row of + a polar readout is labelled `r` rather than presented as a Cartesian `y`. The + numeric angle row is gone from polar readouts: on most polar charts the angle + is where layout put the mark and the cursor is already on it, so it answered a + question nobody asked. Two things survive because they are not numeric angles + — an authored spoke label (a radar category reads `power`) and any row named + explicitly through `labels={"x": ...}`, which opts the angle back in formatted + through the theta axis's own text function. Compositions whose bearing *is* + data say so themselves: a wind rose band still reads its direction, and a pie + slice reads its category and value. +- Polar wedge subdivision is span-proportional: `segments(span) = + clamp(ceil(96 · |span| / turn), 2, 96)` in every renderer, over the *authored* + angular width. Sagitta is quadratic in the per-segment angle, so this holds the + flattening bound while a 16-sector wind-rose bar costs 14 vertices instead of + 194 — the fixed full-turn count made ~50k polar bars build ~9.7M vertices a + frame. A full-turn wedge still uses 96. +- `xy.theta_axis`/`xy.r_axis` now refuse the Cartesian axis keywords no polar + renderer implements — `minor_tick_values`, `minor_style`, + `tick_label_min_gap`, `tick_label_anchor`, and the collision spellings of + `tick_label_strategy` (`auto`, `hide`, `rotate`, `stagger`, `preserve`) — each + with a pointer to the control that does work. They previously rode the wire and + were dropped by all three renderers, so the documented axis surface advertised + options that did nothing. `off` and `none` remain honoured. `xy.pyplot`'s + `projection="polar"` drops the same keywords instead of refusing, because every + matplotlib Axes carries an rcParam-derived minor style it never authored; that + drop is recorded in `spec/matplotlib/compat.md`. +- The renderer/spec protocol is now v12. Angular axes resolve + `sector`/`grid_shape` and radial axes resolve `hole` plus optional + `r_origin`; a cached v11 client would silently draw full-circle, + centre-origin Cartesian grid/segment fallbacks, so the protocol mismatch + rejects it before rendering. The native renderer ABI is now v47 for the + annular-sector display-list clip opcode. - Contributor-only test, lint, type-check, and CodSpeed packages now live in PEP 735 dependency groups instead of published package extras. The unused Plotly-only `bench` extra was removed; cross-library benchmark environments diff --git a/README.md b/README.md index 5719af98..5ff79454 100644 --- a/README.md +++ b/README.md @@ -296,11 +296,13 @@ next, no dates implied: - **Categorical distributions:** strip, swarm, beeswarm, boxen, rug - **Regression diagnostics:** trendline, residual, QQ, PP - **Scatter matrix and joint plots:** SPLOM, pair grid, marginal histograms -- **Pie / donut:** in `xy.pyplot` today, promoting to `xy.pie_chart(xy.pie(...))` +- **Pie / donut:** `xy.pie_chart(labels, values)` ships in the composition API + alongside `xy.pyplot` and unequal-width core polar bars; nested donuts and + variable-radius composition remain - **Candlestick / OHLC and finance overlays:** SMA, VWAP, Bollinger, RSI, MACD; prototyped, awaiting a fresh landing - **Waterfall and funnel** - **Treemap, sunburst, and icicle** -- **Radar / polar and gauge:** needs polar axes first +- **Gauge / indicator:** build on the shipped polar axes and composable radial marks - **Slope, bump, and dumbbell** - **3D and volume:** scatter, surfaces, meshes, isosurfaces, and volumetric views diff --git a/benchmarks/README.md b/benchmarks/README.md index 3b99e41d..091228ed 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -382,6 +382,20 @@ production split transport. Run `bench_animation.py` for real-Chrome previous+next scene bound; browser clocks and GPU work do not belong in CodSpeed simulation. +`test_codspeed_polar.py` attributes the polar coordinate system, which shipped +without a CodSpeed row of its own: three payload rows for the shapes with +materially different validation and emit paths (a 100k polar line, a 16-sector / +50k-observation wind rose, a 24-slice pie whose unequal widths take the four-edge +column path), and three export rows. SVG draws real `A` arcs and needs no +subdivision count, so it is the control for the native-PNG row, which flattens +every wedge at `config.polar_bar_segments(span, turn)` vertices — six segments +for a 22.5-degree sector rather than the full-turn 96, so a regression back to a +flat count appears as an arc-flattening step change. The last row is a polar +heatmap's bounded screen-space inverse raster, which has no Cartesian twin. +Browser wedge vertex counts, GPU buffer lifetime, and radial-zoom frame pacing +are wall-clock/WebGL measurements and stay in `bench_interaction.py` and the +polar smokes. + `test_codspeed_selection.py` covers the backend handlers the client's gesture messages resolve to: hover pick readout with a categorical channel, zone-pruned and full-scan box select at 1M points, and the cross-filter diff --git a/benchmarks/categories.py b/benchmarks/categories.py index 6db43d9f..186baa86 100644 --- a/benchmarks/categories.py +++ b/benchmarks/categories.py @@ -126,6 +126,15 @@ "status": "tracked", "goal": "Compute correct positive log domains from zone statistics with cost proportional to chunks, not points.", }, + { + "id": "polar_coordinate_system", + "name": "Polar coordinate system", + "why": "Radar, wind rose, pie/donut and gauge views are a whole chart family, and a polar wedge is the most expensive mark in the engine — one annular sector per bar instead of one quad.", + "metrics": "payload-prep time, wedge flattening cost, SVG/PNG export latency, inverse-raster latency", + "harness": "benchmarks/test_codspeed_polar.py", + "status": "tracked", + "goal": "Keep polar payload prep bounded by composition size rather than observation count, and keep wedge subdivision proportional to each wedge's own angular span.", + }, { "id": "static_export", "name": "Static export", diff --git a/benchmarks/test_codspeed_polar.py b/benchmarks/test_codspeed_polar.py new file mode 100644 index 00000000..30903e6e --- /dev/null +++ b/benchmarks/test_codspeed_polar.py @@ -0,0 +1,228 @@ +"""CodSpeed attribution for the polar coordinate system. + +The polar increment shipped a whole coordinate system — a new payload-build +validation pass, and the most expensive mark in the codebase — with no CodSpeed +row anywhere near it, so the report read "103 untouched benchmarks" for a change +that rewrote wedge geometry in three renderers. A performance cliff at ~50k +polar bars was found by hand, not by CI, precisely because nothing here could +see it. + +These rows isolate the *Python* cost, which is what simulation mode measures: + +- payload build for the three shapes with materially different validation and + emit paths (a plain polar line, a stacked wind rose, an unequal-width pie); +- static SVG and native-PNG export of wedges, where `polar_wedge_points` / + `_polar_wedge_path` flatten one arc per wedge. This is the row that tracks + the span-proportional subdivision in `config.polar_bar_segments`: a + 16-sector rose flattens six segments per wedge rather than the full-turn + worst case of 96, and a regression back to a flat count shows up here as + roughly a 10x arc-flattening increase rather than as a bug report; +- static export of a polar heatmap, whose bounded inverse raster resolves + screen pixels back through the transform and has no Cartesian twin. + +Browser-side wedge vertex counts, GPU buffer lifetime, and radial-zoom frame +pacing are wall-clock/WebGL measurements and stay out of simulation mode — +`benchmarks/bench_interaction.py` and the polar smokes cover those. +""" + +from __future__ import annotations + +import math + +import numpy as np +import pytest + +import xy +from xy import kernels as k + +#: Angular samples for the line row: enough to make the projection and the +#: per-vertex cull the dominant term rather than figure setup. +LINE_N = 100_000 + +#: Sector count for the rose. Sixteen compass points is the dense end of what +#: real wind roses use, and it is the case the subdivision formula was sized +#: against (22.5 degrees per wedge). +ROSE_SECTORS = 16 +ROSE_OBSERVATIONS = 50_000 + +#: Pie slices. Unequal widths ship four edge columns rather than one scalar +#: width, which is a different emit path and a different flattening call. +PIE_SLICES = 24 + +#: Polar heatmap grid. Cell count, not point count, drives the inverse raster. +HEATMAP_THETA = 96 +HEATMAP_R = 48 + +N_BUCKETS = 2048 + + +@pytest.fixture(scope="session", autouse=True) +def require_native_backend() -> None: + assert k.BACKEND == "native", ( + "CodSpeed benchmarks must run against the native Rust backend; " + f"got {k.BACKEND!r}. Build the native core before running them." + ) + + +@pytest.fixture(scope="session", autouse=True) +def warm_lazy_modules() -> None: + """Warm the polar build and export stacks before any measured region. + + Same phantom-regression guard the other modules carry: without it the first + row pays lazy submodule import for the payload and export stacks and tracks + package source size instead of its own workload. Warmed through the polar + paths specifically, so `_validate_coords`, the wedge emitters and the + projection are all resident. + """ + theta = np.array([0.0, 90.0, 180.0, 270.0]) + radius = np.array([1.0, 2.0, 3.0, 2.0]) + figure = xy.polar_chart( + xy.line(theta, radius), + xy.theta_axis(unit="degrees"), + width=240, + height=240, + ).figure() + figure.build_payload_split(N_BUCKETS) + figure.to_svg(width=240, height=240) + xy.polar_bar_chart( + xy.bar(theta, radius, width=22.5), + xy.theta_axis(unit="degrees"), + width=240, + height=240, + ).figure().to_png(engine=xy.Engine.default, scale=1.0) + + +@pytest.fixture(scope="module") +def polar_data() -> dict[str, object]: + rng = np.random.default_rng(19) + theta = np.linspace(0.0, 360.0, LINE_N, dtype=np.float64) + # A five-lobe rose: the radius varies over the whole turn, so no vertex run + # is culled wholesale and the projection runs on every point. + radius = (1.0 + 0.5 * np.sin(np.radians(5.0 * theta))).astype(np.float64, copy=False) + return { + "theta": theta, + "radius": radius, + "directions": rng.uniform(0.0, 360.0, ROSE_OBSERVATIONS), + "speeds": rng.gamma(2.0, 3.0, ROSE_OBSERVATIONS), + "pie_labels": [f"S{index:02d}" for index in range(PIE_SLICES)], + "pie_values": (10.0 + 6.0 * np.cos(np.linspace(0.0, 9.0, PIE_SLICES))).astype( + np.float64, copy=False + ), + "grid_theta": np.linspace(0.0, 2.0 * math.pi, HEATMAP_THETA, dtype=np.float64), + "grid_r": np.linspace(0.5, 4.0, HEATMAP_R, dtype=np.float64), + } + + +def _polar_line_payload(theta: np.ndarray, radius: np.ndarray) -> int: + figure = xy.polar_chart( + xy.line(theta, radius), + xy.theta_axis(unit="degrees"), + ).figure() + _spec, buffers = figure.build_payload_split(N_BUCKETS) + return sum(b.nbytes for b in buffers) + + +def _wind_rose_payload(directions: np.ndarray, speeds: np.ndarray) -> int: + figure = xy.wind_rose(directions, speeds, sectors=ROSE_SECTORS).figure() + _spec, buffers = figure.build_payload_split(N_BUCKETS) + return sum(b.nbytes for b in buffers) + + +def _pie_payload(labels: list[str], values: np.ndarray) -> int: + figure = xy.pie_chart(labels, values).figure() + _spec, buffers = figure.build_payload_split(N_BUCKETS) + return sum(b.nbytes for b in buffers) + + +def test_first_payload_polar_line(benchmark, polar_data): + """Polar payload prep: coordinate validation plus the angular axis contract. + + A polar figure carries the same raw f32 geometry a cartesian one does — the + projection happens in the renderer — so the payload must stay bounded by the + f32 encoding of two columns, never grow a pre-projected third. + """ + theta = polar_data["theta"] + radius = polar_data["radius"] + assert isinstance(theta, np.ndarray) + assert isinstance(radius, np.ndarray) + payload_bytes = benchmark(_polar_line_payload, theta, radius) + assert 0 < payload_bytes <= (theta.nbytes + radius.nbytes) // 2 + + +def test_first_payload_wind_rose(benchmark, polar_data): + """Wind rose payload prep: Python-side binning plus stacked wedge columns. + + Binning happens in Python, exactly as `hist` does it, so the shipped bytes + must be bounded by sector count and band count — never by observation count. + That bound is the whole reason a rose over 50k observations is cheap. + """ + directions = polar_data["directions"] + speeds = polar_data["speeds"] + assert isinstance(directions, np.ndarray) + payload_bytes = benchmark(_wind_rose_payload, directions, speeds) + assert 0 < payload_bytes < directions.nbytes // 8 + + +def test_first_payload_pie(benchmark, polar_data): + """Pie payload prep: one wedge bar per slice, each with its own width. + + Unequal widths take the four-edge column path rather than the compact + scalar-width one, so this row tracks per-slice emit cost — the thing that + grows when a composition gains a slice, not when it gains a data point. + """ + labels = polar_data["pie_labels"] + values = polar_data["pie_values"] + assert isinstance(labels, list) + payload_bytes = benchmark(_pie_payload, labels, values) + assert payload_bytes > 0 + + +def test_svg_export_polar_wedges(benchmark, polar_data): + """Static SVG export of a dense rose: one real `A` arc pair per wedge. + + SVG needs no flattening count, so this row is the arc-emission and chrome + cost with the subdivision term removed — the control for the PNG row below. + """ + directions = polar_data["directions"] + speeds = polar_data["speeds"] + figure = xy.wind_rose(directions, speeds, sectors=ROSE_SECTORS).figure() + document = benchmark(figure.to_svg, width=720, height=720) + assert document.startswith(" None: """Validate the code-native chart tiles in the compiled gallery route.""" source = module_path.read_text(encoding="utf-8") preview_count = source.count('viewBox=\\"0 0 320 232\\"') - if preview_count != 28: - msg = f"Inline SVG gallery has {preview_count} previews, expected 28: {page_route}" + if preview_count != 33: + msg = f"Inline SVG gallery has {preview_count} previews, expected 33: {page_route}" raise RuntimeError(msg) for marker in ("gallery-preview-surface", "aspect-[320/232]", "shadow-large"): if marker not in source: diff --git a/docs/app/tests/test_docs_site.py b/docs/app/tests/test_docs_site.py index f15c4b8c..9769dcf1 100644 --- a/docs/app/tests/test_docs_site.py +++ b/docs/app/tests/test_docs_site.py @@ -70,15 +70,25 @@ ) from xy_docs.navbar import XY_GITHUB_STARS, XY_REPOSITORY_URL, xy_docs_navbar from xy_docs.sidebar import ( + CHART_FAMILY_SIDEBAR_SECTIONS, + CHART_GALLERY_SIDEBAR_LINK, INTEGRATION_LINK_ICONS, + PIE_DOCS_ROUTE, + POLAR_DOCS_ROUTE, + POLAR_DOCS_ROUTES, + POLAR_SIDEBAR_SECTION, + RADAR_DOCS_ROUTE, + RADIAL_BAR_DOCS_ROUTE, SIDEBAR_SECTION_GROUPS, + WIND_ROSE_DOCS_ROUTE, + _section_items, xy_docs_sidebar, xy_docs_sidebar_comp, ) from xy_docs.xy_docs import _CHART_STYLE, _DOCS_ROUTES, app import xy -from xy.components import _MARK_APPLIERS +from xy.components import _MARK_APPLIERS, _POLAR_INERT_AXIS_KEYWORDS SITEMAP_NAMESPACE = {"sitemap": "https://www.sitemaps.org/schemas/sitemap/0.9"} DOCS_APP_ROOT = Path(__file__).resolve().parent.parent @@ -255,8 +265,29 @@ def test_public_markdown_routes_match_the_docs_navigation() -> None: styling_leaves = next( leaves for title, _route, _icon, leaves in DOCS_SECTIONS if title == "Styling" ) + chart_gallery_leaves = next( + leaves for title, _route, _icon, leaves in DOCS_SECTIONS if title == "Chart Gallery" + ) assert ("Animations", "/styling/animations/") in styling_leaves assert ("Chrome Slots", "/styling/chrome-slots/") in styling_leaves + assert tuple( + leaf + for leaf in chart_gallery_leaves + if leaf[1] + in { + PIE_DOCS_ROUTE, + POLAR_DOCS_ROUTE, + RADAR_DOCS_ROUTE, + RADIAL_BAR_DOCS_ROUTE, + WIND_ROSE_DOCS_ROUTE, + } + ) == ( + ("Polar", POLAR_DOCS_ROUTE), + ("Radar", RADAR_DOCS_ROUTE), + ("Radial Bar", RADIAL_BAR_DOCS_ROUTE), + ("Pie & Donut", PIE_DOCS_ROUTE), + ("Wind Rose", WIND_ROSE_DOCS_ROUTE), + ) assert "/styling/chrome-slots/" not in DOCS_REDIRECTS assert ( max(len(tuple(part for part in route.split("/") if part)) for route in section_routes) <= 2 @@ -341,6 +372,20 @@ def test_component_styling_matrix_covers_public_chrome_boundaries() -> None: ) +def test_tooltip_docs_cover_named_series_and_polar_coordinates() -> None: + """Keep the default tooltip readout aligned with the client renderer.""" + content = " ".join((DOCS_ROOT / "components/tooltips.md").read_text(encoding="utf-8").split()) + + for requirement in ( + "hovered series name", + "label the radial row `r`", + "drop the numeric angle", + "An authored spoke label", + 'labels={"x": ...}', + ): + assert requirement in content + + def test_styling_docs_cover_every_public_dom_slot() -> None: """Make a new stable browser slot fail docs CI until it is documented.""" chrome = (DOCS_ROOT / "styling/chrome-slots.md").read_text(encoding="utf-8") @@ -1206,18 +1251,18 @@ def test_chart_gallery_grid_renders_every_type_as_inline_svg( chart_section = next( leaves for title, _landing_route, _icon, leaves in DOCS_SECTIONS if title == "Chart Gallery" ) - assert len(chart_section) == 14 + assert len(chart_section) == 19 assert "XYChart" not in rendered - assert rendered.count("dangerouslySetInnerHTML") == 28 + assert rendered.count("dangerouslySetInnerHTML") == 33 assert rendered.count('id:"xy-chart-gallery"') == 1 assert rendered.count("main:has(#xy-chart-gallery) > div:has(#toc-navigation)") == 1 assert rendered.count("main:has(#xy-chart-gallery) > div:has(article #xy-chart-gallery)") == 1 assert rendered.count("display: none") == 1 assert rendered.count("max-width: 88rem") == 1 - assert rendered.count("2xl:grid-cols-3") == 8 - assert rendered.count("aspect-[320/232]") == 28 - assert rendered.count("shadow-large") == 28 - assert rendered.count("transition-bg") == 28 + assert rendered.count("2xl:grid-cols-3") == 9 + assert rendered.count("aspect-[320/232]") == 33 + assert rendered.count("shadow-large") == 33 + assert rendered.count("transition-bg") == 33 assert "--gallery-preview-surface: #fff" in rendered assert "--gallery-preview-fill: #efeaff" in rendered assert "--gallery-preview-soft: #dccfff" in rendered @@ -1228,7 +1273,7 @@ def test_chart_gallery_grid_renders_every_type_as_inline_svg( assert "object-contain" not in rendered assert "object-center" not in rendered assert "xy-tailwind-bridge" not in rendered - assert rendered.count("size:14") == 28 + assert rendered.count("size:14") == 33 assert "size:6" not in rendered for chart_type in ( "Line", @@ -1236,6 +1281,11 @@ def test_chart_gallery_grid_renders_every_type_as_inline_svg( "Step", "Stairs", "Scatter", + "Polar", + "Radar", + "Radial Bar", + "Pie & Donut", + "Wind Rose", "Bar", "Column", "Histogram", @@ -1293,7 +1343,7 @@ def test_chart_gallery_inline_svgs_share_the_component_preview_style() -> None: for item in group.items } - assert len(previews) == 28 + assert len(previews) == 33 for svg in previews.values(): assert 'viewBox="0 0 320 232"' in svg assert ' None: assert rendered.count(f"#xy-chart-gallery .{scatter_tone} {{") == 1 +def test_polar_gallery_previews_share_a_safe_visual_frame() -> None: + """Keep every polar preview centered, equally scaled, and clear of its clip.""" + polar_previews = { + title: _gallery_preview_svg(title) + for title in ("Polar", "Radar", "Radial Bar", "Pie & Donut", "Wind Rose") + } + + for svg in polar_previews.values(): + assert '' in svg + + assert "M160 68L205.7 101.2L188.2 154.8" in polar_previews["Radar"] + assert 'r="55"' not in polar_previews["Radial Bar"] + assert "M164.8 61.2" not in polar_previews["Radial Bar"] + assert "M144.5 174" not in polar_previews["Radial Bar"] + assert ( + '' + in polar_previews["Radial Bar"] + ) + assert polar_previews["Radial Bar"].count("A13 13 0 0 0") == 12 + assert "M160 116L" not in polar_previews["Radial Bar"] + assert "preview-fill-soft" not in polar_previews["Radial Bar"] + + pie = polar_previews["Pie & Donut"] + assert '' in pie + assert pie.count('class="preview-fill-strong"') == 2 + assert 'class="preview-fill-soft"' in pie + assert 'class="preview-fill"' in pie + assert "M146 112H174M151 122H169" in pie + + wind_rose = polar_previews["Wind Rose"] + assert ( + '' in wind_rose + ) + assert wind_rose.count("M160 116L") == 8 + assert "M160 68V164M112 116H208" in wind_rose + assert wind_rose.count(" None: """Anchor only chart types with dedicated live sections.""" pages = {page.route: page for page in discover_docs(DOCS_CONFIG)} @@ -1361,6 +1455,11 @@ def test_chart_gallery_cards_link_to_family_pages_with_live_demo_anchors() -> No # mark's live demo. standalone_chart_marks = { "/charts/line-chart/": "xy.line(", + "/charts/polar-chart/": "xy.polar_chart(", + "/charts/radar-chart/": "xy.radar_chart(", + "/charts/radial-bar-chart/": "xy.polar_bar_chart(", + "/charts/pie-chart/": "xy.polar_bar_chart(", + "/charts/wind-rose/": "xy.wind_rose(", "/charts/bar-chart/": "xy.bar(", "/charts/histogram/": "xy.histogram(", "/charts/ecdf/": "xy.ecdf(", @@ -1408,7 +1507,7 @@ def test_chart_gallery_combines_only_the_requested_related_tiles() -> None: titles = {item.title for group in _GALLERY_GROUPS for item in group.items} section_titles = [group.title for group in _GALLERY_GROUPS] - assert len(titles) == 28 + assert len(titles) == 33 assert section_titles[:3] == [ "Line and Area", "Distributions", @@ -1420,6 +1519,23 @@ def test_chart_gallery_combines_only_the_requested_related_tiles() -> None: ("Bar + Column", "/charts/bar-chart/"), ("Scatter", "/charts/scatter/"), ] + polar_group = _GALLERY_GROUPS[5] + assert polar_group.title == "Polar Charts" + assert polar_group.route == "/charts/polar-chart/" + assert [(item.title, item.route) for item in polar_group.items] == [ + ("Polar", None), + ("Radar", "/charts/radar-chart/"), + ("Radial Bar", "/charts/radial-bar-chart/"), + ("Pie & Donut", "/charts/pie-chart/"), + ("Wind Rose", "/charts/wind-rose/"), + ] + specialized_group = _GALLERY_GROUPS[6] + assert specialized_group.title == "Specialized" + assert [(item.title, item.route) for item in specialized_group.items] == [ + ("Stem", "/charts/stem-plot/"), + ("Segments", "/charts/segments/"), + ("Triangle Mesh", "/components/triangle-mesh/"), + ] assert {"Step + Stairs", "Bar + Column"} <= titles assert {"Step", "Stairs", "Bar", "Column"}.isdisjoint(titles) assert { @@ -1428,6 +1544,11 @@ def test_chart_gallery_combines_only_the_requested_related_tiles() -> None: "Hexbin", "Heatmap", "Contour", + "Polar", + "Radar", + "Radial Bar", + "Pie & Donut", + "Wind Rose", "Error Band", "Error Bar", "Facet Chart", @@ -1435,6 +1556,144 @@ def test_chart_gallery_combines_only_the_requested_related_tiles() -> None: } <= titles +def test_polar_guides_track_the_current_coordinate_system_contract() -> None: + """Keep the public guide aligned with the runtime's polar-specific seams.""" + polar = (DOCS_ROOT / "charts" / "polar-chart.md").read_text(encoding="utf-8") + radar = (DOCS_ROOT / "charts" / "radar-chart.md").read_text(encoding="utf-8") + radial_bar = (DOCS_ROOT / "charts" / "radial-bar-chart.md").read_text(encoding="utf-8") + pie = (DOCS_ROOT / "charts" / "pie-chart.md").read_text(encoding="utf-8") + wind_rose = (DOCS_ROOT / "charts" / "wind-rose.md").read_text(encoding="utf-8") + matplotlib = (DOCS_ROOT / "integrations" / "matplotlib.md").read_text(encoding="utf-8") + annotations = (DOCS_ROOT / "components" / "annotations.md").read_text(encoding="utf-8") + limitations = (DOCS_ROOT / "api-reference" / "limitations-and-alpha-status.md").read_text( + encoding="utf-8" + ) + + for fragment in ( + "radial maximum while keeping the radial", + "authored fractional degree", + "splits a line into visible runs", + "`line`, `scatter`, and `area` are limited to", + "Heatmap/contour grids are not rejected", + "`sector=(start, end)`", + '`grid_shape="linear"`', + "`hole` and `origin` are mutually exclusive", + "def polar_field_demo():", + "def polar_uncertainty_demo():", + 'plt.subplots(subplot_kw={"projection": "polar"})', + 'ax.set_thetagrids([0, 90, 180, 270], ["N", "E", "S", "W"])', + "it is not a direct `plt.subplots(polar=True)` argument", + "radial area against `r=0`", + "`set_thetamin()`/`set_thetamax()`", + "`set_rorigin()`/`get_rorigin()`", + "reactivating an existing", + "Explicit `padding=(top, right, bottom, left)`", + ): + assert fragment in polar or fragment in matplotlib + + for fragment in ( + "def radar_demo():", + "def radar_outline_demo():", + "fill=False", + "the outline inherits", + "`line_color` is omitted", + "Supply at least three categories", + "Column-name strings are not resolved", + 'grid_shape="linear"', + ): + assert fragment in radar + + for fragment in ( + "same-length sequence", + "complete annulus", + "def radial_bar_demo():", + "def allocation_overview_demo():", + "def training_summary_demo():", + "def cache_tiers_demo():", + "RADIAL_DATA", + "ALLOCATION_DATA", + "TRAINING_METRICS", + "CACHE_TIERS", + "chart_examples_layout_marker", + "`corner_radius`", + "`stroke_width`", + "linear-gradient(to top", + "`padding=(top, right, bottom, left)`", + "sector=(-120.0, 120.0)", + ): + assert fragment in radial_bar + assert radial_bar.count("~~~python demo exec") == 4 + assert radial_bar.index("## Basic Radial Bar Chart") < radial_bar.index( + "## Allocation Overview" + ) + assert radial_bar.count("xy.modebar(show=False),") == 4 + assert 'xy.legend(loc="right")' in radial_bar + assert '("Direct", 0, 6, "#5b3cc4")' in radial_bar + + for fragment in ( + "def basic_pie_demo():", + "def market_share_demo():", + "def progress_rings_demo():", + "def revenue_mix_demo():", + "def reliability_score_demo():", + "MARKET_SERIES", + "PROGRESS_STATS", + "REVENUE_SERIES", + "RELIABILITY_BANDS", + "xy.polar_bar_chart(", + "background-colored stroke", + "corner_radius=12", + "GAUGE_SPAN = 240.0", + "browser/static exports remain available", + ): + assert fragment in pie + assert pie.count("~~~python demo exec") == 5 + assert pie.index("## Basic Pie Chart") < pie.index("## Market Share") + assert "PIE_DATA = [" in pie + assert 'PURPLE_SHADES = ["#6e56cf"' in pie + assert 'xy.legend(loc="left")' in pie + assert pie.count("xy.modebar(show=False),") == 5 + assert "def donut_demo():" not in radial_bar + + for fragment in ( + "def wind_rose_demo():", + "`sectors=` controls", + "`speed_bins` is omitted", + "Directions are compass bearings in degrees", + ): + assert fragment in wind_rose + + polar_guides = "\n".join((polar, radar, radial_bar, pie, wind_rose)) + assert "projection surface is scaffolded but not wired end to end" not in polar_guides + assert "Downsample very large polar datasets" not in polar_guides + assert "is not yet routed through the polar constructor" not in matplotlib + assert "customization is not yet preserved" not in polar_guides + for stale_fragment in ( + "it does not create or clip the chart", + "`set_thetamin()` and `set_thetamax()` also remain unsupported", + "Polar heatmaps, contours, error bars, histograms", + "General categorical θ axes, partial sectors", + "Partial-sector layouts are not implemented", + "validated log-r semantics", + "Each polar trace is limited to 200,000 points", + ): + assert stale_fragment not in polar_guides + assert stale_fragment not in matplotlib + + annotation_guides = "\n".join((polar, annotations, limitations)) + for fragment in ("`text`", "`label`", "`marker`", "`arrow`", "`callout`"): + assert fragment in annotation_guides + assert "Polar rules and bands remain deferred" in annotation_guides + for stale_fragment in ( + "not yet consistent across the browser", + "Polar point annotations are not yet consistent", + "Polar annotation projection is still renderer-specific", + "Do not rely on point annotations", + "The annotation exception above", + ): + assert stale_fragment not in annotation_guides + + def test_annotations_have_one_canonical_guide_and_a_legacy_redirect() -> None: """Keep annotation guidance consolidated without breaking the old chart URL.""" pages = discover_docs(DOCS_CONFIG) @@ -1517,17 +1776,17 @@ def test_inline_svg_gallery_validator_requires_every_styled_preview(tmp_path: Pa module_path = tmp_path / "route.jsx" preview = 'viewBox=\\"0 0 320 232\\"' module_path.write_text( - preview * 28 + "gallery-preview-surface aspect-[320/232] shadow-large", + preview * 33 + "gallery-preview-surface aspect-[320/232] shadow-large", encoding="utf-8", ) check_html_routes.validate_inline_svg_gallery("/overview/gallery/", module_path) module_path.write_text( - preview * 27 + "gallery-preview-surface aspect-[320/232] shadow-large", + preview * 32 + "gallery-preview-surface aspect-[320/232] shadow-large", encoding="utf-8", ) - with pytest.raises(RuntimeError, match="27 previews, expected 28"): + with pytest.raises(RuntimeError, match="32 previews, expected 33"): check_html_routes.validate_inline_svg_gallery("/overview/gallery/", module_path) @@ -1622,30 +1881,48 @@ def test_xy_sidebar_reuses_memoized_official_navigation_rows() -> None: ] expected_leaf_count = sum( len(leaves) + int(not any(route == landing_route for _title, route in leaves)) - for title, landing_route, _icon, leaves in DOCS_SECTIONS - if title != "Integrations" + for title, landing_route, _icon, leaves in grouped_sections + if title != "Integrations" and leaves ) - accordion_count = len(DOCS_SECTIONS) - 1 + accordion_count = sum( + title != "Integrations" and bool(leaves) + for title, _landing_route, _icon, leaves in grouped_sections + ) + direct_link_count = len(INTEGRATION_LINK_ICONS) + 1 assert rendered.count('jsx("details"') == accordion_count assert rendered.count('jsx("summary"') == accordion_count assert rendered.count("group/details") == accordion_count assert rendered.count("guideMarginClass") == expected_leaf_count - assert rendered.count( - "absolute left-0 top-1/2 -z-10 h-8 w-full -translate-y-1/2 rounded-lg bg-secondary-3" - ) == len(INTEGRATION_LINK_ICONS) - assert rendered.count( - "ml-[2.5rem] flex h-8 w-[calc(100%-2.5rem)] items-center " - "justify-start text-secondary-11 transition-colors " - "group-hover:text-primary-10 dark:group-hover:text-primary-9 " - "xl:max-w-[14rem]" - ) == len(INTEGRATION_LINK_ICONS) - assert rendered.count("group relative block h-8 w-full no-underline") == len( - INTEGRATION_LINK_ICONS - ) - assert sorted(section[0] for section in grouped_sections) == sorted( - section[0] for section in DOCS_SECTIONS + assert ( + rendered.count( + "absolute left-0 top-1/2 -z-10 h-8 w-full -translate-y-1/2 rounded-lg bg-secondary-3" + ) + == direct_link_count ) + assert ( + rendered.count( + "ml-[2.5rem] flex h-8 w-[calc(100%-2.5rem)] items-center " + "justify-start text-secondary-11 transition-colors " + "group-hover:text-primary-10 dark:group-hover:text-primary-9 " + "xl:max-w-[14rem]" + ) + == direct_link_count + ) + assert rendered.count("group relative block h-8 w-full no-underline") == direct_link_count + assert len(grouped_sections) == len(DOCS_SECTIONS) + 5 + sidebar_section_titles = [section[0] for section in grouped_sections] + assert set(sidebar_section_titles) == { + *(section[0] for section in DOCS_SECTIONS if section[0] != "Chart Gallery"), + "Core Charts", + "Distributions", + "Density & Fields", + "Specialized", + "Polar Charts", + } + assert sidebar_section_titles.count("Overview") == 2 + assert "Chart Gallery" not in sidebar_section_titles learning_sections = SIDEBAR_SECTION_GROUPS[0][2] + chart_sections = SIDEBAR_SECTION_GROUPS[1][2] other_sections = SIDEBAR_SECTION_GROUPS[2][2] assert [section[0] for section in learning_sections] == [ "Overview", @@ -1653,8 +1930,53 @@ def test_xy_sidebar_reuses_memoized_official_navigation_rows() -> None: "Styling", "Advanced", ] + assert chart_sections == ( + CHART_GALLERY_SIDEBAR_LINK, + *CHART_FAMILY_SIDEBAR_SECTIONS, + POLAR_SIDEBAR_SECTION, + DOCS_SECTIONS[4], + ) + assert CHART_GALLERY_SIDEBAR_LINK == ( + "Overview", + "/overview/gallery/", + "chart-column", + (), + ) + assert [section[0] for section in CHART_FAMILY_SIDEBAR_SECTIONS] == [ + "Core Charts", + "Distributions", + "Density & Fields", + "Specialized", + ] + expected_non_polar_routes = tuple( + route + for _title, route in DOCS_SECTIONS[3][3] + if route not in {polar_route for _polar_title, polar_route in POLAR_DOCS_ROUTES} + ) + grouped_non_polar_routes = tuple( + route + for _title, _landing_route, _icon, leaves in CHART_FAMILY_SIDEBAR_SECTIONS + for _leaf_title, route in leaves + ) + assert grouped_non_polar_routes == expected_non_polar_routes + assert len(grouped_non_polar_routes) == len(set(grouped_non_polar_routes)) + assert POLAR_DOCS_ROUTES == ( + ("Overview", POLAR_DOCS_ROUTE), + ("Radar", RADAR_DOCS_ROUTE), + ("Radial Bar", RADIAL_BAR_DOCS_ROUTE), + ("Pie & Donut", PIE_DOCS_ROUTE), + ("Wind Rose", WIND_ROSE_DOCS_ROUTE), + ) + assert POLAR_SIDEBAR_SECTION == ( + "Polar Charts", + POLAR_DOCS_ROUTE, + "radar", + POLAR_DOCS_ROUTES, + ) + for _title, route in POLAR_DOCS_ROUTES: + assert f'href:"{route}"' in rendered assert "Advanced" not in {section[0] for section in other_sections} - for group_title in ("Learning", "Examples", "Other"): + for group_title in ("Learning", "Charts", "Other"): assert group_title in rendered for category, route in ( ("Learn", "/"), @@ -1679,10 +2001,42 @@ def test_xy_sidebar_reuses_memoized_official_navigation_rows() -> None: ): assert icon in rendered assert "LucidePlug" not in rendered - assert rendered.count('"aria-current":((') == 3 + assert rendered.count('"aria-current":((') == direct_link_count assert ">XY<" not in rendered +@pytest.mark.parametrize( + ("route", "expected_open_group"), + ( + ("/charts/scatter/", "Core Charts"), + ("/charts/ecdf/", "Distributions"), + ("/charts/contour-plot/", "Density & Fields"), + ("/charts/segments/", "Specialized"), + ("/charts/radial-bar-chart/", "Polar Charts"), + ("/charts/pie-chart/", "Polar Charts"), + ("/overview/gallery/", None), + ), +) +def test_xy_sidebar_opens_only_the_current_chart_family( + route: str, + expected_open_group: str | None, +) -> None: + """Open one exact chart family without expanding unrelated accordions.""" + chart_families = (*CHART_FAMILY_SIDEBAR_SECTIONS, POLAR_SIDEBAR_SECTION) + open_groups = [ + title + for title, landing_route, icon, leaves in chart_families + if "open:true" in str(_section_items(title, landing_route, icon, leaves, route)[0]) + ] + + assert open_groups == ([] if expected_open_group is None else [expected_open_group]) + gallery_link = str(_section_items(*CHART_GALLERY_SIDEBAR_LINK, route)[0]) + assert 'jsx("details"' not in gallery_link + assert ('"aria-current":(true ? "page"' in gallery_link) == ( + route == CHART_GALLERY_SIDEBAR_LINK[1] + ) + + def test_xy_mobile_navbar_uses_the_official_drawer_button() -> None: """Match the official navbar while retaining its mobile drawer trigger.""" component = xy_docs_navbar() @@ -1934,6 +2288,14 @@ def test_chart_gallery_pages_append_factory_api_tables() -> None: "xy.stairs_chart", ), "/charts/scatter/": ("xy.scatter_chart",), + "/charts/polar-chart/": ( + "xy.polar_chart", + "xy.theta_axis", + "xy.r_axis", + ), + "/charts/radar-chart/": ("xy.radar_chart",), + "/charts/radial-bar-chart/": ("xy.polar_bar_chart",), + "/charts/wind-rose/": ("xy.wind_rose",), "/charts/bar-chart/": ("xy.bar_chart", "xy.column_chart"), "/charts/histogram/": ("xy.histogram_chart",), "/charts/ecdf/": ("xy.ecdf_chart",), @@ -2021,6 +2383,32 @@ def test_chart_factory_api_expands_forwarded_chart_props() -> None: assert "| `link_axes` |" in markdown +def test_polar_axis_api_expands_forwarded_axis_props() -> None: + """Show the underlying x/y options instead of an opaque ``**kwargs`` row.""" + theta_reference, radial_reference = component_api_references(("xy.theta_axis", "xy.r_axis")) + theta_names = tuple(parameter.name for parameter in theta_reference.parameters) + radial_names = tuple(parameter.name for parameter in radial_reference.parameters) + refused = frozenset(_POLAR_INERT_AXIS_KEYWORDS) + x_axis_names = tuple( + name for name in inspect.signature(xy.x_axis).parameters if name not in refused + ) + y_axis_names = tuple( + name for name in inspect.signature(xy.y_axis).parameters if name not in refused + ) + + assert theta_names[:5] == ("unit", "zero", "direction", "sector", "grid_shape") + assert theta_names[5:] == x_axis_names + assert radial_names[:2] == ("hole", "origin") + assert radial_names[2:] == y_axis_names + assert "**kwargs" not in {*theta_names, *radial_names} + # The polar axes refuse these outright, so the table must not offer them. + assert not refused & {*theta_names, *radial_names} + assert theta_reference.parameters[theta_names.index("tick_values")].description + assert theta_reference.parameters[theta_names.index("sector")].description + assert radial_reference.parameters[radial_names.index("domain")].description + assert radial_reference.parameters[radial_names.index("hole")].description + + def test_other_api_owned_pages_append_focused_tables() -> None: """Document styling factories and the public Reflex adapter surface in place.""" expected = { diff --git a/docs/app/xy_docs/api_reference.py b/docs/app/xy_docs/api_reference.py index 079366e4..ddf30bb4 100644 --- a/docs/app/xy_docs/api_reference.py +++ b/docs/app/xy_docs/api_reference.py @@ -18,6 +18,7 @@ ) import xy +from xy.components import _POLAR_INERT_AXIS_KEYWORDS COMPONENT_API_METADATA_KEY = "components" API_REFERENCE_HEADING = "API Reference" @@ -34,6 +35,20 @@ (xy.line_chart, xy.area_chart, xy.step_chart, xy.stairs_chart), ), ("Scatter", (xy.scatter_chart,)), + # The polar compositions are public factories like any other, so they belong + # in the generated inventory. `pie_chart` is listed with them: without an + # entry here the API reference implied the only way to draw a pie was to + # hand-roll wedge bars — the exact thing the factory exists to replace. + ( + "Polar", + ( + xy.polar_chart, + xy.radar_chart, + xy.polar_bar_chart, + xy.pie_chart, + xy.wind_rose, + ), + ), ("Bar and Column", (xy.bar_chart, xy.column_chart)), ( "Distributions", @@ -59,6 +74,15 @@ _CHART_FACTORY_COMPONENTS = frozenset( factory for _group_name, factories in CHART_FACTORY_GROUPS for factory in factories ) +_FORWARDED_AXIS_COMPONENTS = { + xy.theta_axis: xy.x_axis, + xy.r_axis: xy.y_axis, +} +# `theta_axis`/`r_axis` forward `**props` to the Cartesian axis vocabulary, but +# they refuse the keywords no polar renderer implements. Documenting the +# forwarded signature verbatim would re-advertise exactly those options — the +# trap the refusal exists to close — so they are dropped from the table. +_REFUSED_POLAR_AXIS_PARAMETERS = frozenset(_POLAR_INERT_AXIS_KEYWORDS) MARKS = ( xy.line, @@ -85,6 +109,8 @@ AXES_AND_ANNOTATIONS = ( xy.x_axis, xy.y_axis, + xy.theta_axis, + xy.r_axis, xy.vline, xy.hline, xy.x_band, @@ -206,8 +232,34 @@ def _documented_parameters( component: Callable[..., Any], descriptions: Mapping[str, str], ) -> tuple[tuple[inspect.Parameter, Mapping[str, str]], ...]: - """Expand chart-factory ``**props`` into the shared Chart constructor API.""" + """Expand forwarded chart and polar-axis keyword arguments.""" parameters = tuple(inspect.signature(component).parameters.values()) + if forwarded_axis := _FORWARDED_AXIS_COMPONENTS.get(component): + forwarded_descriptions = _parameter_descriptions(inspect.getdoc(forwarded_axis) or "") + merged_descriptions = {**forwarded_descriptions, **descriptions} + existing_names = { + parameter.name + for parameter in parameters + if parameter.kind is not inspect.Parameter.VAR_KEYWORD + } + forwarded_parameters = tuple( + parameter + for parameter in inspect.signature(forwarded_axis).parameters.values() + if parameter.name not in existing_names + and parameter.name not in _REFUSED_POLAR_AXIS_PARAMETERS + and parameter.kind is not inspect.Parameter.VAR_KEYWORD + ) + documented: list[tuple[inspect.Parameter, Mapping[str, str]]] = [] + for parameter in parameters: + if parameter.kind is inspect.Parameter.VAR_KEYWORD: + documented.extend( + (forwarded_parameter, merged_descriptions) + for forwarded_parameter in forwarded_parameters + ) + continue + documented.append((parameter, merged_descriptions)) + return tuple(documented) + if component not in _CHART_FACTORY_COMPONENTS: return tuple((parameter, descriptions) for parameter in parameters) diff --git a/docs/app/xy_docs/config.py b/docs/app/xy_docs/config.py index 041d0429..1c06bf15 100644 --- a/docs/app/xy_docs/config.py +++ b/docs/app/xy_docs/config.py @@ -59,6 +59,11 @@ ("Line", "/charts/line-chart/"), ("Area, Step & Stairs", "/charts/area-chart/"), ("Scatter", "/charts/scatter/"), + ("Polar", "/charts/polar-chart/"), + ("Radar", "/charts/radar-chart/"), + ("Radial Bar", "/charts/radial-bar-chart/"), + ("Pie & Donut", "/charts/pie-chart/"), + ("Wind Rose", "/charts/wind-rose/"), ("Bar and Column", "/charts/bar-chart/"), ("Histogram", "/charts/histogram/"), ("Box Plot", "/charts/box-plot/"), diff --git a/docs/app/xy_docs/gallery.py b/docs/app/xy_docs/gallery.py index 7386346f..4ac9fb20 100644 --- a/docs/app/xy_docs/gallery.py +++ b/docs/app/xy_docs/gallery.py @@ -159,6 +159,17 @@ class GalleryGroup: GalleryItem("Error Bar"), ), ), + GalleryGroup( + "Polar Charts", + "/charts/polar-chart/", + ( + GalleryItem("Polar"), + GalleryItem("Radar", route="/charts/radar-chart/"), + GalleryItem("Radial Bar", route="/charts/radial-bar-chart/"), + GalleryItem("Pie & Donut", route="/charts/pie-chart/"), + GalleryItem("Wind Rose", route="/charts/wind-rose/"), + ), + ), GalleryGroup( "Specialized", None, @@ -208,6 +219,21 @@ class GalleryGroup: """, "Scatter": """ +""", + "Polar": """ + +""", + "Radar": """ + +""", + "Radial Bar": """ + +""", + "Pie & Donut": """ + +""", + "Wind Rose": """ + """, "Bar + Column": """ diff --git a/docs/app/xy_docs/sidebar.py b/docs/app/xy_docs/sidebar.py index 2a06c766..201fa6a6 100644 --- a/docs/app/xy_docs/sidebar.py +++ b/docs/app/xy_docs/sidebar.py @@ -12,9 +12,79 @@ from xy_docs.config import DOCS_SECTIONS +POLAR_DOCS_ROUTE = "/charts/polar-chart/" +RADAR_DOCS_ROUTE = "/charts/radar-chart/" +RADIAL_BAR_DOCS_ROUTE = "/charts/radial-bar-chart/" +PIE_DOCS_ROUTE = "/charts/pie-chart/" +WIND_ROSE_DOCS_ROUTE = "/charts/wind-rose/" +POLAR_DOCS_ROUTES = ( + ("Overview", POLAR_DOCS_ROUTE), + ("Radar", RADAR_DOCS_ROUTE), + ("Radial Bar", RADIAL_BAR_DOCS_ROUTE), + ("Pie & Donut", PIE_DOCS_ROUTE), + ("Wind Rose", WIND_ROSE_DOCS_ROUTE), +) +_chart_gallery = DOCS_SECTIONS[3] +_chart_gallery_routes = dict(_chart_gallery[3]) +CHART_GALLERY_SIDEBAR_LINK = ( + "Overview", + _chart_gallery[1], + _chart_gallery[2], + (), +) +CHART_FAMILY_SIDEBAR_SECTIONS = ( + ( + "Core Charts", + _chart_gallery_routes["Line"], + "chart-line", + tuple( + (title, _chart_gallery_routes[title]) + for title in ("Line", "Area, Step & Stairs", "Scatter", "Bar and Column") + ), + ), + ( + "Distributions", + _chart_gallery_routes["Histogram"], + "chart-bar-stacked", + tuple( + (title, _chart_gallery_routes[title]) + for title in ("Histogram", "Box Plot", "Violin Plot", "ECDF") + ), + ), + ( + "Density & Fields", + _chart_gallery_routes["Heatmap"], + "grid-3x3", + tuple((title, _chart_gallery_routes[title]) for title in ("Heatmap", "Hexbin", "Contour")), + ), + ( + "Specialized", + _chart_gallery_routes["Uncertainty"], + "shapes", + tuple( + (title, _chart_gallery_routes[title]) for title in ("Uncertainty", "Stem", "Segments") + ), + ), +) +POLAR_SIDEBAR_SECTION = ( + "Polar Charts", + POLAR_DOCS_ROUTE, + "radar", + POLAR_DOCS_ROUTES, +) + SIDEBAR_SECTION_GROUPS = ( ("Learning", "/", (*DOCS_SECTIONS[:3], DOCS_SECTIONS[7])), - ("Examples", "/overview/gallery/", DOCS_SECTIONS[3:5]), + ( + "Charts", + "/overview/gallery/", + ( + CHART_GALLERY_SIDEBAR_LINK, + *CHART_FAMILY_SIDEBAR_SECTIONS, + POLAR_SIDEBAR_SECTION, + DOCS_SECTIONS[4], + ), + ), ("Other", "/integrations/", (*DOCS_SECTIONS[5:7], *DOCS_SECTIONS[8:])), ) @@ -114,6 +184,8 @@ def _section_items( url: rx.vars.StringVar[str], ) -> tuple[rx.Component, ...]: """Render one sidebar section as a group or a set of direct links.""" + if not leaves: + return (_top_level_link(title, landing_route, icon, url),) section_leaves = _section_leaves(landing_route, leaves) if title == "Integrations": return tuple( @@ -132,11 +204,15 @@ def _section_items( *(_leaf(leaf_title, leaf_route, url) for leaf_title, leaf_route in section_leaves), icon=icon, open_=( - (url == "/") | url.startswith("/overview/") + (url == "/") + | (url.startswith("/overview/") & (url != CHART_GALLERY_SIDEBAR_LINK[1])) if landing_route == "/" else ( - (url == landing_route) | url.startswith("/charts/") - if title == "Chart Gallery" + _matches_route( + url, + tuple(route for _leaf_title, route in section_leaves), + ) + if landing_route.startswith("/charts/") else url.startswith(landing_route) ) ), @@ -144,6 +220,17 @@ def _section_items( ) +def _matches_route( + url: rx.vars.StringVar[str], + routes: tuple[str, ...], +) -> rx.Var[bool]: + """Return a reactive condition matching one of the exact routes.""" + matched = url == routes[0] + for route in routes[1:]: + matched = matched | (url == route) + return matched + + @rx.memo def xy_docs_sidebar_comp(url: rx.vars.StringVar[str]) -> rx.Component: """Render the memoized XY sidebar tree. @@ -224,8 +311,17 @@ def xy_docs_sidebar(route: str) -> rx.Component: __all__ = [ + "CHART_FAMILY_SIDEBAR_SECTIONS", + "CHART_GALLERY_SIDEBAR_LINK", "INTEGRATION_LINK_ICONS", + "PIE_DOCS_ROUTE", + "POLAR_DOCS_ROUTE", + "POLAR_DOCS_ROUTES", + "POLAR_SIDEBAR_SECTION", + "RADAR_DOCS_ROUTE", + "RADIAL_BAR_DOCS_ROUTE", "SIDEBAR_SECTION_GROUPS", + "WIND_ROSE_DOCS_ROUTE", "xy_docs_sidebar", "xy_docs_sidebar_comp", ] diff --git a/docs/charts/contour-plot.md b/docs/charts/contour-plot.md index f7613729..24814964 100644 --- a/docs/charts/contour-plot.md +++ b/docs/charts/contour-plot.md @@ -156,6 +156,8 @@ table. ## Related Charts +- [Polar charts](/docs/xy/charts/polar-chart/#map-a-polar-field) — project a + regular theta/r field and its contour isolines through a polar axis. - [Heatmaps](/docs/xy/charts/heatmap/) — color every grid cell instead of drawing bands. - [Hexbin plots](/docs/xy/charts/hexbin/) — build a density field from scattered diff --git a/docs/charts/heatmap.md b/docs/charts/heatmap.md index 70da8f61..b39d0555 100644 --- a/docs/charts/heatmap.md +++ b/docs/charts/heatmap.md @@ -141,6 +141,9 @@ table. ## Related Charts +- [Polar charts](/docs/xy/charts/polar-chart/#map-a-polar-field) — place a + regular theta/r heatmap on rings and spokes, optionally with contour + isolines. - [Hexbin plots](/docs/xy/charts/hexbin/) — bin scattered points into a colored hexagonal grid. - [Contour plots](/docs/xy/charts/contour-plot/) — draw iso-value lines over a diff --git a/docs/charts/pie-chart.md b/docs/charts/pie-chart.md new file mode 100644 index 00000000..b0b50011 --- /dev/null +++ b/docs/charts/pie-chart.md @@ -0,0 +1,710 @@ +--- +title: Pie and Donut Charts in Python +description: Build polished pie, donut, progress-ring, and gauge blocks in Python with xy and Reflex. +--- + +# Pie and Donut Charts in Python + +A pie chart maps each share to an angular span. A donut uses the same sectors +with a positive inner radius, leaving room for a total, status, or supporting +label. In XY, both are compositions of unequal-width bars inside +`polar_bar_chart()`. + +The first example keeps the chart intentionally small. The examples after it +combine XY's exportable sector geometry with ordinary Reflex layout for center +labels, legends, captions, and summary rows. That separation keeps the data +visualization reusable while the surrounding block remains easy to adapt to a +dashboard. + +~~~python exec +from xy_docs.examples import chart_examples_layout_marker +~~~ + +~~~python eval +chart_examples_layout_marker() +~~~ + +## Basic Pie Chart + +A filled pie starts with simple label-and-value data. The chart converts each +value to an angular width, and `base=0` makes every sector reach the center: + +~~~python demo exec +import reflex_xy +import xy + +PIE_DATA = [ + ("Direct", 40), + ("Partner", 30), + ("Organic", 20), + ("Other", 10), +] +PURPLE_SHADES = ["#6e56cf", "#806bd5", "#9888dd", "#b5aae8"] + +total = sum(value for _label, value in PIE_DATA) +widths = [value / total * 360 for _label, value in PIE_DATA] +angles = [ + sum(widths[:index]) + width / 2 + for index, width in enumerate(widths) +] + +pie = xy.polar_bar_chart( + *( + xy.bar( + [angle], + [1], + base=0, + width=width, + color=color, + name=f"{label} · {value / total:.0%}", + opacity=1, + ) + for (label, value), angle, width, color in zip( + PIE_DATA, + angles, + widths, + PURPLE_SHADES, + strict=True, + ) + ), + xy.theta_axis(unit="degrees", zero="N", show=False, tick_label_strategy="none"), + xy.r_axis(show=False, tick_label_strategy="none"), + xy.legend(loc="left"), + xy.modebar(show=False), +) + + +def basic_pie_demo(): + return reflex_xy.chart(pie, height="320px") +~~~ + +`PIE_DATA` stays independent of the chart geometry, so changing a value updates +both its slice and legend percentage. The separate palette uses the same +`#6e56cf` primary purple used throughout these docs. + +## Market Share + +Use a high-contrast donut when the whole is meaningful and every slice needs a +compact percentage label. The two-column legend preserves the names and raw +values without crowding the ring: + +~~~python demo exec +import reflex as rx +import reflex_xy +import xy + +MARKET_SERIES = [ + ("Skyline", 27, "#0a0a0a"), + ("Datawell", 21, "#262626"), + ("Cloudpeak", 13, "#3d3d3d"), + ("Taskbridge", 21, "#545454"), + ("Insightloop", 6, "#6b6b6b"), + ("Streamforge", 12, "#7d7d7d"), +] + + +def sector_layout(values): + spans = [value / sum(values) * 360.0 for value in values] + centers, cursor = [], 0.0 + for span in spans: + centers.append(cursor + span / 2.0) + cursor += span + return centers, spans + + +market_angles, market_widths = sector_layout( + [value for _label, value, _color in reversed(MARKET_SERIES)], +) + +market_share = xy.polar_bar_chart( + *( + xy.bar( + [angle], + [0.42], + base=0.52, + width=width, + name=label, + color=color, + opacity=1, + stroke="#ffffff", + stroke_width=3, + ) + for (label, _value, color), angle, width in zip( + reversed(MARKET_SERIES), + market_angles, + market_widths, + strict=True, + ) + ), + *( + xy.text( + angle, + 0.73, + f"{value}%", + dx=0, + dy=4, + anchor="middle", + color="#ffffff", + class_name="text-xs font-medium", + ) + for (_label, value, _color), angle in zip( + reversed(MARKET_SERIES), + market_angles, + strict=True, + ) + ), + xy.theta_axis( + unit="degrees", + zero="N", + direction="counterclockwise", + show=False, + tick_label_strategy="none", + ), + xy.r_axis( + domain=(0.0, 1.0), + show=False, + tick_label_strategy="none", + ), + xy.legend(show=False), + xy.modebar(show=False), + xy.theme(plot_background="#ffffff", text_color="#171717"), + width="100%", + height=243, + padding=(0, 0, 0, 0), +) + + +def market_legend_item(label, value, color): + return rx.el.button( + rx.el.span( + class_name="size-3 shrink-0 rounded-[3px]", + style={"background": color}, + ), + rx.el.span(label, class_name="font-medium text-zinc-950"), + rx.el.span(f"${value}B", class_name="text-zinc-500"), + rx.el.span(f"({value}%)", class_name="text-zinc-400"), + type="button", + aria_label=f"{label} ${value}B ({value}%)", + class_name=( + "flex cursor-default items-center gap-2 border-0 bg-transparent " + "p-0 text-left text-xs" + ), + ) + + +def market_share_demo(): + return rx.el.div( + rx.el.div( + reflex_xy.chart(market_share, height="243px"), + rx.el.div( + rx.el.span( + "$100B", + class_name="text-3xl font-semibold tracking-tight text-zinc-950", + ), + rx.el.span( + "Ecosystem value", + class_name="text-xs text-zinc-500", + ), + class_name=( + "pointer-events-none absolute inset-0 flex flex-col " + "items-center justify-center gap-0.5" + ), + ), + class_name="relative min-h-0 w-full flex-1", + ), + rx.el.div( + *( + market_legend_item(label, value, color) + for label, value, color in MARKET_SERIES + ), + class_name=( + "mt-3 grid grid-flow-col grid-cols-2 grid-rows-3 " + "gap-x-6 gap-y-1.5 border-t border-zinc-200 pt-3" + ), + ), + class_name=( + "mx-auto flex h-[22.5rem] w-full max-w-[630px] flex-col bg-white p-4" + ), + ) +~~~ + +The slot width remains proportional to the value. A three-pixel +background-colored stroke creates a constant screen-space separator; unlike an +angular gap, it stays visually even at the inner and outer rim. Text annotations +use `(theta, radius)` coordinates, so their anchors remain attached to the +slices in browser and static output. + +## Progress Rings + +A thin annulus can behave like a circular progress bar. Splitting it into 40 +rounded dashes gives the display a lighter rhythm than one continuous arc: + +~~~python demo exec +import reflex as rx +import reflex_xy +import xy + +PROGRESS_STATS = [ + (48, "Additional support requests from users."), + (67, "Inaccurate forecasts disrupt planning."), +] + + +def progress_ring(value): + dots = 40 + filled = round(dots * value / 100) + active = [228 / 255, 56 / 255, 97 / 255] + track = [212 / 255, 212 / 255, 212 / 255] + return xy.polar_bar_chart( + xy.bar( + [index * 9.0 for index in range(dots)], + [0.07] * dots, + base=0.85, + width=4.5, + color=[ + active if index < filled else track + for index in range(dots) + ], + opacity=1, + corner_radius=6, + ), + xy.theta_axis( + unit="degrees", + zero="N", + direction="counterclockwise", + show=False, + tick_label_strategy="none", + ), + xy.r_axis( + domain=(0.0, 1.0), + show=False, + tick_label_strategy="none", + ), + xy.legend(show=False), + xy.modebar(show=False), + xy.theme(plot_background="#ffffff"), + width="100%", + height=265, + padding=(0, 0, 0, 0), + ) + + +def progress_panel(value, caption): + return rx.el.div( + reflex_xy.chart(progress_ring(value), height="265px"), + rx.el.div( + rx.el.span( + f"{value}%", + class_name="text-4xl leading-none font-medium tracking-tight text-zinc-950", + ), + rx.el.span( + caption, + class_name="text-balance text-xs leading-snug text-zinc-500", + ), + class_name=( + "pointer-events-none absolute inset-0 flex flex-col items-center " + "justify-center gap-1 bg-[radial-gradient(circle_closest-side," + "rgba(0,0,0,0.04)_0_79%,transparent_79%)] px-[25%] text-center" + ), + ), + class_name="relative min-h-0", + ) + + +def progress_rings_demo(): + return rx.el.div( + rx.el.div( + rx.el.div( + rx.el.span( + "User research", + class_name="text-[10px] uppercase tracking-wide text-zinc-500", + ), + rx.el.span( + "Where the workday leaks", + class_name="text-xl leading-tight font-medium tracking-tight text-zinc-950", + ), + class_name="flex flex-col gap-0.5", + ), + rx.el.span( + "1,240 responses", + class_name="shrink-0 text-xs text-zinc-500", + ), + class_name="flex items-start justify-between gap-4", + ), + rx.el.div( + *( + progress_panel(value, caption) + for value, caption in PROGRESS_STATS + ), + class_name="mt-3 grid min-h-0 flex-1 grid-cols-2 gap-4", + ), + class_name=( + "mx-auto flex h-[22.5rem] w-full max-w-[630px] flex-col bg-white p-4" + ), + ) +~~~ + +Each visible dash occupies half of a nine-degree slot. The remaining half is +the gap, so the track stays evenly spaced without creating transparent data +rows. + +## Revenue Mix + +For a smaller category set, pair a rounded donut with a value list. The center +answers the primary question while the aligned legend supports exact lookup: + +~~~python demo exec +import reflex as rx +import reflex_xy +import xy + +REVENUE_SERIES = [ + ("Direct", 52_400, "#7c3aed", "#a855f7"), + ("Marketplace", 38_900, "#4f46e5", "#6366f1"), + ("Wholesale", 24_150, "#0284c7", "#0ea5e9"), + ("Affiliate", 16_300, "#059669", "#10b981"), +] + + +def sector_layout(values): + spans = [value / sum(values) * 360.0 for value in values] + centers, cursor = [], 0.0 + for span in spans: + centers.append(cursor + span / 2.0) + cursor += span + return centers, spans + + +revenue_angles, revenue_widths = sector_layout( + [value for _label, value, _start, _end in REVENUE_SERIES], +) + +revenue_mix = xy.polar_bar_chart( + *( + xy.bar( + [angle], + [0.30], + base=0.62, + width=width, + name=label, + fill={ + "gradient": f"linear-gradient(to right, {start}, {end})", + "space": "plot", + }, + opacity=1, + corner_radius=12, + stroke="#ffffff", + stroke_width=6, + ) + for (label, _value, start, end), angle, width in zip( + REVENUE_SERIES, + revenue_angles, + revenue_widths, + strict=True, + ) + ), + xy.theta_axis( + unit="degrees", + zero="N", + direction="counterclockwise", + show=False, + tick_label_strategy="none", + ), + xy.r_axis( + domain=(0.0, 1.0), + show=False, + tick_label_strategy="none", + ), + xy.legend(show=False), + xy.modebar(show=False), + xy.theme(plot_background="#ffffff"), + width="100%", + height=280, + padding=(0, 0, 0, 0), +) + + +def revenue_row(label, value, color): + return rx.el.div( + rx.el.span( + class_name="size-2.5 shrink-0 rounded-[3px]", + style={"background": color}, + ), + rx.el.span(label, class_name="truncate text-xs text-zinc-500"), + rx.el.span( + f"${value:,}", + class_name="ml-auto text-xs font-semibold text-zinc-950", + ), + class_name="flex items-center gap-2 py-2", + ) + + +def revenue_mix_demo(): + return rx.el.div( + rx.el.div( + reflex_xy.chart(revenue_mix, height="100%"), + rx.el.div( + rx.el.div( + rx.el.span( + "1,284", + class_name="text-2xl leading-none font-semibold tracking-tight text-zinc-950", + ), + rx.el.span( + "Total orders", + class_name="mt-1 text-xs text-zinc-500", + ), + class_name=( + "flex aspect-square w-[56%] flex-col items-center " + "justify-center rounded-full border border-dashed border-zinc-200" + ), + ), + class_name=( + "pointer-events-none absolute inset-0 flex items-center justify-center" + ), + ), + class_name="relative aspect-square w-[40%] max-w-72 shrink-0", + ), + rx.el.div( + *( + revenue_row(label, value, start) + for label, value, start, _end in REVENUE_SERIES + ), + class_name="flex min-h-0 min-w-0 flex-1 flex-col justify-center", + ), + class_name=( + "mx-auto flex h-[22.5rem] w-full max-w-[630px] " + "items-center gap-6 bg-white p-4" + ), + ) +~~~ + +Rounded corners are available when `base` is positive. A CSS linear gradient +paints each one-slice mark independently, so every segment can keep its own +two-color ramp. + +## Reliability Score + +A partial donut becomes a gauge when its angular span represents an ordered +scale. Keep the qualitative bands in the ring and repeat them as a linear key +for exact threshold lookup: + +~~~python demo exec +import reflex as rx +import reflex_xy +import xy + +RELIABILITY_BANDS = [ + ("At risk", 450, "#e11d48"), + ("Fair", 200, "#f59e0b"), + ("Good", 170, "#84cc16"), + ("Excellent", 180, "#059669"), +] +SCORE = 842 +GAUGE_SPAN = 240.0 +GAUGE_START = -120.0 + + +def gauge_layout(values): + spans = [value / sum(values) * GAUGE_SPAN for value in values] + centers, cursor = [], GAUGE_START + for span in spans: + centers.append(cursor + span / 2.0) + cursor += span + return centers, spans + + +gauge_angles, gauge_widths = gauge_layout( + [value for _label, value, _color in reversed(RELIABILITY_BANDS)], +) + +reliability_gauge = xy.polar_bar_chart( + *( + xy.bar( + [angle], + [0.20], + base=0.74, + width=width, + name=label, + color=color, + opacity=1, + corner_radius=10, + stroke="#ffffff", + stroke_width=6, + ) + for (label, _value, color), angle, width in zip( + reversed(RELIABILITY_BANDS), + gauge_angles, + gauge_widths, + strict=True, + ) + ), + xy.theta_axis( + unit="degrees", + zero="N", + direction="counterclockwise", + show=False, + tick_label_strategy="none", + ), + xy.r_axis( + domain=(0.0, 1.0), + show=False, + tick_label_strategy="none", + ), + xy.legend(show=False), + xy.modebar(show=False), + xy.theme(plot_background="#ffffff"), + width="100%", + height=200, + padding=(0, 0, 0, 0), +) + + +def reliability_scale(): + starts = [0, 450, 650, 820] + return rx.el.div( + rx.el.div( + *( + rx.el.span( + str(start), + class_name="text-left", + style={"flex_grow": value, "flex_basis": 0}, + ) + for start, (_label, value, _color) in zip( + starts, + RELIABILITY_BANDS, + strict=True, + ) + ), + rx.el.span("1000"), + class_name="flex text-[10px] text-zinc-500", + ), + rx.el.div( + *( + rx.el.span( + class_name="h-1.5 rounded-full", + style={ + "background": color, + "flex_grow": value, + "flex_basis": 0, + }, + ) + for _label, value, color in RELIABILITY_BANDS + ), + class_name="mt-1 flex gap-1", + ), + class_name="mt-auto shrink-0 pt-2", + ) + + +def reliability_score_demo(): + return rx.el.div( + rx.el.span( + "Delivery Reliability", + class_name="text-lg font-medium tracking-tight text-zinc-950", + ), + rx.el.div( + reflex_xy.chart(reliability_gauge, height="200px"), + rx.html( + """ + + """, + class_name="pointer-events-none absolute inset-0 opacity-50", + ), + rx.el.div( + rx.el.span( + str(SCORE), + class_name="text-4xl font-semibold tracking-tight text-zinc-950", + ), + class_name=( + "pointer-events-none absolute inset-0 flex items-center justify-center" + ), + ), + class_name="relative mx-auto mt-1 aspect-square w-full max-w-50 shrink-0", + ), + rx.el.div( + rx.el.p( + "Reliability is excellent", + class_name="text-sm font-medium text-zinc-950", + ), + rx.el.p( + "Updated 12 Mar 2026", + class_name="text-xs text-zinc-500", + ), + class_name="text-center", + ), + reliability_scale(), + class_name=( + "mx-auto flex h-[22.5rem] w-full max-w-[630px] flex-col bg-white p-4" + ), + ) +~~~ + +This example keeps a full circular layout and authors only 240 degrees of +colored bars, matching a conventional dashboard gauge. Use +`theta_axis(sector=(start, end))` instead when the visible arc should expand to +fill the plot box. + +## Build Your Own Pie Block + +1. Normalize values to a full turn (360 degrees or `2π` radians). +2. Use the cumulative midpoint of each slot as the bar angle. +3. Add a background-colored stroke for a constant-pixel separator, or subtract + a small angle from each slot when the gap should scale with the ring. +4. Give every slice the same `base` and height. +5. Hide the axes and compose labels or legends around the chart with Reflex. + +Set `base=0` for a filled pie. For donuts, a positive base enables rounded +sector corners. Per-slice gradients are easiest to express as one single-slice +bar mark per category. + +## Interaction and Export + +Pie blocks use the shared polar renderer. These examples set +`xy.modebar(show=False)` to keep the presentation clean. The underlying +interactions and APIs remain intact: sector hover, radial wheel zoom, +double-click reset, and browser/static exports remain available. A hovered slice +reads its own label and value; the layout angle and the constant rim radius stay +out of the readout. +Center labels and legends composed in Reflex are browser UI; annotations placed +with `xy.text()` are part of the chart and are preserved in SVG and native +raster exports. + +See [Radial bar charts](/docs/xy/charts/radial-bar-chart/) for width, base, +corner-radius, clipping, and partial-sector details. + +## Related Polar Charts + +- [Polar overview](/docs/xy/charts/polar-chart/) — shared axes, interaction, + annotations, pyplot, and renderer limits. +- [Radial bar charts](/docs/xy/charts/radial-bar-chart/) — equal-angle and + variable-height annular sectors. +- [Radar charts](/docs/xy/charts/radar-chart/) — categorical profile + comparisons. +- [Wind rose charts](/docs/xy/charts/wind-rose/) — direction and speed + distributions. + +## FAQ + +### Does XY have a dedicated pie mark? + +There is no pie *mark*, but there is a pie *chart*: `xy.pie_chart(labels, +values)` builds one slice per label, sized by value. It is a composition rather +than a new geometry — each slice is an `xy.bar()` wedge inside +`xy.polar_bar_chart()` — which keeps sector styling and export behavior on the +same renderer as radial bars. Compose the wedges yourself, as the examples above +do, when you want control the factory does not expose. + +### How do I add space between slices? + +For an even screen-space separator, keep the proportional `width` and add a +background-colored `stroke` with `stroke_width=`. For an angular gap, pass a +slightly smaller per-item `width` while keeping each slot midpoint unchanged. + +### How do I round donut slices? + +Pass `corner_radius=` on bars whose `base` is positive. A filled pie reaches +the center and therefore keeps a sharp center vertex. diff --git a/docs/charts/polar-chart.md b/docs/charts/polar-chart.md new file mode 100644 index 00000000..cc738057 --- /dev/null +++ b/docs/charts/polar-chart.md @@ -0,0 +1,418 @@ +--- +title: Polar Charts in Python +description: Create polar lines, fields, sectors, and uncertainty charts in Python with xy. Configure partial sectors, categorical angles, holes, log radii, and pyplot polar projections. +components: + - xy.polar_chart + - xy.theta_axis + - xy.r_axis +--- + +# Polar Charts in Python + +A **polar chart** places each observation by an angle (theta, or θ) and a +distance from the center (radius, or r). It is a natural fit for cyclic +measurements, directional observations, antenna patterns, radar comparisons, +and wind distributions. + +XY uses the same composition model as its Cartesian charts. Put `line`, +`scatter`, `area`, `bar`, `column`, `heatmap`, `contour`, or `errorbar` marks +inside `polar_chart()`. Focused helpers build +[radar](/docs/xy/charts/radar-chart/), +[radial bar](/docs/xy/charts/radial-bar-chart/), and +[wind rose](/docs/xy/charts/wind-rose/) charts on the same coordinate system. + +## Create a Polar Line Chart + +The first mark channel becomes θ and the second becomes r. Angles use radians +by default: + +~~~python demo exec +import numpy as np +import reflex_xy +import xy + +theta = np.linspace(0.0, 2.0 * np.pi, 361) +radius = 1.0 + 0.28 * np.cos(5.0 * theta) + 0.12 * np.sin(2.0 * theta) + +polar_line = xy.polar_chart( + xy.line(theta, radius, color="#6e56cf", width=2.5), + xy.scatter( + theta[::18], + radius[::18], + color="#2563eb", + size=5, + stroke="#ffffff", + stroke_width=1, + ), + xy.theta_axis(unit="radians"), + xy.r_axis(label="relative magnitude", domain=(0.0, 1.5)), + title="Five-lobe response", +) + + +def polar_line_demo(): + return reflex_xy.chart(polar_line, height="420px") +~~~ + +Without an authored sector, `polar_chart()` draws a full circular frame. Its +angular axis defaults to `0..2π`, and a linear radial axis starts at zero and +ends at the largest radius. Pass `domain=` to `r_axis()` when the radial view +must stay fixed. + +## Map a Polar Field + +Heatmap cells follow rings and spokes rather than stretching a Cartesian image +into a circle. Contours share that projection, so they can be layered over the +field with one colorbar: + +~~~python demo exec +import math + +import numpy as np +import reflex_xy +import xy + +field_theta = np.linspace(0.0, 360.0, 24, endpoint=False) +field_radius = np.geomspace(1.0, 100.0, 8) +field = np.array( + [ + [ + math.sin(3.0 * math.radians(angle)) + math.cos(1.7 * math.log(radius)) + for angle in field_theta + ] + for radius in field_radius + ] +) + +polar_field = xy.polar_chart( + xy.heatmap( + field, + x=field_theta, + y=field_radius, + colormap="viridis", + name="surface", + ), + xy.contour( + field, + x=field_theta, + y=field_radius, + levels=6, + color="#ffffff", + width=1.4, + name="isolines", + ), + xy.colorbar(title="surface"), + xy.theta_axis(unit="degrees"), + xy.r_axis(type_="log", domain=(1.0, 100.0)), + title="Polar field with isolines", +) + + +def polar_field_demo(): + return reflex_xy.chart(polar_field, height="440px") +~~~ + +The polar heatmap path inverse-samples the source grid at the requested +browser or export resolution. Pixels inside a hole or outside an authored +sector stay transparent. Contours remain projected vector geometry. + +## Choose a Polar Chart Type + +The overview owns numeric theta/r data and shared coordinate-system behavior. +Use a focused page when the data already matches one of these higher-level +compositions. + +### Compare Categories with a Radar Chart + +[Radar charts](/docs/xy/charts/radar-chart/) space named dimensions evenly +around the frame and close filled or outlined profiles automatically. + +### Draw Radial Bars + +[Radial bar charts](/docs/xy/charts/radial-bar-chart/) turn bars into annular +sectors with scalar or per-item angular widths and configurable inner radii. + +### Compose a Pie or Donut + +The [pie and donut guide](/docs/xy/charts/pie-chart/) turns unequal-width +sectors and `base=` into polished share, progress-ring, revenue-mix, and gauge +blocks. + +### Summarize Wind with a Wind Rose + +[Wind rose charts](/docs/xy/charts/wind-rose/) bin raw compass bearings into +directional sectors and stack their counts by speed band. + +## Configure the Angular Axis + +`theta_axis()` accepts the ordinary `x_axis()` options for labels, ticks, +formatting, and styling, plus five polar settings: + +| Option | Values | Default | +| --- | --- | --- | +| `unit` | `"radians"` or `"degrees"` | `"radians"` | +| `zero` | `"E"`, `"N"`, `"W"`, `"S"`, or a radian offset | `"E"` | +| `direction` | `"counterclockwise"` or `"clockwise"` | `"counterclockwise"` | +| `sector` | Increasing `(start, end)` no wider than one turn | Full turn | +| `grid_shape` | `"circular"` or `"linear"` | `"circular"` | + +The compass combination +`xy.theta_axis(unit="degrees", zero="N", direction="clockwise")` makes 0° point +north, 90° east, 180° south, and 270° west. + +A numeric `zero` is always an offset in radians counterclockwise from east, +independent of the data unit. Exact `tick_values` take priority over automatic +angular ticks. When `tick_labels` are omitted, authored fractional degree +values retain their precision (for example, `22.5` renders as 22.5°). + +`sector=(start, end)` clips marks and ticks to that angular interval and fits +the chart to the visible arc's bounding box. On `theta_axis()`, `domain=` is a +compatibility alias for `sector=`; pass one or the other, not both. Numeric +theta keeps its independent full-turn data and tick range. Set +`grid_shape="linear"` to join spoke intersections into polygonal radial rings. + +Category strings also work as theta coordinates. They are spaced evenly around +a full turn or across an authored sector, and their labels take priority over +numeric angle formatting. `radar_chart()` remains the convenient composition +when every series shares one category list and should close automatically. + +## Configure the Radial Axis + +`r_axis()` accepts the same options as `y_axis()`. Use `label=` for the measured +quantity, `domain=(minimum, maximum)` for a fixed radial range, and +`tick_values=` when rings must land at exact values. Radial axes support +`type_="linear"`, `"log"`, or `"symlog"`. + +The automatic linear range begins at zero so the center keeps its usual +meaning; log autorange remains strictly positive. An explicit domain can choose +a different inner value. `hole=` reserves a shared display-space inner-radius +fraction from 0 up to (but not including) 1. `origin=` places the shared radial +origin in data space, so an origin below the visible minimum creates an +annulus. `hole` and `origin` are mutually exclusive, and a log-axis origin must +be positive. + +Filled areas and annular sectors are clipped to the visible radial interval. +For example, a bar extending past the outer ring draws up to that ring instead +of disappearing, and a filled area crossing the radial minimum is trimmed +instead of reflecting through the center. A sector wholly outside the interval +disappears. Scatter points and line vertices outside the interval are culled; +an out-of-range vertex splits a line into visible runs instead of connecting +through its mirrored polar position. + +## Combine a Sector, Hole, and Error Bars + +Both angular (`xerr`) and radial (`yerr`) uncertainty project through the polar +coordinate system. This example fits the plot to a 220° compass sector and +clips the marks to a shared inner hole: + +~~~python demo exec +import reflex_xy +import xy + +error_theta = [-90.0, -45.0, 0.0, 45.0, 90.0] +error_radius = [2.0, 3.0, 2.5, 4.0, 3.2] + +polar_uncertainty = xy.polar_chart( + xy.errorbar( + error_theta, + error_radius, + yerr=[0.3, 0.5, 0.4, 0.6, 0.3], + xerr=[8.0, 6.0, 10.0, 7.0, 8.0], + color="#dc2626", + width=2.4, + cap_size=8, + ), + xy.scatter(error_theta, error_radius, color="#111827", size=7), + xy.theta_axis( + unit="degrees", + sector=(-110.0, 110.0), + zero="N", + direction="clockwise", + ), + xy.r_axis(domain=(0.0, 5.0), hole=0.28), + title="Directional uncertainty", +) + + +def polar_uncertainty_demo(): + return reflex_xy.chart(polar_uncertainty, height="390px") +~~~ + +## Hover and Zoom + +Interactive polar charts deliberately expose a smaller gesture set than +Cartesian charts: + +- Hover reports the nearest point or field cell: its series name, radial value, + and any color or size encoding. The numeric angle is left out — the cursor is + already on it — while an authored spoke label survives and + `xy.tooltip(labels={"x": ...})` opts the angle back in. A hole or excluded + part of a sector is not hit-testable. +- Wheel and modebar zoom scale the radial maximum while keeping the radial + minimum fixed. Reset restores the original radial range. +- Theta rotation/panning, interactive sector zoom, box zoom, rectangular or lasso + selection, brushing, and crosshairs are not currently available. + +Keeping the radial minimum fixed prevents an ordinary zoom from unexpectedly +turning a disc into an annulus. See +[Interactions and selections](/docs/xy/core-concepts/interactions/) for the +general interaction configuration surface. + +## Lay Out and Annotate Polar Charts + +Explicit `padding=(top, right, bottom, left)` is preserved by the polar layout. +Increase the bottom or side value to reserve a stable band for a legend, +caption, or other surrounding content; the disc stays centered in the +remaining plot box. + +Point-anchored `text`, `label`, `marker`, `arrow`, and `callout` annotations +interpret data coordinates as `(theta, r)` consistently in the browser, SVG, +and native raster output. Their `dx` and `dy` offsets remain screen-space +pixels. Polar rules and bands remain deferred because they require spoke/ring +and sector/annulus geometry instead of Cartesian lines and rectangles; using +one on a polar chart raises at payload build instead of drawing a Cartesian +approximation. + +## Use `xy.pyplot` + +The Matplotlib-style compatibility layer routes a polar subplot through the +same renderer: + +~~~python +import numpy as np +import xy.pyplot as plt + +theta = np.linspace(0.0, 2.0 * np.pi, 361) +radius = 1.0 + 0.25 * np.cos(4.0 * theta) + +fig, ax = plt.subplots(subplot_kw={"projection": "polar"}) +ax.plot(theta, radius, color="#6e56cf") +ax.fill(theta, radius, color="#6e56cf", alpha=0.15) +ax.set_theta_zero_location("N") +ax.set_theta_direction(-1) +ax.set_thetagrids([0, 90, 180, 270], ["N", "E", "S", "W"]) +ax.set_thetamin(-120) +ax.set_thetamax(120) +ax.set_rlim(0.0, 1.5) +ax.set_rorigin(-0.25) +ax.set_rticks([0.5, 1.0, 1.5]) +plt.show() +~~~ + +Polar routing works through `plt.subplot(projection="polar")`, +`fig.add_subplot(..., projection="polar")`, +`plt.axes(projection="polar")`, and +`plt.subplots(subplot_kw={"projection": "polar"})`. The `polar=True` alias is +accepted by `subplot()`, `add_subplot()`, and `axes()`, or inside +`subplot_kw`; it is not a direct `plt.subplots(polar=True)` argument. Ordinary +`plot`, `scatter`, `fill`, `bar`, heatmap/image, contour, and `errorbar` calls +share the core polar renderer. `fill()` maps its boundary to a radial area +against `r=0`, which matches a full-turn filled profile but not every arbitrary +closed Matplotlib polygon. The shim preserves theta zero, direction, offset, +and authored theta grids. Degree-based `set_thetamin()`/`set_thetamax()` share +view state with radian `set_xlim()`, while `set_rorigin()` authors the +data-space radial origin. Their corresponding getters, radial limits, ticks, +grids, categorical theta, and log/symlog radial scales use the same core axes. + +The stateful `plt.polar()`, `plt.thetagrids()`, and `plt.rgrids()` convenience +wrappers are not part of this increment; call the corresponding methods on the +polar axes. Keep and reuse the returned axes handle: passing +`projection="polar"` again while reactivating an existing `plt.subplot()` is +not supported. See the +[Matplotlib compatibility guide](/docs/xy/integrations/matplotlib/) for the +full boundary. + +## Supported Marks and Current Limits + +The supported polar primitives are `line`, `scatter`, `area`, `bar`, `column`, +`heatmap`, `contour`, and `errorbar`. The +[radar](/docs/xy/charts/radar-chart/), +[radial bar](/docs/xy/charts/radial-bar-chart/), and +[wind rose](/docs/xy/charts/wind-rose/) helpers compose those primitives; they +do not add separate renderers. + +Current limits: + +- Histograms, box plots, hexbin, density grids, generic segments, and meshes + are rejected instead of being drawn with incorrect geometry. Polar + `errorbar` and contour use narrowly allowlisted projected segments; they do + not make every segment-backed mark legal. +- Polar lines and filled-area boundaries connect observations with straight + chords. Repeat the first observation at one full turn when manually closing a + line; the radar helper handles closure itself. +- Polar traces use direct rendering rather than Cartesian line decimation or + scatter-density aggregation. `line`, `scatter`, and `area` are limited to + 200,000 points per trace; a larger point trace raises `ValueError`. + Heatmap/contour grids are not rejected merely because their cell count + exceeds that point ceiling. +- Polar rules and bands need dedicated spoke, ring, annulus, or sector + geometry and raise instead of falling back to Cartesian geometry. +- Polar LOD, facets/animation, interactive theta rotation/pan, sector zoom, and + annulus/sector selection remain deferred. + +Supported marks and point-anchored annotations use the same polar projection in +the browser, SVG, PDF, and native raster exporters, so the chart can be +displayed live or exported through the usual chart methods. + +## Related Charts + +- [Radar charts](/docs/xy/charts/radar-chart/) — compare named dimensions with + closed profiles. +- [Radial bar charts](/docs/xy/charts/radial-bar-chart/) — annular sectors, + pies, and donuts. +- [Wind rose charts](/docs/xy/charts/wind-rose/) — directional frequency split + into magnitude bands. +- [Line charts](/docs/xy/charts/line-chart/) — trends on Cartesian axes. +- [Scatter charts](/docs/xy/charts/scatter/) — relationships and multichannel + points on Cartesian axes. +- [Bar charts](/docs/xy/charts/bar-chart/) — rectangular categorical and + numeric bars. +- [Axes and scales](/docs/xy/components/axes/) — shared labels, domains, ticks, + and styling options. +- [Display and export](/docs/xy/guides/display-and-export/) — notebooks, HTML, + PNG, SVG, PDF, JPEG, and WebP. + +## FAQ + +### How do I create a polar chart in Python? + +Put a supported mark such as `xy.line(theta, radius)`, +`xy.heatmap(z, x=theta, y=radius)`, or +`xy.errorbar(theta, radius, yerr=...)` inside `xy.polar_chart(...)`. Angles are +radians by default. + +### How do I create a radar chart? + +See the [radar chart guide](/docs/xy/charts/radar-chart/) for filled and +outlined profiles, the category/value contract, and shared-scale configuration. + +### How do I create a wind rose? + +See the [wind rose guide](/docs/xy/charts/wind-rose/) for directional bins, +speed bands, input validation, and compass conventions. + +### How do I create a pie or donut chart? + +See [Pie and Donut Charts](/docs/xy/charts/pie-chart/) for four live +unequal-width sector compositions with center metrics and Reflex legends. + +### How do I use degrees instead of radians? + +Add `xy.theta_axis(unit="degrees")`. The angle data and generated tick labels +will both use degrees. + +### How do I make zero degrees point north? + +Use `xy.theta_axis(unit="degrees", zero="N", direction="clockwise")` for the +standard compass convention. The +[wind rose helper](/docs/xy/charts/wind-rose/) applies it automatically. + +### Can I migrate a Matplotlib polar plot? + +Yes. Create the axes with `plt.subplot(projection="polar")` or +`plt.subplots(subplot_kw={"projection": "polar"})`. Lines, scatter, +radial-to-zero fills, bars, heatmaps/images, contours, and error bars route +through the polar renderer. Theta zero/direction/offset and min/max, authored +theta grids, radial origin, radial limits/ticks/grids, categorical theta, and +log/symlog radius are supported. Arbitrary closed-polygon fills remain a +documented approximation. diff --git a/docs/charts/radar-chart.md b/docs/charts/radar-chart.md new file mode 100644 index 00000000..71bbf17f --- /dev/null +++ b/docs/charts/radar-chart.md @@ -0,0 +1,171 @@ +--- +title: Radar Charts in Python +description: Create filled or outlined radar and spider charts in Python with xy. Compare several measurements across a shared set of categories. +components: + - xy.radar_chart +--- + +# Radar Charts in Python + +A **radar chart** (or spider chart) compares several measurements across the +same categorical dimensions. XY places the categories at evenly spaced angles, +draws one spoke for each category, and closes every series across the circular +seam. + +Use radar charts for compact profile comparisons such as product capabilities, +model scores, survey dimensions, and operational health. When the angular +position is itself a measured quantity rather than a category, start with the +[polar chart overview](/docs/xy/charts/polar-chart/) instead. + +## Create a Radar Chart + +Pass the category labels first, followed by one `area` or `line` mark per +series. Each mark supplies exactly one value per category: + +~~~python demo exec +import reflex_xy +import xy + +capabilities = ["Speed", "Range", "Payload", "Efficiency", "Comfort"] + +radar = xy.radar_chart( + capabilities, + xy.area( + [0.92, 0.70, 0.58, 0.82, 0.64], + name="Model A", + color="#6e56cf", + line_color="#6e56cf", + opacity=0.28, + ), + xy.area( + [0.68, 0.88, 0.76, 0.61, 0.86], + name="Model B", + color="#2563eb", + line_color="#2563eb", + opacity=0.24, + ), + xy.theta_axis(grid_shape="linear"), + xy.r_axis(domain=(0.0, 1.0)), + xy.legend(loc="right"), + title="Vehicle comparison", +) + + +def radar_demo(): + return reflex_xy.chart(radar, height="440px") +~~~ + +The helper assigns the category labels to the theta axis and closes the first +and last category with a straight chord. You do not need to repeat the first +value at the end of a series. + +## Choose Filled Areas or Outlines + +Use `xy.area(values)` for a filled profile or `xy.line(values)` for an outline. +When an existing composition already uses area marks, set `fill=False` on the +chart to turn every area into an outline without rewriting the series: + +~~~python demo exec +import reflex_xy +import xy + +outline = xy.radar_chart( + ["Reliability", "Speed", "Efficiency", "Comfort"], + xy.area( + [0.90, 0.72, 0.84, 0.66], + name="Current", + color="#8b5cf6", + line_color="#7c3aed", + line_width=3, + line_opacity=0.8, + ), + xy.area( + [0.78, 0.88, 0.71, 0.81], + name="Candidate", + color="#38bdf8", + line_color="#0284c7", + line_width=3, + line_opacity=0.8, + ), + xy.theta_axis(grid_shape="linear"), + xy.r_axis(domain=(0.0, 1.0)), + xy.legend(loc="right"), + fill=False, + title="Outline comparison", +) + + +def radar_outline_demo(): + return reflex_xy.chart(outline, height="420px") +~~~ + +When `fill=False` rebuilds an area as a line, the outline inherits +`line_color`, `line_width`, `line_opacity`, curve, and dash settings. If +`line_color` is omitted, it falls back to the area's `color`. + +Filled profiles work best with some transparency so overlapping series remain +readable. Give each series a `name` and add `xy.legend()` when the chart +contains more than one profile. + +## Follow the Radar Data Contract + +Radar charts have a deliberately narrow input contract: + +- Supply at least three categories. +- Every series must contain exactly one value per category. +- Use only `area` and `line` marks. +- Put values directly on the mark. Column-name strings are not resolved because + the category list supplies the angular positions. + +XY raises `ValueError` for a category/value mismatch instead of silently +dropping a dimension or drawing a malformed polygon. + +## Configure the Scale + +Add `xy.r_axis(domain=(minimum, maximum))` when several charts need a common +comparison scale. The default radial range begins at zero and extends to the +largest value. Use `tick_values=` for exact rings and `label=` when the score +has a named unit. + +The category list owns the theta labels. An authored `xy.theta_axis()` can +customize their style; `grid_shape="linear"` joins the spokes into polygonal +rings, as in the live example. General `polar_chart()` compositions also +accept category strings directly. `radar_chart()` additionally validates that +each series matches the shared category count and closes profiles for you. + +## Interaction and Export + +Radar charts use the shared polar interaction model: hover, radial-only zoom, +and reset are available; rotation, box zoom, selection, brushing, and +crosshairs are disabled. Browser, SVG, PDF, and native raster exports share the +same radar geometry. + +See [Polar chart interaction and limits](/docs/xy/charts/polar-chart/#hover-and-zoom) +for the complete coordinate-system contract. + +## Related Polar Charts + +- [Polar overview](/docs/xy/charts/polar-chart/) — measured theta/r data, axes, + pyplot compatibility, and shared limits. +- [Radial bar charts](/docs/xy/charts/radial-bar-chart/) — magnitudes encoded as + annular sectors. +- [Wind rose charts](/docs/xy/charts/wind-rose/) — raw compass observations + binned by direction and speed. + +## FAQ + +### What is the difference between a radar chart and a polar line chart? + +A radar chart assigns evenly spaced angles to category labels and closes the +profile automatically. A polar line chart expects explicit numeric angles and +radii. + +### How do I draw an unfilled spider chart? + +Use `xy.line(values)` for each series, or pass `fill=False` to +`xy.radar_chart()` when the children are area marks. + +### Why does my radar chart raise a value-count error? + +Each series must have the same number of values as the category list. Add or +remove values so every category has one measurement. diff --git a/docs/charts/radial-bar-chart.md b/docs/charts/radial-bar-chart.md new file mode 100644 index 00000000..9b99f0b3 --- /dev/null +++ b/docs/charts/radial-bar-chart.md @@ -0,0 +1,715 @@ +--- +title: Radial Bar Charts in Python +description: Build polished radial bar, progress-ring, and semicircular capacity blocks in Python with xy and Reflex. +components: + - xy.polar_bar_chart +--- + +# Radial Bar Charts in Python + +A **radial bar chart** encodes each value as an annular sector. The bar's first +channel is its center angle, its second is its radial height, `width` controls +the angular span, and `base` controls the inner radius. + +The first example keeps that geometry intentionally small. The blocks after it +combine XY's exportable sectors with ordinary Reflex layout for center values, +legends, rails, and summary statistics. This keeps the visualization reusable +without forcing dashboard UI into the chart itself. + +~~~python exec +from xy_docs.examples import chart_examples_layout_marker +~~~ + +~~~python eval +chart_examples_layout_marker() +~~~ + +## Basic Radial Bar Chart + +Start with six named values at evenly spaced angles. Each row also carries one +shade from the purple palette used throughout these docs: + +~~~python demo exec +import reflex_xy +import xy + +RADIAL_DATA = [ + ("Direct", 0, 6, "#5b3cc4"), + ("Search", 60, 9, "#6e56cf"), + ("Email", 120, 7, "#806bd5"), + ("Partner", 180, 11, "#927edc"), + ("Social", 240, 8, "#a596e4"), + ("Other", 300, 5, "#b8afea"), +] + +radial_bars = xy.polar_bar_chart( + *( + xy.bar( + [angle], + [value], + width=48, + color=color, + name=label, + ) + for label, angle, value, color in RADIAL_DATA + ), + xy.theta_axis( + unit="degrees", + zero="N", + direction="clockwise", + tick_values=[0, 60, 120, 180, 240, 300], + ), + xy.r_axis( + label="value", + domain=(0, 12), + tick_values=[0, 4, 8, 12], + ), + xy.legend(loc="right"), + xy.modebar(show=False), +) + + +def radial_bar_demo(): + return reflex_xy.chart(radial_bars, height="320px") +~~~ + +`RADIAL_DATA` stays independent of the chart construction, so labels, values, +and colors are easy to replace. The named one-bar marks populate the legend, +while the authored ticks keep the angular and radial axes easy to read. Pass +one scalar `width` for equal angular spans or a same-length sequence for +per-bar spans. Widths must be finite and positive. + +## Allocation Overview + +Small progress rings work well when several categories share one maximum. Each +ring below is an explicit muted track plus a colored foreground sector; the +table repeats the values for exact lookup: + +~~~python demo exec +import reflex as rx +import reflex_xy +import xy + +ALLOCATION_DATA = [ + ("Platform", 44, 880_000, "#6e56cf"), + ("Product", 24, 480_000, "#806bd5"), + ("Growth", 15, 300_000, "#9888dd"), + ("Operations", 10, 200_000, "#b5aae8"), + ("Support", 7, 140_000, "#d2cbf1"), +] + + +def allocation_ring_chart(label, percent, color): + span = percent / 100 * 360.0 + return xy.polar_bar_chart( + xy.bar( + [180.0], + [0.12], + base=0.72, + width=360.0, + color="#eeecf6", + opacity=1, + ), + xy.bar( + [span / 2.0], + [0.12], + base=0.72, + width=span, + color=color, + opacity=1, + corner_radius=8, + name=f"{label} · {percent}%", + ), + xy.theta_axis( + unit="degrees", + zero="N", + direction="clockwise", + show=False, + tick_label_strategy="none", + ), + xy.r_axis( + domain=(0.0, 1.0), + show=False, + tick_label_strategy="none", + ), + xy.legend(show=False), + xy.modebar(show=False), + xy.theme(plot_background="#ffffff"), + width="100%", + height=92, + padding=(0, 0, 0, 0), + ) + + +def allocation_ring(label, percent, color): + return rx.el.div( + rx.el.div( + reflex_xy.chart( + allocation_ring_chart(label, percent, color), + height="92px", + ), + rx.el.span( + f"{percent}%", + class_name=( + "pointer-events-none absolute inset-0 flex items-center " + "justify-center text-sm font-semibold tabular-nums text-zinc-950" + ), + ), + class_name="relative aspect-square w-full", + ), + rx.el.span( + label, + class_name="truncate text-[11px] leading-none text-zinc-500", + ), + class_name="min-w-0 text-center", + ) + + +def allocation_row(label, percent, amount, color): + return rx.el.div( + rx.el.div( + rx.el.span( + class_name="size-2.5 shrink-0 rounded-[3px]", + style={"background": color}, + ), + rx.el.span( + f"{percent}%", + class_name="w-9 font-medium tabular-nums text-zinc-950", + ), + rx.el.span(label, class_name="text-zinc-500"), + class_name="flex min-w-0 items-center gap-2", + ), + rx.el.span( + f"${amount:,.0f}", + class_name="font-medium tabular-nums text-zinc-950", + ), + class_name=( + "flex items-center justify-between rounded-lg px-2.5 py-1.5 " + "text-xs even:bg-[#fafafa]" + ), + ) + + +def allocation_overview_demo(): + return rx.el.div( + rx.el.div( + rx.el.div( + rx.el.span( + "Annual plan", + class_name="text-[10px] uppercase tracking-wide text-zinc-500", + ), + rx.el.span( + "Team allocation", + class_name="text-lg font-medium tracking-tight text-zinc-950", + ), + class_name="flex flex-col", + ), + rx.el.span( + "$2.0M planned", + class_name="text-xs font-medium tabular-nums text-zinc-500", + ), + class_name="flex items-start justify-between gap-4", + ), + rx.el.div( + *( + allocation_ring(label, percent, color) + for label, percent, _amount, color in ALLOCATION_DATA + ), + class_name="mt-2 grid grid-cols-5 gap-2", + ), + rx.el.div( + *( + allocation_row(label, percent, amount, color) + for label, percent, amount, color in ALLOCATION_DATA + ), + class_name="mt-2 grid gap-0.5 border-t border-zinc-100 pt-2", + ), + class_name=( + "mx-auto flex h-[22.5rem] w-full max-w-[630px] flex-col " + "bg-white p-4" + ), + ) +~~~ + +The rings use a common radial domain and thickness. The percentage controls +only the foreground `width`, while the track remains a complete annulus. The +labels and exact dollar values stay in Reflex so the ring geometry can be +exported or reused independently. + +## Training Summary + +A hero metric can share a card with compact radial KPIs. Here the primary +distance and goal rail lead, while three rings summarize the supporting +measurements and the narrow side column preserves quick operational context: + +~~~python demo exec +import reflex as rx +import reflex_xy +import xy + +TRAINING_METRICS = [ + ("Elevation", 312, "m", 68, "#6e56cf"), + ("Work", 684, "kJ", 82, "#f59e0b"), + ("Cadence", 82, "rpm", 74, "#e11d48"), +] +TRAINING_STATS = [ + ("Intervals", "7"), + ("Recovery", "46 min"), + ("Avg speed", "28.4 km/h"), +] + + +def training_ring_chart(label, percent, color): + span = percent / 100 * 360.0 + return xy.polar_bar_chart( + xy.bar( + [180.0], + [0.13], + base=0.70, + width=360.0, + color="#f0eef7", + opacity=1, + ), + xy.bar( + [span / 2.0], + [0.13], + base=0.70, + width=span, + color=color, + opacity=1, + corner_radius=9, + name=f"{label} · {percent}%", + ), + xy.theta_axis( + unit="degrees", + zero="N", + direction="clockwise", + show=False, + tick_label_strategy="none", + ), + xy.r_axis( + domain=(0.0, 1.0), + show=False, + tick_label_strategy="none", + ), + xy.legend(show=False), + xy.modebar(show=False), + xy.theme(plot_background="#ffffff"), + width="100%", + height=118, + padding=(0, 0, 0, 0), + ) + + +def training_metric(label, value, unit, percent, color): + return rx.el.div( + rx.el.div( + reflex_xy.chart( + training_ring_chart(label, percent, color), + height="118px", + ), + rx.el.div( + rx.el.span( + str(value), + class_name="text-lg font-semibold leading-none tabular-nums text-zinc-950", + ), + rx.el.span( + unit, + class_name="text-[10px] leading-none text-zinc-500", + ), + class_name=( + "pointer-events-none absolute inset-0 flex items-center " + "justify-center gap-1" + ), + ), + class_name="relative aspect-square w-full", + ), + rx.el.span( + label, + class_name="-mt-1 text-center text-[11px] text-zinc-500", + ), + class_name="min-w-0", + ) + + +def training_summary_demo(): + return rx.el.div( + rx.el.div( + rx.el.div( + rx.el.span( + "Training summary", + class_name="text-lg font-medium tracking-tight text-zinc-950", + ), + rx.el.span( + "Week 28", + class_name="text-xs text-zinc-500", + ), + class_name="flex items-center justify-between", + ), + rx.el.div( + rx.el.div( + rx.el.span( + "24.7", + class_name=( + "text-4xl font-semibold leading-none tracking-tight " + "tabular-nums text-zinc-950" + ), + ), + rx.el.span( + "km ridden", + class_name="pb-0.5 text-sm text-zinc-500", + ), + class_name="flex items-end gap-2", + ), + rx.el.span( + "78% of weekly goal", + class_name="text-xs font-medium text-[#6e56cf]", + ), + class_name="mt-3 flex items-end justify-between gap-4", + ), + rx.el.div( + rx.el.div( + class_name="h-full rounded-full bg-[#6e56cf]", + style={"width": "78%"}, + ), + class_name="mt-2 h-1.5 overflow-hidden rounded-full bg-[#eeecf6]", + ), + rx.el.div( + *( + training_metric(label, value, unit, percent, color) + for label, value, unit, percent, color in TRAINING_METRICS + ), + class_name="mt-2 grid grid-cols-3 gap-3", + ), + class_name="min-w-0", + ), + rx.el.div( + *( + rx.el.div( + rx.el.span( + label, + class_name="text-[10px] uppercase tracking-wide text-zinc-400", + ), + rx.el.span( + value, + class_name="mt-0.5 text-lg font-medium tabular-nums text-zinc-950", + ), + class_name="flex flex-col", + ) + for label, value in TRAINING_STATS + ), + class_name=( + "flex flex-col justify-center gap-5 border-l border-zinc-100 pl-4" + ), + ), + class_name=( + "mx-auto grid h-[22.5rem] w-full max-w-[630px] " + "grid-cols-[minmax(0,1fr)_9rem] gap-4 bg-white p-4" + ), + ) +~~~ + +The chart helper owns only a track and foreground arc. The headline, horizontal +goal rail, center values, and side statistics are ordinary layout, so they +remain easy to restyle without changing the chart payload. + +## Cache Tiers + +Nested semicircular bars can compare several capacities without repeating four +separate plots. Every tier starts at the same angle; its foreground width is the +used fraction of the shared 180-degree track: + +~~~python demo exec +import reflex as rx +import reflex_xy +import xy + +CACHE_TIERS = [ + ("Memory", 610, 1_000, "#6e56cf", "#9d8df1"), + ("Regional", 240, 1_000, "#f59e0b", "#fcd34d"), + ("Edge", 100, 1_000, "#2563eb", "#60a5fa"), + ("Origin", 50, 1_000, "#e11d48", "#fb7185"), +] +CACHE_BASES = [0.24, 0.40, 0.56, 0.72] +CACHE_STATS = [ + ("Served warm", "8,420"), + ("Revalidated", "1,140"), + ("Evictions", "386"), + ("Purges", "72"), +] + +cache_tiers = xy.polar_bar_chart( + *( + xy.bar( + [0.0], + [0.09], + base=base, + width=180.0, + color="#eeecf6", + opacity=1, + corner_radius=8, + ) + for base in CACHE_BASES + ), + *( + xy.bar( + [-90.0 + (used / capacity * 180.0) / 2.0], + [0.09], + base=base, + width=used / capacity * 180.0, + name=label, + fill=f"linear-gradient(to top, {start}, {end})", + opacity=1, + corner_radius=8, + stroke="#ffffff", + stroke_width=1, + ) + for (label, used, capacity, start, end), base in zip( + CACHE_TIERS, + CACHE_BASES, + strict=True, + ) + ), + xy.theta_axis( + unit="degrees", + sector=(-90.0, 90.0), + zero="N", + direction="clockwise", + show=False, + tick_label_strategy="none", + ), + xy.r_axis( + domain=(0.0, 1.0), + show=False, + tick_label_strategy="none", + ), + xy.legend(show=False), + xy.modebar(show=False), + xy.theme(plot_background="#ffffff"), + width="100%", + height=210, + padding=(4, 4, 0, 4), +) + + +def cache_legend_item(label, used, capacity, color): + percent = used / capacity + return rx.el.div( + rx.el.div( + rx.el.span( + class_name="size-2.5 shrink-0 rounded-[3px]", + style={"background": color}, + ), + rx.el.span( + label, + class_name="truncate text-xs font-medium text-zinc-700", + ), + class_name="flex items-center gap-2", + ), + rx.el.span( + f"{used:,}/{capacity:,} ({percent:.0%})", + class_name="mt-1 text-[11px] tabular-nums text-zinc-500", + ), + class_name="min-w-0", + ) + + +def cache_tiers_demo(): + return rx.el.div( + rx.el.div( + rx.el.span( + "Cache tiers", + class_name="text-lg font-medium tracking-tight text-zinc-950", + ), + rx.el.span( + "Last 24 hours", + class_name="text-xs text-zinc-500", + ), + class_name="flex items-center justify-between", + ), + rx.el.div( + rx.el.div( + reflex_xy.chart(cache_tiers, height="210px"), + class_name="min-w-0", + ), + rx.el.div( + *( + rx.el.div( + rx.el.span( + label, + class_name="text-xs text-zinc-500", + ), + rx.el.span( + value, + class_name=( + "mt-0.5 text-xl font-medium tabular-nums " + "tracking-tight text-zinc-950" + ), + ), + class_name="flex flex-col", + ) + for label, value in CACHE_STATS + ), + class_name="grid grid-cols-2 content-center gap-x-5 gap-y-5", + ), + class_name="mt-1 grid min-h-0 flex-1 grid-cols-[1.15fr_0.85fr] gap-4", + ), + rx.el.div( + *( + cache_legend_item(label, used, capacity, start) + for label, used, capacity, start, _end in CACHE_TIERS + ), + class_name=( + "grid grid-cols-4 gap-3 border-t border-zinc-100 pt-3" + ), + ), + class_name=( + "mx-auto flex h-[22.5rem] w-full max-w-[630px] flex-col " + "bg-white p-4" + ), + ) +~~~ + +Separate one-bar marks make it straightforward to give every tier a name, +gradient, and rounded edge. The `sector=(-90.0, 90.0)` axis fits the visible +semicircle to the available plot box instead of reserving space for the missing +half. + +## Build Your Own Radial Block + +1. Decide whether values control radial height, angular progress, or both. +2. Author a muted track when every category shares a known maximum. +3. Keep the track and foreground on the same `base` and radial height. +4. Convert a percentage to `percentage / 100 × 360` for a full ring, or multiply + by the declared partial-sector span. +5. Hide axes and the native legend when Reflex supplies the surrounding labels. +6. Keep raw data and derived spans outside the chart construction. + +XY does not add a background track or infer a maximum automatically. Explicit +track marks make the maximum, thickness, color, and export behavior unambiguous. + +## Inner Radius and Sector Geometry + +`base` is the inner radial edge, and the bar value is the height above it. A +scalar base opens the same hole beneath every bar; a sequence can give each bar +its own starting radius: + +~~~python +rings = xy.polar_bar_chart( + xy.bar( + [0, 90, 180, 270], + [0.30, 0.42, 0.24, 0.36], + base=0.40, + width=72, + ), + xy.theta_axis(unit="degrees"), + xy.r_axis(domain=(0.0, 1.0)), +) +~~~ + +Filled sectors that cross the visible radial range are clipped at the inner or +outer ring. A sector wholly outside the range disappears instead of reflecting +through the center. + +`base=` belongs to the mark and may differ per bar. For one chart-wide opening, +use `r_axis(hole=...)` to reserve a display-space inner fraction or +`r_axis(origin=...)` to set a data-space radial origin. `hole` and `origin` are +mutually exclusive. + +A single sector with `width=360` on a degree axis (or a full `2π` width on a +radian axis) and a positive `base` draws a complete annulus. That is the track +used by the progress-ring blocks above. + +## Partial Gauges + +Use `sector=(start, end)` for a gauge whose visible arc should expand to fill +the plot box. Rounded sectors, background-colored strokes, gradients, and +explicit padding work the same way on a partial layout: + +~~~python +partial_gauge = xy.polar_bar_chart( + xy.bar( + [0.0], + [0.18], + base=0.70, + width=240.0, + color="#e5e7eb", + corner_radius=10, + ), + xy.bar( + [-30.0], + [0.18], + base=0.70, + width=180.0, + fill="linear-gradient(to top, #7c3aed, #34d399)", + corner_radius=10, + stroke="#ffffff", + stroke_width=1.5, + ), + xy.theta_axis( + unit="degrees", + sector=(-120.0, 120.0), + zero="N", + direction="clockwise", + show=False, + ), + xy.r_axis(domain=(0.0, 1.0), show=False), + padding=(18, 18, 36, 18), +) +~~~ + +Use a scalar `corner_radius` for symmetric rounding. A gradient applies to the +whole mark and takes priority over a solid color. Use `stroke_width` to control +the separator or outline in screen pixels. Explicit +`padding=(top, right, bottom, left)` is preserved by the polar layout, so a +stable caption or legend band can be reserved without shifting the arc. + +## Angular Convention + +Use `xy.theta_axis(unit="degrees")` when positions and widths are expressed in +degrees. Add `zero="N", direction="clockwise"` for compass bearings. With the +default radian axis, both the center angles and widths must be radians. + +On `theta_axis()`, `domain=` is a compatibility alias for `sector=`; pass only +one. XY clips marks and ticks to that interval and fits the visible arc to its +own bounding box. + +## Interaction and Export + +All live blocks on this page set `xy.modebar(show=False)` to keep the +presentation quiet. Sector hover, radial wheel zoom, double-click reset, and +browser/static exports remain available through the shared polar renderer. + +Reflex-composed center values, rails, statistics, and custom legends are browser +UI. The radial sectors themselves remain part of the XY chart and are preserved +in SVG and native raster exports. + +See the [polar overview](/docs/xy/charts/polar-chart/) for shared axes, +interaction, pyplot, large-data, and annotation limits. + +## Related Polar Charts + +- [Radar charts](/docs/xy/charts/radar-chart/) — compare values across named + dimensions. +- [Pie and donut charts](/docs/xy/charts/pie-chart/) — polished share, + progress-ring, and gauge blocks. +- [Wind rose charts](/docs/xy/charts/wind-rose/) — bin raw direction/speed + observations into stacked radial bars. +- [Bar charts](/docs/xy/charts/bar-chart/) — rectangular bars on Cartesian + axes. + +## FAQ + +### How do I add gaps between radial bars? + +Place bar centers at the full bin spacing and choose a slightly smaller +`width`. For centers 30° apart, `width=26` leaves a 4° gap. + +### How do I create a donut hole? + +Set a positive `base` and choose a bar height that reaches the desired outer +radius. Hide both axes for a conventional ring presentation. + +### Can each sector have a different angular width? + +Yes. Pass a width sequence with one finite, positive value per bar. diff --git a/docs/charts/uncertainty.md b/docs/charts/uncertainty.md index dbae0490..f334d4e2 100644 --- a/docs/charts/uncertainty.md +++ b/docs/charts/uncertainty.md @@ -142,6 +142,11 @@ beneath a line to show confidence or forecast ranges. Use `errorbar` for uncertainty attached to individual observations. It supports x or y uncertainty and symmetric or asymmetric values. +`errorbar` is also supported inside +[`xy.polar_chart()`](/docs/xy/charts/polar-chart/#combine-a-sector-hole-and-error-bars), +where `xerr` is angular uncertainty and `yerr` is radial uncertainty. +`error_band` remains Cartesian-only. + ## Variants Use symmetric or asymmetric error arrays on either axis. Layer a translucent diff --git a/docs/charts/wind-rose.md b/docs/charts/wind-rose.md new file mode 100644 index 00000000..8adcc174 --- /dev/null +++ b/docs/charts/wind-rose.md @@ -0,0 +1,148 @@ +--- +title: Wind Rose Charts in Python +description: Create wind rose charts in Python with xy. Bin compass bearings into sectors and stack directional frequencies by speed band. +components: + - xy.wind_rose +--- + +# Wind Rose Charts in Python + +A **wind rose** summarizes how often observations arrive from each compass +direction and how those observations are distributed across speed bands. XY +bins the raw direction/speed pairs in Python and renders the result as stacked +polar bars. + +Use `wind_rose()` when you have one bearing and one magnitude per observation. +If the directional counts are already aggregated, use a +[radial bar chart](/docs/xy/charts/radial-bar-chart/) instead. + +## Create a Wind Rose + +Directions are compass bearings in degrees: 0° is north and values increase +clockwise. The helper applies that convention automatically: + +~~~python demo exec +import numpy as np +import reflex_xy +import xy + +rng = np.random.default_rng(14) +wind_direction = np.mod( + np.concatenate( + [ + rng.normal(35.0, 28.0, 260), + rng.normal(225.0, 38.0, 180), + ] + ), + 360.0, +) +wind_speed = np.clip(rng.gamma(shape=2.4, scale=2.1, size=440), 0.2, 11.8) + +rose = xy.wind_rose( + wind_direction, + wind_speed, + sectors=16, + speed_bins=(2, 4, 6, 8, 12), + title="Wind frequency by direction and speed", +) + + +def wind_rose_demo(): + return reflex_xy.chart(rose, height="440px") +~~~ + +Each stacked color band counts observations whose speed is above the previous +edge and at or below the current edge. The legend labels show those inclusive +upper edges. + +## Choose Directional Sectors + +`sectors=` controls the number of equally sized angular bins and must be at +least 3. Each bin is centered on its compass bearing, so a value of exactly 0° +belongs to the sector centered on north. Bearings outside `0..360` wrap around +the circle. + +More sectors reveal directional detail but need more observations to keep each +bin stable. Common choices are 8, 12, 16, or 36 sectors, depending on sample +size and the directional resolution of the source. + +## Configure Speed Bands + +Pass increasing upper edges through `speed_bins=`: + +~~~python +rose = xy.wind_rose( + directions, + speeds, + sectors=16, + speed_bins=(2, 4, 6, 8, 12), +) +~~~ + +The final edge should cover the fastest observation; values above it do not +belong to a displayed band. XY removes duplicate edges and orders the remaining +values. When `speed_bins` is omitted, it derives up to four readable bands from +the speed quartiles and rounds the top edge upward so every finite observation +is covered. + +## Follow the Input Contract + +- `directions` and `speeds` must have the same length. +- Each pair describes one observation. +- Non-finite pairs are dropped together. +- At least one finite pair must remain. +- `sectors` must be 3 or greater. +- `speed_bins` must contain at least one edge when supplied. + +XY raises `ValueError` for mismatched arrays, an empty finite dataset, too few +sectors, or an empty band definition. + +## Read and Style the Result + +The radial value is a count, not a speed. Each speed band becomes one stacked +bar series and takes the next chart palette color. Pass chart keyword props +such as `title`, `width`, `height`, `padding`, `class_names`, and `styles` through +`wind_rose()`. + +The helper authors a degree-based theta axis with north at zero and clockwise +rotation, plus an r axis labeled `count`. Build the equivalent sectors manually +with `polar_bar_chart()` when you need custom pre-binning, non-count radial +values, a different angular convention, or component children such as +`xy.theme()`, `xy.legend()`, and `xy.modebar()`. + +## Interaction and Export + +Wind roses support hover, fixed-minimum radial zoom, reset, and browser/static +export through the shared polar renderer. Theta rotation, box zoom, selection, +brushing, and crosshairs are not available. + +See the [polar overview](/docs/xy/charts/polar-chart/) for the full interaction, +renderer, annotation, and large-data boundary. + +## Related Polar Charts + +- [Polar overview](/docs/xy/charts/polar-chart/) — numeric theta/r data and + shared polar axes. +- [Radial bar charts](/docs/xy/charts/radial-bar-chart/) — pre-aggregated + annular sectors. +- [Pie and donut charts](/docs/xy/charts/pie-chart/) — share, progress-ring, + revenue-mix, and gauge blocks. +- [Radar charts](/docs/xy/charts/radar-chart/) — category profiles rather than + directional frequencies. + +## FAQ + +### Are wind directions radians or degrees? + +`wind_rose()` always accepts compass bearings in degrees. It applies north-zero, +clockwise theta settings automatically. + +### What happens when I omit `speed_bins`? + +XY derives up to four bands from the finite speed quartiles and rounds the final +edge upward to include the maximum observation. + +### Why are some observations missing? + +Non-finite direction/speed pairs are dropped. With authored `speed_bins`, make +sure the final upper edge covers the fastest finite observation. diff --git a/docs/components/annotations.md b/docs/components/annotations.md index c65eba52..ff2db13f 100644 --- a/docs/components/annotations.md +++ b/docs/components/annotations.md @@ -208,6 +208,16 @@ Annotation coordinates use the chart's axis space. Rules and markers take scalar coordinates or categories, bands take two endpoints, and arrows take a start and end point. They do not require a separate data table. +Point-anchored `text`, `label`, `marker`, `arrow`, and `callout` annotations +interpret data coordinates as `(theta, r)` consistently on polar charts in the +browser, SVG, and native raster output. Their `dx` and `dy` offsets remain +screen-space pixels, and the authored hole or excluded portion of a sector is +clipped consistently. Polar rules and bands remain deferred: they need +spokes/rings and sectors/annuli rather than Cartesian straight lines and +rectangles. `hline`, `vline`, `x_band`, `y_band`, `threshold`, and +`threshold_zone` therefore raise `ValueError` on a polar chart instead of +drawing a Cartesian approximation. + ## Styling and Paint Order Annotation geometry uses `color`, `width`, `opacity`, and component-specific diff --git a/docs/components/modebars-and-interaction-controls.md b/docs/components/modebars-and-interaction-controls.md index 8da483c5..3d0b26c0 100644 --- a/docs/components/modebars-and-interaction-controls.md +++ b/docs/components/modebars-and-interaction-controls.md @@ -23,6 +23,14 @@ Back and Next view-history controls live at the top of the zoom menu. They disable automatically at the ends of the history, remain open while stepping through views, and a new navigation after going Back clears the forward stack. +Polar charts use a narrower control contract: hover, radial wheel/button zoom, +reset, and export remain available. Radial zoom scales `r_max` about a fixed +`r_min` while preserving an authored `hole` or `origin`. Authored angular +sectors render normally, but Pan/theta rotation, interactive sector zoom, box +zoom, selection, brushing, and crosshairs do not yet have polar semantics and +stay disabled even when a general interaction flag is enabled. The polar +modebar therefore omits the Pan button. + ### The default toolbar Every interactive chart gets the modebar for free — hover over the top-right diff --git a/docs/components/tooltips.md b/docs/components/tooltips.md index f6a0fe7e..74803b17 100644 --- a/docs/components/tooltips.md +++ b/docs/components/tooltips.md @@ -8,15 +8,23 @@ components: # Tooltips in Python XY shows a built-in hover tooltip by default. With no tooltip component it -reports the available x/y values and encoded color or size values. Add -`tooltip()` to choose fields, give source columns readable labels, format -values, supply a title template, hide the tooltip, or register -framework-rendered content. +leads with the hovered series name when one is available, then reports the +available x/y values and encoded color or size values. Polar charts label the +radial row `r` and drop the numeric angle, which is layout rather than data. +Add `tooltip()` to choose fields, give source columns +readable labels, format values, supply a title template, hide the tooltip, or +register framework-rendered content. ## Default Hover Tooltip With a bare `xy.tooltip()` (or none at all), hovering a point reports its x and -y values without any further configuration. +y values without any further configuration. A named mark uses its series name +as the tooltip title. On a polar chart the readout reports the values — series +name, radial value, and any color or size encoding — and leaves the numeric +angle out, since the cursor is already sitting on it. An authored spoke label +survives, so a radar category reads `power` rather than a number, and +`labels={"x": ...}` opts the angle back in formatted through the theta axis. +Explicit `title=` and `fields=` continue to control a customized readout. ~~~python demo exec import reflex_xy diff --git a/docs/core-concepts/interactions.md b/docs/core-concepts/interactions.md index 179b39bc..c446e0ff 100644 --- a/docs/core-concepts/interactions.md +++ b/docs/core-concepts/interactions.md @@ -46,6 +46,19 @@ Viewport DOM events are always available. Add ``on_view_change`` to a live notebook or Reflex adapter when Python needs the semantic range events; there is no separate transport configuration flag. +### Polar interaction boundary + +Polar charts keep hover, radial wheel/modebar zoom, and reset. Radial zoom +changes the maximum radius while holding the minimum fixed, so zooming a disc +does not unexpectedly create a hole and an authored `hole` or `origin` remains +stable. Authored sectors are supported, but theta rotation/panning, interactive +sector zoom, box zoom, selection, brushing, and crosshairs are disabled until +those gestures have polar-native geometry. Polar charts do not expose the Pan +button, and interaction flags cannot opt one into an unsupported gesture. + +See the [polar chart guide](/docs/xy/charts/polar-chart/#hover-and-zoom) for the +complete coordinate-system boundary. + ## Handle chart events in Reflex Core chart containers accept `on_hover`, `on_click`, `on_brush`, `on_select`, diff --git a/docs/core-concepts/large-data-and-performance.md b/docs/core-concepts/large-data-and-performance.md index 7ebb2fdb..00c4e1b1 100644 --- a/docs/core-concepts/large-data-and-performance.md +++ b/docs/core-concepts/large-data-and-performance.md @@ -25,6 +25,16 @@ and very large density traces can build a lazy multiresolution pyramid for viewport queries. These are pre-1.0 policy thresholds, not API guarantees; write code against the behavior and recorded `tier`, not a hard-coded count. +Polar traces are an explicit exception to this ladder. They always use the +direct tier because Cartesian M4 and density reductions do not preserve cyclic +polar geometry. Polar `line`, `scatter`, and `area` traces have a hard +200,000-point ceiling; building a larger point trace raises `ValueError` +instead of silently drawing an unbounded payload or applying an unsuitable +reduction. Heatmap and contour grids are not rejected merely because their +cell count exceeds that point ceiling. Polar heatmaps inverse-sample at the +requested browser/export surface, so output work is pixel-bounded even though +source ingest and validation still depend on the source grid. + ## Automatic versus explicit density ~~~python demo exec diff --git a/docs/guides/display-and-export.md b/docs/guides/display-and-export.md index 49f9fbbe..f94d597a 100644 --- a/docs/guides/display-and-export.md +++ b/docs/guides/display-and-export.md @@ -53,6 +53,13 @@ font, and WebGL fidelity for any format except SVG. Native exports never install or launch a browser; when Chromium is requested but not found, the error names `XY_BROWSER` and the supported browsers. +Polar line/area/bar geometry, heatmaps, contours, error bars, partial sectors, +holes/origins, log/symlog radius, polygonal grids, and point-anchored +annotations share one projection across the browser, SVG, and native raster +paths. Polar heatmaps are inverse-sampled at the requested output resolution +and embedded as bounded raster layers in SVG/PDF; contour lines and chart +chrome remain vector. + ### Background policy `background` accepts `"auto"` (each renderer's default backdrop: opaque white @@ -112,8 +119,11 @@ chart.to_html("chart.html") ~~~ HTML export is self-contained: it includes the chart spec, binary data, and -bundled render client. It keeps zoom, pan, hover, selection, and built-in chart -chrome and does not need a browser or network connection at export time. +bundled render client. It keeps the interactions supported by that chart and +coordinate system—zoom, pan, hover, and selection on ordinary Cartesian +charts, with the narrower radial-zoom/hover/reset contract on polar charts—and +built-in chart chrome. It does not need a browser or network connection at +export time. Pass `custom_css=` to include author CSS for chrome classes and tokens in the exported document. Standalone HTML uses inline scripts and styles by design; diff --git a/docs/integrations/matplotlib.md b/docs/integrations/matplotlib.md index d075c1d3..d31d9afc 100644 --- a/docs/integrations/matplotlib.md +++ b/docs/integrations/matplotlib.md @@ -40,11 +40,67 @@ is tested. Depending on the feature, output can have exact geometry, equivalent semantics, or a documented visual approximation. It is not a claim to reproduce Matplotlib's renderer or complete Artist graph. +## Polar Plots + +Create a polar axes with `plt.subplot()` or `Figure.add_subplot()`: + +~~~python +import numpy as np +import xy.pyplot as plt + +theta = np.linspace(0.0, 2.0 * np.pi, 361) +radius = 1.0 + 0.25 * np.cos(4.0 * theta) + +fig, ax = plt.subplots(subplot_kw={"projection": "polar"}) +ax.plot(theta, radius, color="#6e56cf") +ax.fill(theta, radius, color="#6e56cf", alpha=0.15) +ax.set_theta_zero_location("N") +ax.set_theta_direction(-1) +ax.set_thetagrids([0, 90, 180, 270], ["N", "E", "S", "W"]) +ax.set_thetamin(-120) +ax.set_thetamax(120) +ax.set_rlim(0.0, 1.5) +ax.set_rorigin(-0.25) +ax.set_rticks([0.5, 1.0, 1.5]) +plt.show() +~~~ + +The shim supports `projection="polar"` through `plt.subplot()`, +`Figure.add_subplot()`, `plt.axes()`, and `plt.subplots(subplot_kw=...)`. +`polar=True` is accepted by the first three factories or inside `subplot_kw`; +it is not a direct `plt.subplots(polar=True)` argument. Ordinary `plot`, +`scatter`, `fill`, `bar`, regular-grid heatmap/image, contour, and `errorbar` +calls route through the core polar renderer in HTML, PNG, and SVG. `fill()` is +a documented approximation: it creates a radial area against `r=0`, which is +correct for a full-turn filled profile but does not retain the closing chord of +every arbitrary Matplotlib polygon. + +Polar axes preserve `set_theta_zero_location()`, `set_theta_direction()`, +`set_theta_offset()`, `set_thetagrids()`, radial limits/ticks/grids, categorical +theta, and log/symlog radial scales. Degree-based +`set_thetamin()`/`set_thetamax()` and their getters share the same sector state +as radian `set_xlim()`/`get_xlim()`; the latest call wins. +`set_rorigin()`/`get_rorigin()` expose the data-space radial origin. + +Polar rules/spans, generic mesh or segment artists, LOD, facets/animation, and +angular navigation/selection remain outside this surface. Use axes methods +rather than the not-yet-exposed stateful `plt.polar()`, `plt.thetagrids()`, and +`plt.rgrids()` convenience wrappers. Keep the returned axes handle instead of +passing `projection="polar"` again to reactivate an existing `plt.subplot()`. + +The declarative [polar chart overview](/docs/xy/charts/polar-chart/) documents +the shared coordinate system. Focused guides cover +[radar charts](/docs/xy/charts/radar-chart/), +[radial bars and donuts](/docs/xy/charts/radial-bar-chart/), and +[wind roses](/docs/xy/charts/wind-rose/). + ## Compatibility Boundary -Unsupported projections, animations, GUI backends, arbitrary third-party -Artist graphs, clipping/transform graphs, and material options that XY cannot -honor fail with an actionable error instead of being silently ignored. +Three-dimensional, geographic, ternary, and custom projections; animations; +GUI backends; arbitrary third-party Artist graphs; clipping/transform graphs; +and material options that XY cannot honor fail with an actionable error +instead of being silently ignored. Polar is the supported non-Cartesian +projection, with the limits above. Consult the repository's [generated compatibility matrix](https://github.com/reflex-dev/xy/blob/main/spec/matplotlib/compat-matrix.md) diff --git a/docs/overview/gallery.md b/docs/overview/gallery.md index e2be6f31..2c4f9caf 100644 --- a/docs/overview/gallery.md +++ b/docs/overview/gallery.md @@ -6,9 +6,9 @@ description: Browse every chart type and visual pattern available in XY. # Chart Gallery Start with the visual result you need. Every public chart type is represented -below, grouped to match the Chart Gallery navigation. Each tile opens a focused -family page with guidance on when to use the chart, a live example, its expected -data shape, common variants, and the options that matter most. +below, grouped by visual family. Each tile opens a focused family page with +guidance on when to use the chart, a live example, its expected data shape, +common variants, and the options that matter most. ~~~python exec from xy_docs.gallery import chart_gallery_grid @@ -39,6 +39,12 @@ Looking for a specific family? - Density and grids: [heatmap](/docs/xy/charts/heatmap/), [hexbin](/docs/xy/charts/hexbin/), and [contour](/docs/xy/charts/contour-plot/) - [Uncertainty](/docs/xy/charts/uncertainty/) for error bars and bands +- Polar families: [polar charts](/docs/xy/charts/polar-chart/) for measured + angles and radii, [radar charts](/docs/xy/charts/radar-chart/) for category + comparisons, [radial bars](/docs/xy/charts/radial-bar-chart/) for annular + sectors, [pie and donut charts](/docs/xy/charts/pie-chart/) for shares, + progress rings, and gauges, and + [wind roses](/docs/xy/charts/wind-rose/) for directional distributions - Specialized: [stem](/docs/xy/charts/stem-plot/), [segments](/docs/xy/charts/segments/), and [triangle mesh](/docs/xy/components/triangle-mesh/) diff --git a/docs/styling/capabilities.md b/docs/styling/capabilities.md index 7c0b6291..0f67668b 100644 --- a/docs/styling/capabilities.md +++ b/docs/styling/capabilities.md @@ -11,7 +11,7 @@ Every styling question about XY has the same two halves: *can I change this*, and *does the change survive where I need it*. This page answers both from the registry the implementation is checked against. -- **10** mark style properties across **20** mark kinds, drawn by all three renderers. +- **11** mark style properties across **20** mark kinds, drawn by all three renderers. - **29** stable chrome slots for CSS and Tailwind in the browser. - **1** way to add a mark kind XY does not ship, without forking it. @@ -31,6 +31,7 @@ built — one renderer never silently ignores what another draws. | `stroke-dasharray` | svg | `area`, `ecdf`, `line`, `stairs`, `step` | full | full | full | shipped | | `stroke-linecap` | svg | `ecdf`, `line`, `stairs`, `step` | full | full | full | shipped | | `border-radius` | css | `bar`, `column`, `hist`, `histogram` | full | full | full | shipped | +| `wedge-gap` | xy | `bar`, `column`, `hist`, `histogram` | full | full | full | shipped | | `marker-shape` | xy | `scatter` | full | full | full | shipped | ### Notes @@ -43,6 +44,7 @@ built — one renderer never silently ignores what another draws. - **`stroke-dasharray`** — 2-8 positive px lengths, or `none`. The WebGL client tracks arc length on the CPU so dashes stay continuous across segments and constant on screen through zoom. - **`stroke-linecap`** — Line family only — a cap is open-path geometry. XY's default is `round`, not CSS's `butt`, because the native rasterizer has always drawn round and is the reference for static export. Verified per renderer: a Rust coverage test, a rasterized-ink test, and three Chromium screenshots that hash differently per cap. - **`border-radius`** — Rect kinds only. `corner_radius=(tip, base)` rounds the two ends separately. +- **`wedge-gap`** — Gap between neighbouring polar wedges, in px. Rect kinds under `coords="polar"` only; ignored elsewhere. Deliberately a LENGTH rather than an angle: an angular pad's seam is `r * dtheta` wide, so it tapers to nothing at the hole and reads as uneven spacing. The angular inset therefore grows as the radius shrinks, which is the same construction as d3's padAngle/padRadius pair. An XY vocabulary name: CSS has no gap between two arcs. - **`marker-shape`** — 17 shapes, drawn as analytic signed-distance fields in all three renderers. An XY vocabulary name: CSS has no shape keyword for a non-DOM point mark, and the CSS spelling and `symbol=` compile to the same value. ## Chrome slots diff --git a/js/src/00_header.ts b/js/src/00_header.ts index 443ec35c..b4332c02 100644 --- a/js/src/00_header.ts +++ b/js/src/00_header.ts @@ -36,7 +36,35 @@ // v10: `title_options` carries independent left/center/right title artists, // including axes-fraction y and pixel padding. A v9 client would silently omit // non-center slots and their placement, so v10 rejects it before rendering. -export const PROTOCOL = 10; +// v11: the chart-level `coords: "polar"` key and the angular axis descriptors +// (`theta_unit`, `theta_zero`, `theta_direction`). A v10 client ignores +// `coords` and draws the (theta, r) columns as cartesian x/y, so it must +// reject the payload rather than render a plausible wrong picture. +// v12: polar angular axes carry `sector`/`grid_shape`, and radial axes carry +// `hole`/`r_origin`. A v11 client would accept those fields but silently draw +// full circular, centre-origin geometry. +export const PROTOCOL = 12; + +// Every GL buffer field a built trace — or a drill / sample-overlay clone of +// one — can own. Teardown reads this list instead of a hand-kept subset: the +// previous subset covered geometry only, so every rebuilt trace (a state-driven +// data update, an append that cannot patch in place, an animated spec swap) +// orphaned its style, direct-rgba colour, stroke, corner-radius, LOD-blend and +// dashed line-length buffers, and repeated updates walked the GPU out of +// memory. It lives here, in the leaf module, because both the ChartView +// teardown and the LOD drill teardown must delete exactly the same set and +// 45_lod cannot import from 50_chartview. `test_trace_teardown_deletes_every_gpu_buffer` +// pins that every `Buf` field assigned anywhere in js/src appears here, +// so adding a channel buffer cannot silently reintroduce the leak. +export const TRACE_GPU_BUFFERS = [ + "xBuf", "yBuf", "cBuf", "sBuf", "selBuf", "baseBuf", + "x0Buf", "x1Buf", "x2Buf", "y0Buf", "y1Buf", "y2Buf", + "posBuf", "value1Buf", "value0Buf", + "rgbaBuf", "styleBuf", "strokeBuf", "radiusBuf", "dBuf", + "_lenBuf", "_segmentDashOffsetBuf", "_segmentDashDirBuf", + "_transitionPrevXBuf", "_transitionPrevYBuf", + "_transitionPrevPosBuf", "_transitionPrevValue1Buf", "_transitionPrevValue0Buf", +]; // HTTP binary frame v1 (spec/design/wire-protocol.md §7; Python side in // python/xy/_framing.py). The chart spec's PROTOCOL diff --git a/js/src/30_ticks.ts b/js/src/30_ticks.ts index 947dddb8..b1809b8e 100644 --- a/js/src/30_ticks.ts +++ b/js/src/30_ticks.ts @@ -112,6 +112,55 @@ function calendarTicks(lo, hi, rough) { return { ticks: out, step: stepM * 30 * MS.d }; } +// Angular tick ladders. niceStep's [1, 2, 2.5, 5, 10] cannot reach 15, 30, 45 +// or 90, so feeding it degrees gives 0/50/100/150 — a grid nobody reads angles +// on. Fixed ladders instead, like TIME_STEPS. +// Mirrored by _DEGREE_STEPS/_RADIAN_STEPS in python/xy/_svg.py. +const DEGREE_STEPS = [1, 2, 5, 10, 15, 30, 45, 60, 90, 120, 180, 360]; +const RADIAN_STEPS = [1 / 12, 1 / 8, 1 / 6, 1 / 4, 1 / 3, 1 / 2, 2 / 3, 1, 2].map((f) => Math.PI * f); + +export function angularTicks(lo, hi, unit, target = 6) { + const a = Math.min(lo, hi); + const b = Math.max(lo, hi); + if (!Number.isFinite(a) || !Number.isFinite(b)) return { ticks: [], step: 1 }; + if (a === b) return { ticks: [a], step: 1 }; + const ladder = unit === "degrees" ? DEGREE_STEPS : RADIAN_STEPS; + const rough = (b - a) / Math.max(1, target); + const step = ladder.find((s) => s >= rough * (1 - 1e-12)) ?? ladder[ladder.length - 1]; + const out = []; + for (let v = Math.ceil(a / step) * step; v <= b + step * 1e-9 && out.length < 200; v += step) { + out.push(Math.abs(v) < step * 1e-9 ? 0 : v); + } + // A full turn lands a tick on both ends of the seam; they are one spoke, so + // the duplicate is dropped rather than overdrawn. + const turn = unit === "degrees" ? 360 : 2 * Math.PI; + if (out.length > 1 && Math.abs(out[out.length - 1] - out[0] - turn) < step * 1e-9) out.pop(); + return { ticks: out, step }; +} + +// Mirrored by _fmt_angle in python/xy/_svg.py. `step` sets the degree +// precision: the generated ladder is all integers, but authored fractional +// tick_values (a 22.5-degree compass grid) mislabel under a hardcoded 1. +export function fmtAngle(v, unit, step = 1) { + if (unit === "degrees") return `${fmtLinear(v, step || 1)}\u00b0`; + if (Math.abs(v) < 1e-12) return "0"; + const frac = v / Math.PI; + for (const den of [1, 2, 3, 4, 6, 8, 12]) { + const scaled = frac * den; + const nearest = Math.round(scaled); + // 1e-6, not 1e-9: hover values arrive f32-decoded (§4/§16), so pi/2 + // lands ~2e-8 off its f64 self and a 1e-9 gate showed "1.57" in the + // tooltip while the tick at the same spoke said "pi/2". No real tick sits + // within 1e-6 of a pi-fraction without being one. + if (nearest && Math.abs(scaled - nearest) < 1e-6) { + const num = Math.abs(nearest) === 1 ? "" : String(Math.abs(nearest)); + const body = `${nearest < 0 ? "-" : ""}${num}\u03c0`; + return den === 1 ? body : `${body}/${den}`; + } + } + return fmtLinear(v, 0.01); +} + function fmtTime(ms, step) { const d = new Date(ms); const pad = (n, w = 2) => String(n).padStart(w, "0"); @@ -234,7 +283,19 @@ function collapsedToZero(formatted) { } export function fmtAxis(axis, v, tickStep) { + // A categorical theta axis still carries theta_unit for its geometric + // transform. Category labels own the display text, so they must win over + // angular numeric formatting here. if (axis && axis.kind === "category") return fmtCategory(v, axis.categories || []); + // An authored `format` wins over the angular default. It used to lose: the + // angular branch ran first, so `theta_axis(format=".0f°")` shipped, was + // accepted, and was then overwritten by the built-in degree/radian text in + // every renderer. The default only applies when nothing was authored. + // Mirrored by the same branch in `_fmt_axis` (python/xy/_svg.py). + if (axis && axis.theta_unit) { + const authored = fmtNumberSpec(v, axis.format); + return authored || fmtAngle(v, axis.theta_unit, tickStep); + } if (axis && axis.kind === "time") return fmtTimeSpec(v, axis.format) || fmtTime(v, tickStep); const formatted = fmtNumberSpec(v, axis && axis.format); if (axis && axis.scale === "log" && Number(v) > 0 && Number(v) < 1) { diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index b048cf43..6ca15d22 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -102,8 +102,184 @@ float xyViewValue(float coord, int mode, float constant) { if (mode == 2) return sign(coord) * constant * (exp(abs(coord)) - 1.0); return coord; } +// Polar placement (spec/design/polar-axes.md §3). Replaces only the final +// affine step: theta and r arrive already decoded and scale-mapped by +// xyAxisCoord, exactly as the cartesian path leaves them. +// +// pol = (cx, cy, rx, ry) in CLIP space; rx/ry differ because clip space is +// square while the plot rect is not, and a round circle needs 2R/w +// horizontally against 2R/h vertically. rr = (r_lo, r_hi) in scaled coord +// space — radial zoom is a change to this uniform alone, which is why the +// transform lives here rather than being pre-projected kernel-side. +// zdir = (zero angle in radians, direction * unit-scale). +// +// The y term ADDS: clip space grows upward. The Python twin +// (_svg._PolarProjection) subtracts, because screen space grows downward. +float xyPositiveMod(float value, float period) { + return mod(mod(value, period) + period, period); +} +float xyPolarThetaValue(float angle, vec2 zdir, vec2 trange, float turn) { + float raw = (angle - zdir.x) / (abs(zdir.y) > 1e-30 ? zdir.y : 1.0); + return trange.x + xyPositiveMod(raw - trange.x, max(turn, 1e-30)); +} +bool xyPolarThetaVisible(float thC, vec2 trange, float turn) { + float period = max(turn, 1e-30); + float sweep = trange.y - trange.x; + if (sweep >= period * (1.0 - 1e-6)) return true; + float offset = xyPositiveMod(thC - trange.x, period); + return offset <= sweep + period * 1e-6; +} +vec2 xyPolarPos(float thC, float rC, vec4 pol, vec2 rr, vec2 zdir, + vec2 trange, float turn, vec2 rshape) { + float rmin = min(rr.x, rr.y); + float rmax = max(rr.x, rr.y); + if (!xyPolarThetaVisible(thC, trange, turn) + || rC < rmin - 1e-6 || rC > rmax + 1e-6) { + return vec2(uintBitsToFloat(0x7fc00000u)); + } + // rshape = (radial origin in scale coordinates, display-space hole). + // The formula is shared with the CPU/export paths. The visible lower bound + // remains rr.x; an origin below it creates an annulus. + float denom = rr.y - rshape.x; + if (abs(denom) <= 1e-30) return vec2(uintBitsToFloat(0x7fc00000u)); + float base = (rC - rshape.x) / denom; + float rn = rshape.y + (1.0 - rshape.y) * base; + // Outside the radial range there is no honest position: rn < 0 would reflect + // the mark through the centre, and rn > 1 would draw it past the outer ring + // into the rect corners the disc does not cover — the GL canvas is the plot + // RECT, so nothing else clips it (the SVG exporter has a disc clipPath; this + // is the client's equivalent). NaN culls the primitive instead: the same gap + // semantics NaN data gets (§3 D7), and the same NaN idiom xyAxisCoord's mode + // 3 already uses. The epsilon keeps the outermost home-view point, which sits + // exactly at rn == 1. + if (rn < rshape.y - 1e-6 || rn > 1.0 + 1e-6) { + return vec2(uintBitsToFloat(0x7fc00000u)); + } + float a = zdir.x + zdir.y * thC; + return vec2(pol.x + rn * pol.z * cos(a), pol.y + rn * pol.w * sin(a)); +} `; +// Uniform block every polar-capable vertex shader declares. u_coordMode is 0 +// for cartesian and 1 for polar; the branch is uniform across every vertex in +// a draw, so it costs no divergence. +// The cartesian/polar dispatch, for the shaders whose two coordinate columns +// are plain x/y (points, pick, line). AREA_VS and BAR_VS deliberately do NOT +// use it: they interpolate in data space before projecting, which is what keeps +// a fill's radial edges true radii and a bar's span an arc. +export const POLAR_XYPOS_GLSL = ` +vec2 xyPos(float xe, float ye) { + if (u_coordMode == 1) { + return xyPolarPos(xyAxisCoord(xe, u_xmeta, u_xmode, u_xconstant), + xyAxisCoord(ye, u_ymeta, u_ymode, u_yconstant), + u_polar, u_rrange, u_zdir, + u_trange, u_turn, u_rshape); + } + return vec2(xyMap(xe, u_xmap, u_xmeta, u_xmode, u_xconstant), + xyMap(ye, u_ymap, u_ymeta, u_ymode, u_yconstant)); +}`; + +export const POLAR_GLSL_UNIFORMS = ` +uniform int u_coordMode; uniform vec4 u_polar; uniform vec2 u_rrange; uniform vec2 u_zdir; +uniform vec2 u_trange; uniform float u_turn; uniform vec2 u_rshape;`; + +// Vertex clipping can reject a point whose centre is outside the polar view, +// but it cannot clip the pixels of a wide point or the interior of a chord, +// triangle, or strip. Every legal polar mark therefore applies the same +// annular-sector test in its fragment stage. u_polar is expressed in clip +// space, while gl_FragCoord is in device pixels, so u_clipRes bridges the two +// without an interpolated varying (and works for both the color and pick +// framebuffers). +const POLAR_FRAGMENT_CLIP_GLSL = ` +uniform int u_coordMode; +uniform vec4 u_polar; +uniform vec2 u_rrange; +uniform vec2 u_zdir; +uniform vec2 u_trange; +uniform float u_turn; +uniform vec2 u_rshape; +uniform vec2 u_clipRes; +float xyClipPositiveMod(float value, float period) { + return mod(mod(value, period) + period, period); +} +bool xyPolarFragmentVisible() { + if (u_coordMode != 1) return true; + vec2 clip = gl_FragCoord.xy / max(u_clipRes, vec2(1.0)) * 2.0 - 1.0; + vec2 local = (clip - u_polar.xy) / max(abs(u_polar.zw), vec2(1e-30)); + float displayedRadius = length(local); + if (displayedRadius > 1.0 + 1e-6 + || displayedRadius < u_rshape.y - 1e-6) return false; + + // Convert the displayed radius back into radial scale coordinates. This + // also exposes the implicit inner hole created when r_origin lies below the + // visible radial minimum; testing only u_rshape.y would miss that annulus. + float radialFraction = (displayedRadius - u_rshape.y) + / max(1.0 - u_rshape.y, 1e-30); + float rCoord = u_rshape.x + radialFraction * (u_rrange.y - u_rshape.x); + float rmin = min(u_rrange.x, u_rrange.y); + float rmax = max(u_rrange.x, u_rrange.y); + if (rCoord < rmin - 1e-6 || rCoord > rmax + 1e-6) return false; + + float period = max(u_turn, 1e-30); + float rawTheta = (atan(local.y, local.x) - u_zdir.x) + / (abs(u_zdir.y) > 1e-30 ? u_zdir.y : 1.0); + float theta = u_trange.x + + xyClipPositiveMod(rawTheta - u_trange.x, period); + float sweep = u_trange.y - u_trange.x; + if (sweep >= period * (1.0 - 1e-6)) return true; + return xyClipPositiveMod(theta - u_trange.x, period) + <= sweep + period * 1e-6; +} +void xyClipPolarFragment() { + if (!xyPolarFragmentVisible()) discard; +}`; + +// Annular-sector vertex placement for BAR_VS/RECT_VS, with edge antialiasing. +// The GL context is created with antialias: false, so every smooth edge in the +// client is fragment-shader coverage — and a wedge disabled the rect SDF +// (v_half = 1e6), which left all four of its edges hard-aliased. The strip is +// expanded XY_POLAR_AA px outward here and RECT_FS trims it back against the +// TRUE radii/angles (v_polarRadii/v_polarAngles, device px / screen radians): +// the fringe gets room to ramp, and because the expanded chords stay outside +// the true outer arc, the trimmed arc is exactly round rather than faceted. +// +// Positions are xyPolarPos in pixel form — same centre, radius and angle from +// the same uniforms — computed directly because xyPolarPos's rn > 1 cull +// would eat the expanded outer vertices. +export const POLAR_WEDGE_GLSL = ` +const float XY_POLAR_AA = 2.0; +flat out vec2 v_polarRadii; flat out vec2 v_polarAngles; +vec4 xyPolarWedge(float th0, float th1, float r0C, float r1C, float t, float side) { + float radiusPx = u_polar.z * u_res.x * 0.5; + vec2 centrePx = (u_polar.xy * 0.5 + 0.5) * u_res; + // Match xyPolarPos exactly: r_origin may sit below the visible minimum, + // creating an implicit annulus, while hole reserves an explicit inner ring. + float denom = u_rrange.y - u_rshape.x; + if (abs(denom) <= 1e-30) return vec4(uintBitsToFloat(0x7fc00000u)); + float rBase = ( + u_rshape.y + (1.0 - u_rshape.y) * ((r0C - u_rshape.x) / denom) + ) * radiusPx; + float rTop = ( + u_rshape.y + (1.0 - u_rshape.y) * ((r1C - u_rshape.x) / denom) + ) * radiusPx; + float a0 = u_zdir.x + u_zdir.y * th0; + float a1 = u_zdir.x + u_zdir.y * th1; + v_polarRadii = vec2(min(rBase, rTop), max(rBase, rTop)); + v_polarAngles = vec2(a0, a1); + // A span collapsed by the radial clamp, or a zero angular width, draws + // nothing — without this cull the AA expansion would leave a ghost sliver. + if (rBase == rTop || a0 == a1) return vec4(uintBitsToFloat(0x7fc00000u)); + float outward = sign(rTop - rBase); + float rE = max(side == 0.0 ? rBase - outward * XY_POLAR_AA : rTop + outward * XY_POLAR_AA, 0.0); + // No angular expansion at a full turn: the two ends are one seam, and + // growing past it double-covers translucent fills. + float grow = abs(a1 - a0) >= 6.2831853 ? 0.0 : XY_POLAR_AA / max(v_polarRadii.y, 1.0); + float dir = a1 >= a0 ? 1.0 : -1.0; + float aE = mix(a0 - dir * grow, a1 + dir * grow, t); + vec2 pix = centrePx + rE * vec2(cos(aE), sin(aE)); + return vec4(pix / u_res * 2.0 - 1.0, 0.0, 1.0); +}`; + export const POINT_VS = `#version 300 es in float ax; in float ay; in float a_prevx; in float a_prevy; in float a_cval; in float a_sval; in float a_sel; in float a_dval; @@ -117,10 +293,13 @@ uniform float u_transitionProgress; uniform int u_transitionActive; out float v_lutCoord; out float v_dim; out float v_dval; out float v_ptSize; out float v_sel; out vec4 v_rgba; out vec4 v_style; out vec4 v_stroke; ${AXIS_GLSL} +${POLAR_GLSL_UNIFORMS} +${POLAR_XYPOS_GLSL} + void main() { float x = u_transitionActive == 1 ? mix(a_prevx, ax, u_transitionProgress) : ax; float y = u_transitionActive == 1 ? mix(a_prevy, ay, u_transitionProgress) : ay; - gl_Position = vec4(xyMap(x, u_xmap, u_xmeta, u_xmode, u_xconstant), xyMap(y, u_ymap, u_ymeta, u_ymode, u_yconstant), 0.0, 1.0); + gl_Position = vec4(xyPos(x, y), 0.0, 1.0); float sz = u_sizeMode == 1 ? mix(u_sizeRange.x, u_sizeRange.y, a_sval) : u_size; int symbol = a_style.w >= 0.0 ? int(a_style.w + 0.5) : u_symbol; float symbolScale = symbol == 2 || symbol == 14 ? 1.414213562 : 1.0; @@ -231,7 +410,9 @@ in float v_lutCoord; in float v_dim; in float v_dval; in float v_ptSize; in floa in vec4 v_rgba; in vec4 v_style; in vec4 v_stroke; out vec4 outColor; ${MARKER_SDF_GLSL} +${POLAR_FRAGMENT_CLIP_GLSL} void main() { + xyClipPolarFragment(); vec2 d = gl_PointCoord - 0.5; float sd; int symbol = v_style.w >= 0.0 ? int(v_style.w + 0.5) : u_symbol; @@ -315,18 +496,23 @@ uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform float u uniform float u_size; uniform float u_dpr; uniform float u_transitionProgress; uniform int u_transitionActive; ${AXIS_GLSL} +${POLAR_GLSL_UNIFORMS} +${POLAR_XYPOS_GLSL} + void main() { float x = u_transitionActive == 1 ? mix(a_prevx, ax, u_transitionProgress) : ax; float y = u_transitionActive == 1 ? mix(a_prevy, ay, u_transitionProgress) : ay; - gl_Position = vec4(xyMap(x, u_xmap, u_xmeta, u_xmode, u_xconstant), xyMap(y, u_ymap, u_ymeta, u_ymode, u_yconstant), 0.0, 1.0); + gl_Position = vec4(xyPos(x, y), 0.0, 1.0); gl_PointSize = u_size * u_dpr; }`; export const POINT_SIMPLE_FS = `#version 300 es -precision highp float; +precision highp float; precision highp int; uniform vec4 u_color; out vec4 outColor; +${POLAR_FRAGMENT_CLIP_GLSL} void main() { + xyClipPolarFragment(); float sd = length(gl_PointCoord - 0.5) - 0.5; float aa = fwidth(sd) + 1e-4; float coverage = clamp(0.5 - sd / aa, 0.0, 1.0); @@ -353,10 +539,13 @@ uniform float u_size; uniform int u_sizeMode; uniform vec2 u_sizeRange; uniform uniform float u_transitionProgress; uniform int u_transitionActive; flat out int v_id; ${AXIS_GLSL} +${POLAR_GLSL_UNIFORMS} +${POLAR_XYPOS_GLSL} + void main() { float x = u_transitionActive == 1 ? mix(a_prevx, ax, u_transitionProgress) : ax; float y = u_transitionActive == 1 ? mix(a_prevy, ay, u_transitionProgress) : ay; - gl_Position = vec4(xyMap(x, u_xmap, u_xmeta, u_xmode, u_xconstant), xyMap(y, u_ymap, u_ymeta, u_ymode, u_yconstant), 0.0, 1.0); + gl_Position = vec4(xyPos(x, y), 0.0, 1.0); float sz = u_sizeMode == 1 ? mix(u_sizeRange.x, u_sizeRange.y, a_sval) : u_size; gl_PointSize = max(sz, 6.0) * u_dpr; // enlarge hit target v_id = gl_VertexID; @@ -367,7 +556,9 @@ precision highp float; precision highp int; uniform int u_pick_base; flat in int v_id; out vec4 outColor; +${POLAR_FRAGMENT_CLIP_GLSL} void main() { + xyClipPolarFragment(); vec2 d = gl_PointCoord - 0.5; if (length(d) > 0.5) discard; int id = u_pick_base + v_id; @@ -392,10 +583,11 @@ export const GRID_VS = `#version 300 es in vec2 a_corner; uniform vec4 u_view; // x0,x1,y0,y1 uniform int u_xmode; uniform float u_xconstant; uniform int u_ymode; uniform float u_yconstant; -out vec2 v_coord; +out vec2 v_coord; out vec2 v_clip; ${AXIS_GLSL} void main() { gl_Position = vec4(a_corner * 2.0 - 1.0, 0.0, 1.0); + v_clip = gl_Position.xy; float x = mix(xyViewCoord(u_view.x, u_xmode, u_xconstant), xyViewCoord(u_view.y, u_xmode, u_xconstant), a_corner.x); float y = mix(xyViewCoord(u_view.z, u_ymode, u_yconstant), xyViewCoord(u_view.w, u_ymode, u_yconstant), a_corner.y); v_coord = vec2(x, y); @@ -457,11 +649,39 @@ uniform vec4 u_gridRange; // gx0,gx1,gy0,gy1 (raw data units) uniform int u_xmode; uniform float u_xconstant; uniform int u_ymode; uniform float u_yconstant; uniform float u_opacity; uniform int u_truecolor; -in vec2 v_coord; +in vec2 v_coord; in vec2 v_clip; out vec4 outColor; ${AXIS_GLSL} +${POLAR_GLSL_UNIFORMS} void main() { - vec2 data = vec2(xyViewValue(v_coord.x, u_xmode, u_xconstant), xyViewValue(v_coord.y, u_ymode, u_yconstant)); + vec2 data; + if (u_coordMode == 1) { + vec2 local = (v_clip - u_polar.xy) / max(abs(u_polar.zw), vec2(1e-30)); + float displayedRadius = length(local); + if (displayedRadius > 1.0 + 1e-6 + || displayedRadius < u_rshape.y - 1e-6) discard; + float radialFraction = (displayedRadius - u_rshape.y) + / max(1.0 - u_rshape.y, 1e-30); + float rCoord = u_rshape.x + radialFraction * (u_rrange.y - u_rshape.x); + float rmin = min(u_rrange.x, u_rrange.y); + float rmax = max(u_rrange.x, u_rrange.y); + if (rCoord < rmin - 1e-6 || rCoord > rmax + 1e-6) discard; + float thCoord = xyPolarThetaValue( + atan(local.y, local.x), u_zdir, u_trange, u_turn); + if (!xyPolarThetaVisible(thCoord, u_trange, u_turn)) discard; + // Grid edges commonly straddle the angular seam + // ([-halfCell, turn-halfCell]). Choose the equivalent theta in the + // heatmap's own range, not blindly [0, turn), before locating the cell. + thCoord = u_gridRange.x + + xyPositiveMod(thCoord - u_gridRange.x, max(u_turn, 1e-30)); + data = vec2( + xyViewValue(thCoord, u_xmode, u_xconstant), + xyViewValue(rCoord, u_ymode, u_yconstant)); + } else { + data = vec2( + xyViewValue(v_coord.x, u_xmode, u_xconstant), + xyViewValue(v_coord.y, u_ymode, u_yconstant)); + } vec2 uv = vec2((data.x - u_gridRange.x) / (u_gridRange.y - u_gridRange.x), (data.y - u_gridRange.z) / (u_gridRange.w - u_gridRange.z)); if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) discard; @@ -496,13 +716,19 @@ in float a_len0; in float a_len1; out float v_off; out float v_dash; out vec2 v_cap; const vec2 corners[4] = vec2[4](vec2(0.,-1.), vec2(0.,1.), vec2(1.,-1.), vec2(1.,1.)); ${AXIS_GLSL} +${POLAR_GLSL_UNIFORMS} +${POLAR_XYPOS_GLSL} + void main() { float px0 = u_transitionActive == 1 ? mix(a_prevx, ax0, u_transitionProgress) : ax0; float py0 = u_transitionActive == 1 ? mix(a_prevy, ay0, u_transitionProgress) : ay0; float px1 = u_transitionActive == 1 ? mix(a_prevx1, ax1, u_transitionProgress) : ax1; float py1 = u_transitionActive == 1 ? mix(a_prevy1, ay1, u_transitionProgress) : ay1; - vec2 p0 = vec2(xyMap(px0, u_xmap, u_xmeta, u_xmode, u_xconstant), xyMap(py0, u_ymap, u_ymeta, u_ymode, u_yconstant)); - vec2 p1 = vec2(xyMap(px1, u_xmap, u_xmeta, u_xmode, u_xconstant), xyMap(py1, u_ymap, u_ymeta, u_ymode, u_yconstant)); + // Endpoints project through the coordinate map; the pixel-space expansion + // below then joins them with a straight CHORD, which is the polar line + // semantics radar/spider edges require (polar-axes.md §5). + vec2 p0 = xyPos(px0, py0); + vec2 p1 = xyPos(px1, py1); float reveal = clamp(u_revealProgress * u_revealSegments - float(gl_InstanceID), 0.0, 1.0); p1 = mix(p0, p1, reveal); vec2 pix0 = (p0 * 0.5 + 0.5) * u_res; @@ -542,7 +768,9 @@ uniform vec4 u_color; uniform float u_width; uniform int u_cap; uniform int u_dashCount; uniform float u_dashArr[8]; uniform float u_dashPeriod; in float v_off; in float v_dash; in vec2 v_cap; out vec4 outColor; +${POLAR_FRAGMENT_CLIP_GLSL} void main() { + xyClipPolarFragment(); float half_w = u_width * 0.5; // How far past the nearest end of painted stroke this fragment lies, along // the path. Two kinds of end contribute and the cap shapes both: the @@ -596,9 +824,46 @@ uniform int u_x0mode; uniform float u_x0constant; uniform int u_x1mode; uniform out float v_off; out float v_cval; out float v_dash; out vec4 v_rgba; out vec4 v_style; const vec2 corners[4] = vec2[4](vec2(0.,-1.), vec2(0.,1.), vec2(1.,-1.), vec2(1.,1.)); ${AXIS_GLSL} +${POLAR_GLSL_UNIFORMS} void main() { - vec2 p0 = vec2(xyMap(ax0, u_xmap, u_x0meta, u_x0mode, u_x0constant), xyMap(ay0, u_ymap, u_y0meta, u_y0mode, u_y0constant)); - vec2 p1 = vec2(xyMap(ax1, u_xmap, u_x1meta, u_x1mode, u_x1constant), xyMap(ay1, u_ymap, u_y1meta, u_y1mode, u_y1constant)); + vec2 p0; + vec2 p1; + if (u_coordMode == 1) { + float th0 = xyAxisCoord(ax0, u_x0meta, u_x0mode, u_x0constant); + float th1 = xyAxisCoord(ax1, u_x1meta, u_x1mode, u_x1constant); + float r0 = xyAxisCoord(ay0, u_y0meta, u_y0mode, u_y0constant); + float r1 = xyAxisCoord(ay1, u_y1meta, u_y1mode, u_y1constant); + float rmin = min(u_rrange.x, u_rrange.y); + float rmax = max(u_rrange.x, u_rrange.y); + if (max(r0, r1) < rmin || min(r0, r1) > rmax) { + p0 = vec2(uintBitsToFloat(0x7fc00000u)); + p1 = p0; + } else { + // Independent contour/error-bar segments clip at the radial window. + // Interpolating theta at the clipped endpoint keeps a diagonal segment + // attached to the ring instead of bending it onto a radial clamp. + float dr = r1 - r0; + float t0 = 0.0; + float t1 = 1.0; + if (abs(dr) > 1e-30) { + float ta = (rmin - r0) / dr; + float tb = (rmax - r0) / dr; + t0 = max(0.0, min(ta, tb)); + t1 = min(1.0, max(ta, tb)); + } + float cth0 = mix(th0, th1, t0); + float cth1 = mix(th0, th1, t1); + float cr0 = clamp(mix(r0, r1, t0), rmin, rmax); + float cr1 = clamp(mix(r0, r1, t1), rmin, rmax); + p0 = xyPolarPos(cth0, cr0, u_polar, u_rrange, u_zdir, + u_trange, u_turn, u_rshape); + p1 = xyPolarPos(cth1, cr1, u_polar, u_rrange, u_zdir, + u_trange, u_turn, u_rshape); + } + } else { + p0 = vec2(xyMap(ax0, u_xmap, u_x0meta, u_x0mode, u_x0constant), xyMap(ay0, u_ymap, u_y0meta, u_y0mode, u_y0constant)); + p1 = vec2(xyMap(ax1, u_xmap, u_x1meta, u_x1mode, u_x1constant), xyMap(ay1, u_ymap, u_y1meta, u_y1mode, u_y1constant)); + } vec2 center = (p0 + p1) * 0.5; p0 = mix(center, p0, u_animationProgress); p1 = mix(center, p1, u_animationProgress); @@ -625,7 +890,9 @@ uniform vec4 u_color; uniform float u_width; uniform int u_colorMode; uniform sa uniform int u_dashCount; uniform float u_dashArr[8]; uniform float u_dashPeriod; in float v_off; in float v_cval; in float v_dash; in vec4 v_rgba; in vec4 v_style; out vec4 outColor; +${POLAR_FRAGMENT_CLIP_GLSL} void main() { + xyClipPolarFragment(); float itemWidth = v_style.z >= 0.0 ? v_style.z : u_width; float half_w = itemWidth * 0.5; vec4 paint = u_colorMode == 3 ? v_rgba : (u_colorMode != 0 ? vec4(texture(u_lut, vec2(clamp(v_cval, 0.0, 1.0), 0.5)).rgb, 1.0) : u_color); @@ -744,6 +1011,7 @@ uniform float u_revealProgress; uniform float u_revealSegments; out float v_top; out float v_base; out float v_pos; const vec2 corners[4] = vec2[4](vec2(0.,0.), vec2(1.,0.), vec2(0.,1.), vec2(1.,1.)); ${AXIS_GLSL} +${POLAR_GLSL_UNIFORMS} void main() { vec2 c = corners[gl_VertexID]; float x0 = xyMap(ax0, u_xmap, u_xmeta, u_xmode, u_xconstant); @@ -756,6 +1024,37 @@ void main() { x1 = mix(x0, x1, reveal); y1 = mix(y0, y1, reveal); b1 = mix(b0, b1, reveal); + if (u_coordMode == 1) { + // Polar interpolates in DATA space and projects the result, rather than + // interpolating already-projected clip coordinates: the quad's two radial + // edges must run along true radii. The outer and inner edges come out as + // chords between projected corners, which is the fill semantics radar + // polygons require (polar-axes.md §5). + float th = mix(xyAxisCoord(ax0, u_xmeta, u_xmode, u_xconstant), + xyAxisCoord(ax1, u_xmeta, u_xmode, u_xconstant), c.x); + float topR = mix(xyAxisCoord(ay0, u_ymeta, u_ymode, u_yconstant), + xyAxisCoord(ay1, u_ymeta, u_ymode, u_yconstant), c.x); + float baseR = mix(xyAxisCoord(ab0, u_bmeta, u_ymode, u_yconstant), + xyAxisCoord(ab1, u_bmeta, u_ymode, u_yconstant), c.x); + // CLAMP the span to the visible annulus rather than letting xyPolarPos + // NaN-cull an out-of-range corner: the fill at a given theta is exactly + // [base, top] intersected with [r_lo, r_hi], and culling instead made the + // whole radar fill vanish the moment radial zoom lifted r_lo above the + // base. A span fully outside collapses to zero height and draws nothing. + float rmin = min(u_rrange.x, u_rrange.y); + float rmax = max(u_rrange.x, u_rrange.y); + topR = clamp(topR, rmin, rmax); + baseR = clamp(baseR, rmin, rmax); + float rr = mix(baseR, topR, c.y); + // The fragment stage divides these for a height fraction, so any space + // affine in the fill direction works — radius is that space here. + v_top = topR; + v_base = baseR; + v_pos = rr; + gl_Position = vec4(xyPolarPos(th, rr, u_polar, u_rrange, u_zdir, + u_trange, u_turn, u_rshape), 0.0, 1.0); + return; + } float top = mix(y0, y1, c.x); float base = mix(b0, b1, c.x); float clipY = mix(base, top, c.y); @@ -777,7 +1076,9 @@ uniform vec2 u_res; in float v_top; in float v_base; in float v_pos; out vec4 outColor; ${GRAD_GLSL} +${POLAR_FRAGMENT_CLIP_GLSL} void main() { + xyClipPolarFragment(); vec4 premult = vec4(u_color.rgb * u_color.a, u_color.a); if (u_gradMode != 0) { // 0 at the baseline, 1 exactly at the curve — even at the curve everywhere. @@ -807,6 +1108,9 @@ out vec2 v_local; out vec2 v_half; out float v_t; out vec4 v_rgba; out vec4 v_style; out vec4 v_stroke; out vec2 v_radius; const vec2 corners[4] = vec2[4](vec2(0.,0.), vec2(1.,0.), vec2(0.,1.), vec2(1.,1.)); ${AXIS_GLSL} +${POLAR_GLSL_UNIFORMS} +${POLAR_WEDGE_GLSL} +uniform int u_polarSegments; void main() { vec2 c = corners[gl_VertexID]; float x0 = xyMap(ax0, u_x0map, u_x0meta, u_xmode, u_xconstant) + u_edgePad.x; @@ -814,6 +1118,35 @@ void main() { float y0 = xyMap(ay0, u_y0map, u_y0meta, u_ymode, u_yconstant) + u_edgePad.z; float y1 = xyMap(ay1, u_y1map, u_y1meta, u_ymode, u_yconstant) + u_edgePad.w; v_lutCoord = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; + if (u_coordMode == 1) { + // Four edge columns ARE an annular sector under polar: (x0, x1) is the + // angular span and (y0, y1) the radial one. Same triangle-strip sweep as + // BAR_VS, which is what lets a slice carry its OWN angular width — the + // compact bar path only ships one scalar width, so unequal slices (a pie + // or donut) route here. + float th0 = xyAxisCoord(ax0, u_x0meta, u_xmode, u_xconstant); + float th1 = xyAxisCoord(ax1, u_x1meta, u_xmode, u_xconstant); + // Clamp, do not cull: a sector's visible extent is its span intersected + // with the radial range (polar-axes.md §8). + float rmin = min(u_rrange.x, u_rrange.y); + float rmax = max(u_rrange.x, u_rrange.y); + float r0C = clamp(xyAxisCoord(ay0, u_y0meta, u_ymode, u_yconstant), rmin, rmax); + float r1C = clamp(xyAxisCoord(ay1, u_y1meta, u_ymode, u_yconstant), rmin, rmax); + int pair = gl_VertexID >> 1; + float t = float(pair) / float(max(u_polarSegments, 1)); + float side = float(gl_VertexID & 1); + gl_Position = xyPolarWedge(th0, th1, r0C, r1C, t, side); + v_t = side; + // The rectangle SDF is inert here (v_half huge => "deep inside"); RECT_FS + // runs the annular-sector SDF instead, which handles coverage, the stroke + // and corner_radius in the unrolled (arc, radial) frame. The radius rides + // through unchanged so a rounded slice is rounded in the browser too. + v_half = vec2(1e6); + v_local = vec2(0.0); + v_radius = a_radius; + v_rgba = a_rgba; v_style = a_style; v_stroke = a_stroke; + return; + } // Pixel-space local frame for the rounded-corner/stroke SDF (v_half is // constant across the quad; v_local interpolates to the fragment offset). vec2 pA = (vec2(x0, y0) * 0.5 + 0.5) * u_res; @@ -847,6 +1180,9 @@ out vec2 v_local; out vec2 v_half; out float v_t; out vec4 v_rgba; out vec4 v_style; out vec4 v_stroke; out vec2 v_radius; const vec2 corners[4] = vec2[4](vec2(0.,0.), vec2(1.,0.), vec2(0.,1.), vec2(1.,1.)); ${AXIS_GLSL} +${POLAR_GLSL_UNIFORMS} +${POLAR_WEDGE_GLSL} +uniform int u_polarSegments; uniform float u_polarV0C; void main() { vec2 c = corners[gl_VertexID]; float nextP = xyMap(a_pos, u_pmap, u_pmeta, u_pmode, u_pconstant); @@ -868,6 +1204,47 @@ void main() { v1 = mix(v0, v1, u_animationProgress); float halfW = abs(width * u_pmap.x) * 0.5; v_lutCoord = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; + if (u_coordMode == 1) { + // A polar bar is an annular sector, which four corners cannot express. The + // instance is drawn as a triangle strip of u_polarSegments+1 vertex PAIRS + // sweeping theta0..theta1, so both radial edges are true radii and the two + // arcs are subdivided rather than chorded (polar-axes.md §5). + // + // Everything here is data space: the clip-space p/v0/v1 above are the + // cartesian path's, and a polar bar needs its angle and radius before the + // affine map, not after. + float thC = xyAxisCoord(a_pos, u_pmeta, u_pmode, u_pconstant); + // Constant baselines arrive in scaled data space via u_polarV0C — the + // cartesian u_v0Const is already clip-space and useless here. Baselines + // below the radial minimum clamp to the centre, matching the exporters' + // max(0, inner) clamp, instead of reflecting through it. + // CLAMP both radii to the visible annulus: a bar crossing the zoomed outer + // ring draws up to the ring (matplotlib/Plotly clip semantics); relying on + // xyPolarPos's NaN cull instead vanished the whole wedge the moment its + // tip left the range. A bar fully outside collapses to zero span. + float rmin = min(u_rrange.x, u_rrange.y); + float rmax = max(u_rrange.x, u_rrange.y); + float r0C = clamp( + u_v0Mode == 0 ? u_polarV0C : xyAxisCoord(a_v0, u_v0meta, u_vmode, u_vconstant), + rmin, rmax); + float r1C = clamp( + mix(r0C, xyAxisCoord(a_v1, u_v1meta, u_vmode, u_vconstant), u_animationProgress), + rmin, rmax); + float hw = abs(width) * 0.5; + int pair = gl_VertexID >> 1; + float t = float(pair) / float(max(u_polarSegments, 1)); + float side = float(gl_VertexID & 1); + gl_Position = xyPolarWedge(thC - hw, thC + hw, r0C, r1C, t, side); + v_t = side; + // The rectangle SDF is inert here (v_half huge => "deep inside"); RECT_FS + // runs the annular-sector SDF instead, which handles coverage, the stroke + // and corner_radius in the unrolled (arc, radial) frame. + v_half = vec2(1e6); + v_local = vec2(0.0); + v_radius = a_radius; + v_rgba = a_rgba; v_style = a_style; v_stroke = a_stroke; + return; + } vec2 clipA, clipB; if (u_orientation == 0) { clipA = vec2(p - halfW, v0); clipB = vec2(p + halfW, v1); @@ -902,9 +1279,23 @@ uniform vec2 u_res; in float v_lutCoord; in vec2 v_local; in vec2 v_half; in float v_t; in vec4 v_rgba; in vec4 v_style; in vec4 v_stroke; in vec2 v_radius; +// Polar wedge coverage. The rect SDF above is inert under polar (v_half is +// huge), so a wedge's edges were hard-aliased — the context has +// antialias: false and coverage is the only smoothing there is. The vertex +// stage expands the strip by XY_POLAR_AA px (POLAR_WEDGE_GLSL) and this SDF +// trims it back to the true annular sector, which also makes the outer arc +// exactly round rather than chord-faceted. +// u_wedgeGap is the gap between neighbouring wedges in device px. Subtracting +// a CONSTANT number of px from the arc half-width at every radius keeps the +// seam between two slices the same width from the hole to the rim; an angular +// pad's gap is r*dtheta and tapers to nothing at the centre. +uniform float u_wedgeGap; +flat in vec2 v_polarRadii; flat in vec2 v_polarAngles; out vec4 outColor; ${GRAD_GLSL} +${POLAR_FRAGMENT_CLIP_GLSL} void main() { + xyClipPolarFragment(); vec4 paint = u_colorMode == 3 ? v_rgba : (u_colorMode == 0 ? u_color : vec4(texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb, 1.0)); float alpha = (v_style.y >= 0.0 ? v_style.y : paint.a) * v_style.x * u_opacity; vec4 premult = vec4(paint.rgb * alpha, alpha); @@ -918,7 +1309,7 @@ void main() { } vec2 radius = v_radius.x >= 0.0 ? v_radius : u_radius; float strokeWidth = v_style.z >= 0.0 ? v_style.z : u_strokeWidth; - if (radius.x > 0.0 || radius.y > 0.0 || strokeWidth > 0.0) { + if (u_coordMode != 1 && (radius.x > 0.0 || radius.y > 0.0 || strokeWidth > 0.0)) { // u_radius = (tip, base) in mark space: v_t > 0.5 is the tip half, so // corner_radius=(6, 0) rounds only the value end of the bar. On the // straight sides the SDF reduces to |local|-half independent of r, so @@ -938,6 +1329,44 @@ void main() { } premult *= 1.0 - smoothstep(-aa, aa, d); } + if (u_coordMode == 1) { + // Annular-sector SDF: signed px distance to the wedge boundary, negative + // inside. Everything the rectangle path gets from its own SDF — the AA + // fringe, the stroke ring and corner_radius -- comes from this one. + vec2 rel = gl_FragCoord.xy - (u_polar.xy * 0.5 + 0.5) * u_res; + float dist = length(rel); + float rMid = (v_polarRadii.x + v_polarRadii.y) * 0.5; + float hr = (v_polarRadii.y - v_polarRadii.x) * 0.5; + float sweep = abs(v_polarAngles.y - v_polarAngles.x); + float d; + if (sweep >= 6.2831853 - 1e-4) { + // A full turn has no angular edges to round or stroke: a plain annulus, + // or a disc when the inner radius is zero. + d = v_polarRadii.x > 0.0 ? abs(dist - rMid) - hr : dist - v_polarRadii.y; + } else { + // Angular offset from the sector's mid angle, wrapped to (-pi, pi]: + // symmetric at both edges and seam-safe. + float mid = (v_polarAngles.x + v_polarAngles.y) * 0.5; + float off = mod(atan(rel.y, rel.x) - mid + 3.14159265359, 6.28318530718) - 3.14159265359; + // Unrolled (arc, radial) frame in px at this fragment's own radius: the + // wedge becomes a rectangle there, so the standard rounded-rect SDF + // yields corners that follow the arc -- which is what corner_radius + // means on a slice, and what every donut/progress-ring design uses. + float ha = max(sweep * 0.5 * dist - u_wedgeGap * 0.5, 0.0); + float rad = clamp(v_t > 0.5 ? radius.x : radius.y, 0.0, min(hr, ha)); + vec2 q = vec2(abs(off * dist) - (ha - rad), abs(dist - rMid) - (hr - rad)); + d = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - rad; + } + float aa = 0.75; + if (strokeWidth > 0.0) { + vec4 strokeSrc = u_strokeMode == 1 ? v_stroke : (u_strokeMode == 2 ? paint : u_stroke); + float strokeAlpha = (v_style.y >= 0.0 ? v_style.y : strokeSrc.a) * v_style.x * u_strokeOpacity; + vec4 stroke = vec4(strokeSrc.rgb * strokeAlpha, strokeAlpha); + float inner = 1.0 - smoothstep(-aa, aa, d + strokeWidth); + premult = mix(stroke, premult, inner); + } + premult *= 1.0 - smoothstep(-aa, aa, d); + } if (premult.a <= 0.001) discard; outColor = premult; }`; diff --git a/js/src/45_lod.ts b/js/src/45_lod.ts index 17fce771..b2d3e909 100644 --- a/js/src/45_lod.ts +++ b/js/src/45_lod.ts @@ -1,3 +1,4 @@ +import { TRACE_GPU_BUFFERS } from "./00_header"; import { parseColor } from "./20_theme"; // --------------------------------------------------------------------------- @@ -688,10 +689,10 @@ function lodSameWindow(a, b) { } function lodFreeDrillBuffers(view, d) { - const gl = view.gl; view._deleteVaos(d); // each drill object carries its own VAOs - for (const b of [d.xBuf, d.yBuf, d.cBuf, d.rgbaBuf, d.sBuf, d.styleBuf, - d.strokeBuf, d.selBuf, d.dBuf]) if (b) gl.deleteBuffer(b); + // The shared list, not a drill-shaped subset: a drill window is built by the + // same channel code as its trace, so it grows the same buffers. + view._deleteBuffers(d, TRACE_GPU_BUFFERS); } // Reset the trace's drill lifecycle state without touching the (moved or @@ -928,6 +929,10 @@ export function lodApplyDrill(view, g, upd, buffers) { copy("stroke_width", 2, view.dpr); copy("symbol", 3); d._cpuStyle = values; + // Widths are baked in DEVICE pixels; stamp the dpr they were baked at so a + // later zoom rescales them (view._rescaleDprBakedBuffers) instead of leaving + // a drill window's strokes at the previous scale. + d._styleDpr = view.dpr; if (!d.styleBuf) d.styleBuf = gl.createBuffer(); d.styleBuf._fcType = gl.FLOAT; gl.bindBuffer(gl.ARRAY_BUFFER, d.styleBuf); diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index fda314be..0db864a2 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -1,7 +1,7 @@ -import { PROTOCOL, xyByteSpan } from "./00_header"; +import { PROTOCOL, TRACE_GPU_BUFFERS, xyByteSpan } from "./00_header"; import { buildLutData, colormapKey, colormapStops } from "./10_colormaps"; import { chartBackdrop, cssColor, ensureChromeStylesheet, hexColor, parseColor, readTheme, safeCssPaint } from "./20_theme"; -import { categoryTicks, fmtAxis, fmtGeneral, fmtLinear, fmtLog, fmtValue, linearTicks, logTicks, timeTicks } from "./30_ticks"; +import { angularTicks, categoryTicks, fmtAxis, fmtGeneral, fmtLinear, fmtLog, fmtValue, linearTicks, logTicks, timeTicks } from "./30_ticks"; import { AREA_FS, AREA_VS, ATTR_SLOTS, BAR_VS, DENSITY_FS, GRID_VS, HEATMAP_FS, LINE_CAP_MODES, LINE_FS, LINE_VS, MESH_FS, MESH_VS, PICK_FS, PICK_VS, POINT_FS, POINT_SIMPLE_FS, POINT_SIMPLE_VS, POINT_VS, RECT_FS, RECT_VS, SEGMENT_FS, SEGMENT_VS, makeProgram, uniformOf, xySmoothResample } from "./40_gl"; import { lodCopyGrid, lodDecodeLogU8, lodDrawDensityTier, lodDropDensityCache, lodDropPointCache, lodRememberDensity, lodSampleForView, lodWriteGridTexture } from "./45_lod"; import { markOf } from "./55_marks"; @@ -19,6 +19,77 @@ export interface ChartView { } const MARGIN = { l: 62, r: 14, t: 10, b: 42 }; +// Subdivisions across one polar bar's angular span. Mirrored by +// POLAR_BAR_SEGMENTS in python/xy/config.py so the raster exporter flattens the +// same arc; the SVG exporter draws a true `A` arc and needs no count. Sized so +// a full-turn wedge's chord sagitta stays under the XY_POLAR_AA expansion on a +// ~1400-device-px disc — the fragment SDF then trims the strip to an exactly +// round arc (see POLAR_WEDGE_GLSL in 40_gl.ts). +const POLAR_BAR_SEGMENTS = 96; +// Floor on any single wedge's subdivision: two segments keep a strip that still +// brackets the true arc after the AA expansion, even for a hairline slice. +const POLAR_BAR_SEGMENTS_MIN = 2; + +// Subdivisions for one wedge of angular width `span` out of `turn`. Mirrors +// `polar_bar_segments` in python/xy/config.py. +// +// The count used to be a flat POLAR_BAR_SEGMENTS per wedge, sized for the worst +// case of a wedge sweeping the whole circle. Almost no wedge does: a 16-sector +// wind rose sweeps 22.5 degrees, so every bar paid 2*(96+1) = 194 vertices for an +// arc that needs six segments, and 50k polar bars fell off a cliff building +// ~9.7M vertices a frame instead of ~700k. Sagitta is quadratic in the +// per-segment angle, so holding `span / n` fixed holds the flattening error +// fixed: the honest count is exactly proportional, which reproduces 96 at a full +// turn and preserves the bound for everything narrower. §28 — a recorded formula +// over the AUTHORED angular width, never a view-dependent choice, so zooming and +// exporting cannot change it. +function xyPolarBarSegments(span, turn) { + // An unmeasurable span falls back to the full-turn count: under-subdividing a + // wide wedge is a visible facet, and paying for one is not. + if (!(turn > 0) || !Number.isFinite(span)) return POLAR_BAR_SEGMENTS; + const scaled = Math.ceil(POLAR_BAR_SEGMENTS * (Math.abs(span) / turn)); + return Math.max(POLAR_BAR_SEGMENTS_MIN, Math.min(POLAR_BAR_SEGMENTS, scaled)); +} +// Uniform room outside the outer ring for angular tick labels. Mirrored by +// _POLAR_LABEL_ROOM in python/xy/_svg.py. +const POLAR_LABEL_ROOM = 30; +// Radial tick labels run along a spoke this many degrees off theta zero, and +// angular labels sit this many px outside the rim. Mirrored by +// _POLAR_RLABEL_DEG / _POLAR_TICK_GAP in python/xy/_svg.py. +const POLAR_LABEL_ROOM_MAX = 90; +const POLAR_RLABEL_DEG = 22.5; +const POLAR_TICK_GAP = 8; +// Direction theta=0 points, in radians ccw from east. Mirrored by THETA_ZERO in +// python/xy/_svg.py; the wire carries the letters so one table serves all +// renderers. +const THETA_ZERO = { E: 0, N: Math.PI / 2, W: Math.PI, S: -Math.PI / 2 }; +// Gutter reserved for a legend beside a disc. A cartesian legend overlays the +// plot because data rarely reaches a corner; a disc inscribed in its rect leaves +// no corner at all, so an inside legend lands on the marks — an `upper right` box +// covered a wind rose's whole north-east quadrant and the outer radial label +// under it. +// +// A FRACTION OF THE CANVAS, clamped, rather than a measurement of the label set: +// every renderer knows the canvas width to the pixel, so all three reserve the +// identical box, while a measured reservation would drift with each renderer's +// font metrics (system-ui here, DejaVu in the exporters). A flat constant was +// tried first and is the wrong shape — 96 px ellipsized `Partner (30%)`, an +// ordinary pie slice's default name, while being a fifth of a phone canvas and a +// fifteenth of a wide one. A label still wider than the gutter ellipsizes with +// its full text in `title`/ARIA. +// Mirrored by `_polar_legend_room` in python/xy/_svg.py. +const POLAR_LEGEND_ROOM_FRACTION = 0.22; +const POLAR_LEGEND_ROOM_MIN = 120; +const POLAR_LEGEND_ROOM_MAX = 200; + +// `Math.floor`, not `Math.round`: Python and JavaScript disagree about half-way +// cases, and the two must land on the same integer pixel. +function xyPolarLegendRoom(width) { + const scaled = Math.floor(Number(width) * POLAR_LEGEND_ROOM_FRACTION); + return Math.min(POLAR_LEGEND_ROOM_MAX, Math.max(POLAR_LEGEND_ROOM_MIN, scaled)); +} + +const POLAR_LEGEND_BAND = 64; // DejaVu Sans advances at 16 px, generated beside python/xy/_fontmetrics.py // and the native rasterizer. Layout must retain proportional glyph metrics: // character count makes "WWWW" and "iiii" reserve the same (wrong) width. @@ -38,6 +109,33 @@ const XY_MISSING_ADVANCE = 16; // font rasterization/rounding cannot consume its authored title-to-tick gap. const Y_TITLE_MEASURE_SAFETY_PX = 2; +// Greedy word wrap of already newline-split lines. Mirrors `wrap_lines` in +// python/xy/_textblock.py, and matches what CSS `white-space: pre-line` does to +// the same string: authored newlines are hard breaks, runs of other whitespace +// collapse to one space, breaks are only taken at a space, and a word wider than +// the limit keeps its own line and overflows (no `overflow-wrap` is set). +function xyWrapLines(lines, advance, maxWidth) { + const wrapped = []; + for (const line of lines) { + const words = String(line).split(/\s+/).filter((word) => word.length); + if (!words.length) { + wrapped.push(""); + continue; + } + let current = words[0]; + for (const word of words.slice(1)) { + const candidate = `${current} ${word}`; + if (advance(candidate) <= maxWidth) current = candidate; + else { + wrapped.push(current); + current = word; + } + } + wrapped.push(current); + } + return wrapped; +} + function xyTextAdvance(text, fontSize) { let units = 0; for (const char of String(text)) { @@ -52,6 +150,17 @@ function xyTextAdvance(text, fontSize) { const COLORBAR_THICKNESS = 18; const COLORBAR_GAP = 24; const COMPACT_COLORBAR_GAP = 8; +// A compact vertical colorbar keeps its two EXTREME tick labels, stacked above +// and below the gradient rather than beside it. Hiding every tick left an +// unlabelled gradient — a colour ramp with no numbers on it says nothing at all, +// so the compact form was not a smaller version of the chrome but the absence of +// it. Stacking is what makes the fix free: a side gutter wide enough for `0.25` +// cost 36 px of plot width, which is the very thing the compact collapse exists +// to protect, while two centred labels overflow the 18 px bar by ~4 px a side and +// fit inside the gap that is already reserved. Interior ticks and the rotated +// title are what a phone-width chart genuinely cannot afford; the title stays +// readable through the box's own `title`/ARIA text. +const COMPACT_COLORBAR_LABEL_GAP = 3; let XY_A11Y_ID = 0; // Legend hover emphasis (interaction spec §9): opacity kept by non-hovered series on // the marks canvas, and by non-hovered rows in the legend box itself. @@ -567,12 +676,29 @@ export class ChartView { (this._axisTickLabelSides(axis).includes("top") || axis.side === "top") && this._axisTickLabelStrategy(axis) !== "none"); const hasTopAxis = topAxes.length > 0; + const authoredLeft = pad + ? (responsivePad ? Math.min(pad[3], 46) : pad[3]) + : (compact ? 46 : MARGIN.l); + // Width the title wraps at. Resolved from the authored/default gutters + // (`baseRight`, before colorbar and right-axis room) rather than the final + // plot rect, because the measured left gutter depends on the plot height, + // which depends on the title band — wrapping at the final width would be + // circular. Mirrors `_title_wrap_width` in python/xy/_svg.py. + // + // The title DIV wraps whether or not layout accounts for it (white-space is + // `pre-line`), so measuring one line and drawing two put the first line + // above the canvas: a compact Wind Rose title lost about 10 px off its top. + // `_positionTitles` caps the element at this same width, so what the DOM + // wraps is exactly what is reserved here. + this._titleWrapWidth = Math.max(40, this.size.w - authoredLeft - baseRight); const titleRoom = this._titleEntries().reduce((room, entry) => { const authoredSize = Number.parseFloat(entry.style?.["font-size"]); const titleFontSize = Number.isFinite(authoredSize) ? authoredSize : this._slotFontSize("title", 14); - const measured = this._estimateTickLabel(entry.text, titleFontSize).h; + const measured = this._estimateTickLabel( + entry.text, titleFontSize, this._titleWrapWidth, + ).h; const pad = Number.isFinite(Number(entry.pad)) ? Number(entry.pad) : 8; const candidate = entry.automatic_y !== false ? Math.max(compact ? 26 : 30, measured + pad) @@ -586,9 +712,6 @@ export class ChartView { const provisionalBottom = Math.max(baseBottom, provisionalBottomAxisRoom) + colorbarBottomRoom; const plotHeight = Math.max(40, this.size.h - provisionalTop - provisionalBottom); - const authoredLeft = pad - ? (responsivePad ? Math.min(pad[3], 46) : pad[3]) - : (compact ? 46 : MARGIN.l); // Explicit padding is a floor, not permission to clip. Long numeric or // categorical ticks and an outside y title reserve the room their actual // strings need before the plot rectangle is fixed. @@ -628,6 +751,147 @@ export class ChartView { w: plotWidth, h: Math.max(40, this.size.h - top - marginBottom), }; + // The box legends place themselves in. Null means "the plot rect", which is + // every cartesian chart and every polar chart that reserves no gutter — kept + // null rather than aliased to `this.plot`, because `_recutPolarPlot` REPLACES + // that object and an alias would freeze the pre-recut geometry. + this._legendRect = null; + this._recutPolarPlot(compact); + } + + // Side and px a polar legend gutter claims, or null when nothing is reserved: + // a non-polar figure, no legend rows, an authored `anchor` (an explicit + // plot-relative placement the author owns), or an authored 4-tuple `padding` + // (which already states the box the plot should occupy, and is the documented + // way to hand-reserve a caption band). + // Mirrors `_polar_legend_reserve` in python/xy/_svg.py. + _polarLegendReserve(compact) { + const s = this.spec || {}; + if (s.coords !== "polar" || s.show_legend === false) return null; + if (Array.isArray(s.padding) && s.padding.length === 4) return null; + const options = s.legend || {}; + if (Array.isArray(options.anchor) && [2, 4].includes(options.anchor.length)) return null; + const hasRows = (options.items || []).length > 0 + || (s.extra_legends || []).length > 0 + || (s.traces || []).some((t) => + t && (t.name || (t.color && t.color.mode === "categorical"))); + if (!hasRows) return null; + if (compact) return { side: "bottom", room: POLAR_LEGEND_BAND }; + const loc = String(options.loc || "upper right"); + return { + side: loc.includes("left") ? "left" : "right", + room: xyPolarLegendRoom(this.size.w), + }; + } + + // Re-cut the plot rect for a disc. Mirrors `_recut_polar_plot` in + // python/xy/_svg.py; the two must agree or the same chart renders at a + // different size and centre in the browser than in an export. + // + // Cartesian tick-label gutters hold labels hugging the left and bottom edges. + // A polar chart rings its labels around the rim instead, so those gutters are + // symmetrised (not simply zeroed — a colorbar that genuinely claimed space + // keeps it), and a uniform allowance is reserved all the way around. + // Room outside the ring for the angular tick labels. Measured rather than + // fixed: authored category names are far wider than an angle, and a constant + // allowance hard-clipped them. Mirrors `_polar_label_room` in + // python/xy/_svg.py — including the ceiling, past which a pathological label + // truncates instead of shrinking the disc away. + _polarLabelRoom(axis) { + const labels = axis && axis.tick_labels; + if (!Array.isArray(labels) || !labels.length) return POLAR_LABEL_ROOM; + const size = this._axisStyleNumber(axis, "tick_label_size", 11); + let widest = 0; + for (const text of labels) widest = Math.max(widest, xyTextAdvance(String(text), size)); + return Math.min( + POLAR_LABEL_ROOM_MAX, + Math.max(POLAR_LABEL_ROOM, widest + POLAR_TICK_GAP + 4), + ); + } + + _recutPolarPlot(compact = false) { + if (this.spec?.coords !== "polar") return; + const xAxisSpec = this._axis("x") || {}; + if (this._axisTickLabelStrategy(xAxisSpec) === "none") return; + // A legend gutter comes off the canvas edge FIRST, before the disc is fitted + // to what remains, so the disc never occupies the gutter and the legend + // never occupies the disc. Mirrors the same block in `_recut_polar_plot`. + let canvasX0 = 0; + let canvasW = this.size.w; + let canvasH = this.size.h; + const reserve = this._polarLegendReserve(compact); + if (reserve) { + const p0 = { ...this.plot }; + if (reserve.side === "left") { + canvasX0 = reserve.room; + this._legendRect = { x: 0, y: p0.y, w: reserve.room, h: p0.h }; + p0.x = Math.max(p0.x, reserve.room); + } else if (reserve.side === "right") { + canvasW -= reserve.room; + this._legendRect = { x: canvasW, y: p0.y, w: reserve.room, h: p0.h }; + } else { + canvasH -= reserve.room; + this._legendRect = { x: p0.x, y: canvasH, w: p0.w, h: reserve.room }; + } + p0.w = Math.max(40, Math.min(p0.w, canvasW - p0.x)); + p0.h = Math.max(40, Math.min(p0.h, canvasH - p0.y)); + this.plot = p0; + } + const p = this.plot; + const reservedTop = p.y; + const reservedRight = canvasW - p.x - p.w; + const reservedBottom = canvasH - p.y - p.h; + const room = this._polarLabelRoom(xAxisSpec); + // An explicit `padding` states the box the author wants the plot to + // occupy — usually to reserve a band under the disc for a legend or + // caption. Reclaiming those gutters below would throw that away, so an + // authored box is only inset by the uniform label room. Mirrors the same + // early return in `_recut_polar_plot` (python/xy/_svg.py). + if (Array.isArray(this.spec.padding) && this.spec.padding.length === 4) { + const boxW = p.w - 2 * room; + const boxH = p.h - 2 * room; + if (boxW >= 40 && boxH >= 40) { + this.plot = { ...p, x: p.x + room, y: p.y + room, w: boxW, h: boxH }; + this._topAxisRoom = (this._topAxisRoom || 0) + room; + } + return; + } + const side = Math.max(room, reservedRight); + // A radial-axis title still lives in the left gutter — a disc gives it no + // natural home — and it is placed outward past the tick-label room, so a + // titled radial axis keeps its gutter whole rather than part-reclaimed. + const yAxis = this._axis("y") || {}; + const titled = !!yAxis.label; + // `canvasX0` is a left legend gutter; the label room still applies inside + // it. With no gutter it is 0 and `side >= room`, so this is the old value. + const left = Math.max(titled ? Math.max(side, p.x) : side, canvasX0 + room); + const right = canvasW - side; + // Only the bottom can be symmetrised: the top also holds the figure title, + // and the bottom keeps its full band when the theta axis has a title of its + // own (it is drawn there; reclaiming the band pushed it off the canvas). + const xAxis = this._axis("x") || {}; + // A horizontal colorbar hangs off the plot's bottom edge; extending the + // rect downward would walk it off the canvas. + const keepsBottom = !!xAxis.label || this.spec?.colorbar?.orientation === "horizontal"; + const bottomReserve = keepsBottom ? reservedBottom : Math.min(reservedBottom, reservedTop); + const bottom = canvasH - Math.max(room, bottomReserve); + const top = reservedTop + room; + const w = right - left; + const h = bottom - top; + if (!(w >= 40) || !(h >= 40)) return; + this.plot = { ...p, x: left, y: top, w, h }; + // The top slice is angular-label room, so it belongs to the axis + // reservation: `_positionTitles` anchors at `plot.y - _topAxisRoom`, and + // without this the title rides the rect down and the topmost angular label + // lands on top of it. Mirrors the same line in `_recut_polar_plot`. + this._topAxisRoom = (this._topAxisRoom || 0) + room; + // Re-square the legend gutter against the FINAL rect so the box tracks the + // disc it sits beside rather than the pre-recut rect it was cut from. + if (reserve) { + const box = this._legendRect; + if (reserve.side === "bottom") this._legendRect = { ...box, x: this.plot.x, w: this.plot.w }; + else this._legendRect = { ...box, y: this.plot.y, h: this.plot.h }; + } } _titleEntries() { @@ -663,6 +927,11 @@ export class ChartView { title.style.textAlign = loc; title.style.left = `${x}px`; title.style.top = `${anchorY}px`; + // Cap the box at the width `_layout` measured the title band at, so the + // DOM cannot wrap into more lines than the band reserves. The transform + // still positions by the element's REAL height, so an agreed line count + // means the reserved band and the painted block are the same box. + title.style.maxWidth = `${Math.max(40, Number(this._titleWrapWidth) || 40)}px`; title.style.transform = `translate(${shiftX}, calc(-100% - ${Number(entry.pad ?? 8)}px))`; } } @@ -882,9 +1151,22 @@ export class ChartView { _axisPolicy(name) { const configured = this.interaction?.[name]; - if (!Array.isArray(configured) || !configured.length) return this._axisIds(); - const declared = new Set(this._axisIds()); - return [...new Set(configured.filter((axisId) => declared.has(axisId)))]; + const ids = (!Array.isArray(configured) || !configured.length) + ? this._axisIds() + : (() => { + const declared = new Set(this._axisIds()); + return [...new Set(configured.filter((axisId) => declared.has(axisId)))]; + })(); + // Polar interaction is deliberately small (polar-axes.md §8): wheel zoom + // is RADIAL-ONLY (the y axis carries r), and pan is disabled rather than + // half-working — a cartesian pan would shift the theta range and rescale + // the disc as if the chart were rectilinear, which is exactly what it + // looked like: wrong. + if (this.spec?.coords === "polar") { + if (name === "pan_axes") return []; + if (name === "zoom_axes") return ids.filter((axisId) => axisId.startsWith("y")); + } + return ids; } _resetAxisPolicy() { @@ -911,7 +1193,8 @@ export class ChartView { const requested = typeof this.interaction?.default_drag_action === "string" ? this.interaction.default_drag_action : "auto"; const canNavigate = this._interactionFlag("navigation", true); - const canPan = canNavigate && this._interactionFlag("pan", true); + const canPan = canNavigate && this._interactionFlag("pan", true) + && this._axisPolicy("pan_axes").length > 0; const canZoom = canNavigate && this._interactionFlag("zoom", true) && this._interactionFlag("box_zoom", true); const canSelect = this._pickable && this._interactionFlag("select", true) @@ -971,16 +1254,80 @@ export class ChartView { return [Number(r[0]), Number(r[1])]; } + // Stride-thin radial tick LABELS to what the POLAR_RLABEL_DEG spoke can + // hold. Their usable run is the annulus width projected onto that spoke — + // about a fifth of the plot at the default 22.5 degrees — so a plot-height + // worth of labels packed into it and overlapped, the polar path skipping the + // collision pass that would otherwise thin them. Grid rings come from the + // same tick list and must keep full density, so only the labels are thinned. + // Mirrored by _polar_thin_radial_labels in python/xy/_svg.py. + _polarThinRadialLabels(labels, geom) { + if (!geom || !Array.isArray(labels)) return labels; + const span = geom.radius * (1 - (geom.hole || 0)); + const usable = Math.max(1, span * Math.abs(Math.sin((POLAR_RLABEL_DEG * Math.PI) / 180))); + const capacity = Math.max(2, Math.floor(usable / 45)); + if (labels.length <= capacity) return labels; + const stride = Math.ceil(labels.length / capacity); + const thinned = labels.filter((_, i) => i % stride === 0); + const last = labels[labels.length - 1]; + if (!thinned.includes(last)) thinned.push(last); + return thinned; + } + + // One full turn in the axis's own angular unit, or 0 when the axis is not a + // continuous angular one — mirrors _tick_window_filter in _svg.py. + _polarAngularTurn(axisId): number { + const axis = this._axis(axisId); + if (!axis || !axis.theta_unit || axis.kind === "category") return 0; + return axis.theta_unit === "degrees" ? 360 : 2 * Math.PI; + } + _axisTicks(axisId, target): any { const axis = this._axis(axisId); - const [lo, hi] = this._axisRange(axisId); + let [lo, hi] = this._axisRange(axisId); + if (this.spec?.coords === "polar" && this._axisDim(axisId) === "x") { + if (axis.kind === "category") { + lo = 0; + hi = Math.max(0, (axis.categories || []).length - 1); + } else if (Array.isArray(axis.sector) && axis.sector.length === 2) { + lo = Number(axis.sector[0]); + hi = Number(axis.sector[1]); + } + } if (Array.isArray(axis.tick_values)) { const a = Math.min(lo, hi), b = Math.max(lo, hi); - const ticks = axis.tick_values.map(Number).filter((v) => Number.isFinite(v) && v >= a && v <= b); + // An angular window can cross the 0/turn seam (sector 300..420, or the + // compass-natural -30..30). The plain range test drops every tick spelled + // on the far side of the seam while a data point at that same angle still + // plots inside the sector, because mark culling is modular. Match it — + // mirrored by _tick_window_filter in _svg.py. + const turn = this._polarAngularTurn(axisId); + const span = b - a; + const inside = turn + ? (v) => ((v - a) % turn + turn) % turn <= span + turn * 1e-9 + : (v) => v >= a && v <= b; + const ticks = axis.tick_values.map(Number).filter((v) => Number.isFinite(v) && inside(v)); return { ticks, labels: ticks, step: ticks.length > 1 ? Math.abs(ticks[1] - ticks[0]) : 1 }; } + // Placed after the authored-tick_values return so explicit ticks still + // win, and before every kind branch — mirrored by axis_ticks in _svg.py. + if (axis.kind === "category") { + const categories = axis.categories || []; + // Every categorical-theta value defines a spoke (and, for + // grid_shape="linear", one polygon vertex). Cartesian categories may be + // thinned for legibility, but silently thinning polar categories changes + // the grid geometry itself. An explicit tick_count remains the opt-in + // control for authors who want fewer spokes. + const authoredTarget = Number(axis.tick_count); + const categoryTarget = this.spec?.coords === "polar" + && this._axisDim(axisId) === "x" + && !(Number.isFinite(authoredTarget) && authoredTarget > 0) + ? categories.length + : target; + return categoryTicks(lo, hi, categories, categoryTarget); + } + if (axis.theta_unit) return angularTicks(lo, hi, axis.theta_unit, target); if (axis.kind === "time") return timeTicks(lo, hi, target); - if (axis.kind === "category") return categoryTicks(lo, hi, axis.categories || [], target); if (axis.scale === "log") return logTicks(lo, hi, target); if (axis.scale === "symlog") { const c0 = this._axisCoord(axis, lo), c1 = this._axisCoord(axis, hi); @@ -1074,6 +1421,15 @@ export class ChartView { } _interactionFlag(name, fallback = false) { + // Rectangle-shaped gestures have no polar geometry yet: a screen-space box + // neither matches a (theta, r) region nor reads as one. Off rather than + // half-working, per polar-axes.md §8; hover and radial wheel zoom remain. + if ( + this.spec?.coords === "polar" && + (name === "box_zoom" || name === "select" || name === "brush" || name === "crosshair") + ) { + return false; + } const value = this.interaction && this.interaction[name]; return value === undefined ? fallback : value === true; } @@ -1363,6 +1719,14 @@ export class ChartView { const mq = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); this._onDprChange = () => { if (this._destroyed) return; + // Synchronous on purpose, and pinned that way: `render_smoke_nonumpy.py`'s + // `dprw` probe calls this and reads `dpr`/`canvas.width`/`chrome.width` on + // the very next line, because a DPR change with no container resize has no + // later event to piggyback on. Deferring it into `_queueResize` broke that + // contract, and the redundant second frame it was meant to save does not + // exist: the ResizeObserver's queued pass early-returns when width, height + // and dpr are all unchanged, and when the CSS size *did* change too, the + // second pass is doing real work at a new size. this._resize(this.size.w, this.size.h); // re-reads devicePixelRatio this._armDprWatch(); }; @@ -1811,6 +2175,69 @@ export class ChartView { this._ctxIo.observe(this.root); } + // Re-upload the buffers whose values were baked at a device-pixel ratio, after + // dpr changed under them (browser zoom, or a window moving between displays). + // + // Per-instance stroke/line widths (`styleBuf` component 2) and corner radii + // (`radiusBuf`) are written in DEVICE pixels at build time, because the + // shaders consume device pixels. Everything else in the frame absorbs a dpr + // change through uniforms and the backing-store resize, so these two silently + // kept the OLD scale: after a zoom from 1x to 2x, a chart's authored 2 px + // strokes and 6 px wedge corners rendered at half their intended size, and + // only these marks were wrong. The streaming-append fast path already refuses + // to patch a trace whose `_styleDpr` is stale (54_kernel.ts); this is the + // matching repair for the traces already on the GPU. + _rescaleDprBakedBuffers() { + if (!this.gl || this._glLost) return; + const dpr = this.dpr; + const rescale = (record) => { + if (!record) return; + const previous = Number(record._styleDpr); + if (!(previous > 0) || previous === dpr) return; + // Repair in place ONLY while the CPU mirrors still cover every row the + // GPU holds. The streaming-append fast path extends styleBuf with a tail + // `bufferSubData` and advances `n` without growing `_cpuStyle` + // (54_kernel.ts), so after an append the mirror is short: re-uploading it + // would shrink the store out from under the appended rows, and scaling it + // would leave that tail at the old dpr either way. Leave `_styleDpr` + // stale instead — the append guard then refuses the fast path and its + // rebuild renormalizes every row at the current dpr, which is the + // fallback that case has always relied on. + const rows = Number(record.n); + if (!(rows > 0)) return; + if (record._cpuStyle && record._cpuStyle.length !== rows * 4) return; + if (record._cpuRadius && record._cpuRadius.length !== rows * 2) return; + const factor = dpr / previous; + // Widths ride component 2 of the canonical style row; the other three + // components (opacity, artist alpha, symbol) are dpr-independent. + const style = record._cpuStyle; + if (style && record.styleBuf) { + for (let i = 2; i < style.length; i += 4) style[i] *= factor; + this._reuploadBuffer(record.styleBuf, style); + } + const radius = record._cpuRadius; + if (radius && record.radiusBuf) { + for (let i = 0; i < radius.length; i++) radius[i] *= factor; + this._reuploadBuffer(record.radiusBuf, radius); + } + record._styleDpr = dpr; + }; + for (const g of this.gpuTraces || []) { + rescale(g); + rescale(g.drill); + rescale(g.sampleOverlay); + for (const d of g.densityCache || []) rescale(d && d.overlay); + rescale(g.density && g.density.overlay); + } + for (const g of this._transitionOldTraces || []) rescale(g); + } + + _reuploadBuffer(buffer, data) { + const gl = this.gl; + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW); + } + // Container size changed (fluid mode). Cheap on purpose: data GPU buffers // are untouched — the _map() uniforms absorb the new aspect — and the pick // FBO realloc is deferred to the next actual pick (_renderPick checks dims). @@ -1826,10 +2253,16 @@ export class ChartView { this.dpr = dpr; this.size.w = w; this.size.h = h; + // Before the layout/paint below, so this frame draws at the new scale. + this._rescaleDprBakedBuffers(); this._layout(); const p = this.plot; - this.root.style.setProperty("--xy-legend-max-width", Math.max(40, p.w - 12) + "px"); - this.root.style.setProperty("--xy-legend-max-height", Math.max(40, p.h - 12) + "px"); + // Legends are bounded by the box they place in, which under polar is the + // reserved gutter rather than the plot rect — clamping to the plot would let + // a long label spill back over the disc the gutter exists to protect. + const lb = this._legendRect || p; + this.root.style.setProperty("--xy-legend-max-width", Math.max(40, lb.w - 12) + "px"); + this.root.style.setProperty("--xy-legend-max-height", Math.max(40, lb.h - 12) + "px"); this.canvas.style.left = p.x + "px"; this.canvas.style.top = p.y + "px"; this.canvas.style.width = p.w + "px"; @@ -1870,8 +2303,8 @@ export class ChartView { root.style.cssText = `position:relative;width:${this.fluid ? "100%" : this.size.w + "px"};` + `height:${this.fluidH ? "100%" : this.size.h + "px"};` + - `--xy-legend-max-width:${Math.max(40, this.plot.w - 12)}px;` + - `--xy-legend-max-height:${Math.max(40, this.plot.h - 12)}px;` + + `--xy-legend-max-width:${Math.max(40, (this._legendRect || this.plot).w - 12)}px;` + + `--xy-legend-max-height:${Math.max(40, (this._legendRect || this.plot).h - 12)}px;` + (this.fluidH ? "min-height:120px;" : "") + // parent without a height -> visible floor "user-select:none;"; this._applySlot(root, "root"); @@ -2183,9 +2616,18 @@ export class ChartView { const handleTextPad = Number.isFinite(Number(options.handletextpad)) ? Math.max(0, Number(options.handletextpad)) : 0.8; + // `minmax(0, max-content)`, not bare `max-content`: the box is capped at + // `--xy-legend-max-width`, and a column that refuses to shrink below its + // content made a long row overflow horizontally — a legend with a horizontal + // SCROLLBAR, which hides the label it is meant to be showing. Shrinkable + // columns let a long label WRAP inside its column instead, so the inline axis + // never needs to scroll and the text stays whole; the block axis still + // scrolls, which is the browser legend's advantage over the static + // exporters, which can only ellipsize. Row `title`/ARIA carries the full + // name either way, for the rows the block-axis cap does clip. lg.style.cssText = "position:absolute;" + - `display:grid;grid-template-columns:repeat(${horizontal ? ncols : 1},max-content);` + - "column-gap:2em;row-gap:.5em;overflow:auto;"; + `display:grid;grid-template-columns:repeat(${horizontal ? ncols : 1},minmax(0,max-content));` + + "column-gap:2em;row-gap:.5em;overflow-x:hidden;overflow-y:auto;"; lg.dataset.xyLegendLoc = loc; if (Array.isArray(options.anchor)) { lg.dataset.xyLegendAnchor = JSON.stringify(options.anchor); @@ -2333,6 +2775,17 @@ export class ChartView { label.textContent = it.name; this._applySlot(label, "legend_label"); row.appendChild(label); + // A row too wide for the capped box ellipsizes (the `legend_item` / + // `legend_label` rules in 20_theme.ts) rather than pushing a horizontal + // scrollbar onto the legend. Only the LABEL clips: the swatch is + // `flex:none` and keeps `overflow:visible`, so an authored oversized + // marker still draws outside its 18x14 box. Same full-text-in-title/ARIA + // rule categorical tick labels use, so nothing an ellipsis hides becomes + // unreachable. + if (it.name) { + row.title = String(it.name); + row.setAttribute("aria-label", String(it.name)); + } // Hover emphasis (interaction spec §9): rows backed by live traces dim the rest // of the chart while hovered. Manually-added Legend artists carry no // trace linkage, so extra_legends rows stay inert. @@ -2811,7 +3264,14 @@ export class ChartView { // Responsive anchors flow through private custom properties consumed by a // zero-specificity rule. Author classes or component styles can still set // real left/right/top/bottom/transform declarations and win normally. - const rightInset = this.size.w - (this.plot.x + this.plot.w); + // + // Cartesian legends place inside the plot rect; a polar chart hands over a + // gutter beside the disc instead (`_recutPolarPlot`), because a disc fills + // its rect and an inside box lands on the marks. An authored `anchor` is + // still resolved against the PLOT — it is a plot-relative coordinate — and + // reserves no gutter, so the two cannot disagree. + const plot = anchor ? this.plot : (this._legendRect || this.plot); + const rightInset = this.size.w - (plot.x + plot.w); const h = loc.includes("left") ? "left" : loc.includes("right") ? "right" : "center"; const locTokens = loc.split(/[\s_-]+/); const v = loc.includes("upper") || locTokens.includes("top") @@ -2973,7 +3433,9 @@ export class ChartView { const raw = tickValues[tickIndex]; const value = Number(raw); if (!Number.isFinite(value) || value < Math.min(lo, hi) || value > Math.max(lo, hi)) continue; - const tick = document.createElement("span"); + // `any` because the node carries the stashed beside-the-bar cssText below + // (same reason as the legend box's `lg`). + const tick: any = document.createElement("span"); tick.textContent = hasExplicitTicks && Array.isArray(cb.tick_labels) && @@ -2985,9 +3447,17 @@ export class ChartView { ? fmtLog(value) : fmtLinear(value, tickStep); const fraction = fractionFor(value); + // The compact vertical form keeps only the two extreme ticks (see + // `_positionColorbar`), so record each tick's position on the node and let + // positioning decide — the tick VALUES are generated here, and which of + // them survive is a responsive decision that changes with the container. + tick.dataset.xyColorbarFraction = String(fraction); tick.style.cssText = horizontal ? `position:absolute;left:${100 * fraction}%;top:${barThickness + 2}px;transform:translateX(-50%);white-space:nowrap;` : `position:absolute;left:${barThickness + 5}px;top:${100 * (1 - fraction)}%;transform:translateY(-50%);white-space:nowrap;`; + // The compact form restacks the two endpoints above/below the gradient, so + // keep the beside-the-bar placement to restore when the container widens. + tick._xyBesideCss = tick.style.cssText; this._applySlot(tick, "colorbar_tick"); box.appendChild(tick); } @@ -3062,8 +3532,41 @@ export class ChartView { ? axesPlacement ? this.plot.h + 24 : 50 : Math.max(24, barHeight)) + "px"; this._colorbar.dataset.xyCompact = compactVertical ? "true" : "false"; + // Compact keeps the two EXTREME tick labels — hiding all of them left a bare + // gradient with no numbers on it — and restacks them above and below the + // gradient. Beside the bar they would need a gutter wide enough for `0.25`, + // which costs 36 px of the plot width the compact collapse exists to protect; + // centred on an 18 px bar they overflow ~4 px a side into the gap already + // reserved. The interior ladder still drops, and so does the rotated title: + // at phone width it has nowhere to go, and the box's own `title`/ARIA text + // already names the scale and its range. + const ticks = [...this._colorbar.querySelectorAll('[data-xy-slot="colorbar_tick"]')]; + const fractions = ticks.map((node) => Number(node.dataset.xyColorbarFraction)); + const lowest = Math.min(...fractions); + const highest = Math.max(...fractions); + for (const [index, node] of ticks.entries()) { + const fraction = fractions[index]; + const endpoint = !Number.isFinite(fraction) || fraction === lowest || fraction === highest; + node.hidden = compactVertical && !endpoint; + if (horizontal || !node._xyBesideCss) continue; + if (!compactVertical || !endpoint) { + node.style.cssText = node._xyBesideCss; + continue; + } + // Above the top of the gradient for the maximum, below the bottom for the + // minimum, both centred on the bar. + const above = fraction === highest; + const offset = COMPACT_COLORBAR_LABEL_GAP; + node.style.cssText = + "position:absolute;left:50%;white-space:nowrap;" + + (above + ? `top:-${offset}px;transform:translate(-50%,-100%);` + : `top:calc(100% + ${offset}px);transform:translateX(-50%);`); + } + // The rotated title and the text-free minor ticks are ink a phone-width chart + // cannot spend; `box.title` keeps the scale name reachable. for (const node of this._colorbar.querySelectorAll( - '[data-xy-slot="colorbar_tick"], [data-xy-slot="colorbar_title"]' + '[data-xy-slot="colorbar_title"], [data-xy-colorbar-minor]' )) { node.hidden = compactVertical; } @@ -3381,7 +3884,15 @@ export class ChartView { values[i * 2] = source[i * components] * this.dpr; values[i * 2 + 1] = (components > 1 ? source[i * components + 1] : source[i * components]) * this.dpr; } + // Kept CPU-readable for the same reason as `_cpuStyle`: these rows are + // baked at the dpr in force right now, and a later dpr change rescales + // them in place (`_rescaleDprBakedBuffers`) instead of leaving 1x radii + // on a 2x canvas. + g._cpuRadius = values; g.radiusBuf = this._upload(values); + // Also stamped here: a trace can carry corner radii without any style + // channels, and an unstamped record is one the rescale pass skips. + g._styleDpr = this.dpr; } if (t.stroke && t.stroke.mode === "direct_rgba") { g._cpuStroke = this._columnView(buffer, this.spec.columns[t.stroke.buf]); @@ -3474,10 +3985,9 @@ export class ChartView { _destroySampleOverlay(s) { if (!s || !this.gl) return; - for (const b of [s.xBuf, s.yBuf, s.cBuf, s.rgbaBuf, s.sBuf, s.styleBuf, - s.strokeBuf, s.selBuf, s.dBuf]) { - if (b) this.gl.deleteBuffer(b); - } + // Same shared list the trace teardown uses: an overlay is a point-tier + // clone of a trace and grows the same channel buffers. + this._deleteBuffers(s, TRACE_GPU_BUFFERS); } // Full teardown of every sample overlay this tier owns. Overlays ride their @@ -3612,6 +4122,9 @@ export class ChartView { copy("symbol", 3); s._cpuStyle = values; s.styleBuf = this._upload(values); + // Overlays bake device-pixel widths like their parent trace, so they need + // the same stamp for `_rescaleDprBakedBuffers` to find them. + s._styleDpr = this.dpr; } if (sample.stroke && sample.stroke.mode === "direct_rgba") { s._cpuStroke = this._asU8(buffers[sample.stroke.buf]); @@ -3743,6 +4256,11 @@ export class ChartView { // `_cpu` columns either way (`_nearestCpuIndex` limits to the source length). _smoothArrays(t, x, y, base, n) { if (!t.style || t.style.curve !== "smooth") return null; + // Polar draws chords, never smoothed curves: the Hermite control points + // are only exact under an affine map, and both static exporters already + // skip smoothing for the same reason (polar-axes.md §5). Resampling here + // made the browser render a rounded shape the exports do not have. + if (this.spec?.coords === "polar") return null; return xySmoothResample(x, y, base || null, n, 32768); } @@ -4275,6 +4793,389 @@ export class ChartView { gl.uniform1f(u(`${prefix}constant`), this._axisConstant(axisId)); } + // Widest angular span, in the theta axis's own data units, across a + // four-edge-column trace. Cached on the trace: it is a property of the data, + // not of the view, so it survives pan/zoom and cannot make the subdivision + // count view-dependent (§28). Rebuilt traces get a fresh object and re-measure. + _polarRectMaxSpan(g) { + if (g._polarMaxSpan !== undefined) return g._polarMaxSpan; + const cpu = g._cpuRect; + // No CPU copy to measure (a path that does not retain one) means fall back + // to the full-turn count rather than guess narrow. + let widest = cpu ? 0 : NaN; + if (cpu) { + for (let i = 0; i < g.n; i++) { + const span = this._decodeValue(cpu.x1, cpu.x1Meta, i) + - this._decodeValue(cpu.x0, cpu.x0Meta, i); + if (Number.isFinite(span)) widest = Math.max(widest, Math.abs(span)); + } + } + g._polarMaxSpan = widest; + return widest; + } + + // Geometry of the polar disc, in the units the shaders and the CPU-side + // hover math both need. Null on a cartesian chart. + // + // The GL canvas is sized and positioned to exactly the plot rect, so clip + // space [-1,1] IS the plot rect. Full turns center there; partial sectors fit + // their visible bounding box and can move the polar origin. Radius therefore + // converts to clip units per axis (2R/w, 2R/h) — a vec2, because a round + // circle in a non-square rect cannot use one scalar. + _polarGeometry() { + if (this.spec?.coords !== "polar") return null; + const p = this.plot; + if (!p || !(p.w > 0) || !(p.h > 0)) return null; + const thetaAxis = this._axis("x") || {}; + const radialAxis = this._axis("y") || {}; + const rawZero = thetaAxis.theta_zero ?? "E"; + const zero = typeof rawZero === "string" ? (THETA_ZERO[rawZero] ?? 0) : Number(rawZero) || 0; + const dir = thetaAxis.theta_direction === "clockwise" ? -1 : 1; + const unitScale = thetaAxis.theta_unit === "degrees" ? Math.PI / 180 : 1; + const angularTurn = thetaAxis.theta_unit === "degrees" ? 360 : 2 * Math.PI; + const authoredSector = Array.isArray(thetaAxis.sector) && thetaAxis.sector.length === 2 + ? [Number(thetaAxis.sector[0]), Number(thetaAxis.sector[1])] + : [0, angularTurn]; + const sectorStart = Number.isFinite(authoredSector[0]) ? authoredSector[0] : 0; + const sectorEnd = Number.isFinite(authoredSector[1]) ? authoredSector[1] : angularTurn; + const sectorSpan = Math.max(0, sectorEnd - sectorStart); + const fullSector = sectorSpan >= angularTurn * (1 - 1e-10); + const categories = thetaAxis.kind === "category" ? (thetaAxis.categories || []) : null; + const categoryCount = categories ? categories.length : 0; + let thetaStart = sectorStart; + let thetaEnd = sectorEnd; + let dirUnit = dir * unitScale; + let angleBase = zero; + if (categories) { + // Full turns are N equal bands with no duplicated seam. Partial sectors + // instead place the first and last category centers on the authored + // endpoints, hence N-1 intervals. + const intervals = fullSector + ? Math.max(categoryCount, 1) + : Math.max(categoryCount - 1, 1); + dirUnit = dir * unitScale * sectorSpan / intervals; + angleBase = zero + dir * unitScale * sectorStart; + thetaStart = 0; + thetaEnd = fullSector ? Math.max(categoryCount, 1) : Math.max(categoryCount - 1, 0); + } + const dataTurn = (2 * Math.PI) / Math.max(Math.abs(dirUnit), 1e-30); + const [rLoRaw, rHiRaw] = this._axisRange("y"); + const rLo = this._axisCoord(radialAxis, rLoRaw); + const rHi = this._axisCoord(radialAxis, rHiRaw); + const originRaw = radialAxis.r_origin != null && Number.isFinite(Number(radialAxis.r_origin)) + ? Number(radialAxis.r_origin) + : Number(rLoRaw); + const rOrigin = this._axisCoord(radialAxis, originRaw); + const hole = Math.max(0, Math.min(0.999999, Number(radialAxis.hole) || 0)); + const angleStart = zero + dir * unitScale * sectorStart; + const angleEnd = zero + dir * unitScale * sectorEnd; + let radius; + let cx; + let cy; + if (fullSector) { + radius = Math.min(p.w, p.h) / 2; + cx = p.x + p.w / 2; + cy = p.y + p.h / 2; + } else { + // Fit the sector's actual bounding box, including its visible inner + // boundary. A semicircular gauge should use the whole plot instead of + // reserving an invisible half-disc. + const denom = rHi - rOrigin; + const inner = Math.max(0, Math.min(1, Math.abs(denom) > 1e-30 + ? hole + (1 - hole) * ((rLo - rOrigin) / denom) + : 0)); + const loAngle = Math.min(angleStart, angleEnd); + const hiAngle = Math.max(angleStart, angleEnd); + const angles = [angleStart, angleEnd]; + for (const cardinal of [0, Math.PI / 2, Math.PI, 3 * Math.PI / 2]) { + const first = Math.ceil((loAngle - cardinal) / (2 * Math.PI)); + const last = Math.floor((hiAngle - cardinal) / (2 * Math.PI)); + for (let turnIndex = first; turnIndex <= last; turnIndex++) { + angles.push(cardinal + turnIndex * 2 * Math.PI); + } + } + const xs = []; + const ys = []; + for (const angle of angles) { + xs.push(Math.cos(angle), inner * Math.cos(angle)); + ys.push(-Math.sin(angle), -inner * Math.sin(angle)); + } + if (inner <= 1e-12) { + xs.push(0); + ys.push(0); + } + const xmin = Math.min(...xs); + const xmax = Math.max(...xs); + const ymin = Math.min(...ys); + const ymax = Math.max(...ys); + const xspan = Math.max(xmax - xmin, 1e-12); + const yspan = Math.max(ymax - ymin, 1e-12); + radius = Math.min(p.w / xspan, p.h / yspan); + const left = p.x + (p.w - radius * xspan) / 2; + const top = p.y + (p.h - radius * yspan) / 2; + cx = left - radius * xmin; + cy = top - radius * ymin; + } + return { + radius, + cx, + cy, + clipCx: ((cx - p.x) / p.w) * 2 - 1, + clipCy: 1 - ((cy - p.y) / p.h) * 2, + clipRx: (2 * radius) / p.w, + clipRy: (2 * radius) / p.h, + rLo, + rHi, + rLoRaw, + rHiRaw, + rOrigin, + rOriginRaw: originRaw, + hole, + zero: angleBase, + rawZero: zero, + dir, + dirUnit, + unitScale, + turn: dataTurn, + angularTurn, + thetaStart, + thetaEnd, + sectorStart, + sectorEnd, + sectorSpan, + fullSector, + angleStart, + angleEnd, + categoryCount, + gridShape: thetaAxis.grid_shape || "circular", + }; + } + + _polarPositiveMod(value, period) { + return ((value % period) + period) % period; + } + + _polarThetaValue(geom, angle) { + const raw = (angle - geom.zero) / (geom.dirUnit || 1); + return geom.thetaStart + + this._polarPositiveMod(raw - geom.thetaStart, geom.turn || 1); + } + + _polarThetaVisible(geom, theta) { + const sweep = geom.thetaEnd - geom.thetaStart; + if (sweep >= geom.turn * (1 - 1e-10)) return true; + const offset = this._polarPositiveMod(theta - geom.thetaStart, geom.turn || 1); + return offset <= sweep + geom.turn * 1e-10; + } + + _polarThetaAngle(geom, theta) { + return geom.zero + geom.dirUnit * theta; + } + + _polarRadius(geom, value, { coord = false } = {}) { + const radial = coord ? Number(value) : this._axisCoord(this._axis("y"), value); + const denom = geom.rHi - geom.rOrigin; + if (!Number.isFinite(radial) || !Number.isFinite(denom) || Math.abs(denom) <= 1e-30) { + return NaN; + } + const fraction = geom.hole + + (1 - geom.hole) * ((radial - geom.rOrigin) / denom); + return fraction * geom.radius; + } + + _polarProjectCoords(thetaCoord, rCoord, geom) { + if (!geom || !this._polarThetaVisible(geom, thetaCoord)) return [NaN, NaN]; + const rMin = Math.min(geom.rLo, geom.rHi); + const rMax = Math.max(geom.rLo, geom.rHi); + if (!Number.isFinite(rCoord) || rCoord < rMin - 1e-10 || rCoord > rMax + 1e-10) { + return [NaN, NaN]; + } + const projectedRadius = this._polarRadius(geom, rCoord, { coord: true }); + if (!Number.isFinite(projectedRadius) + || projectedRadius < geom.hole * geom.radius - 1e-6 + || projectedRadius > geom.radius + 1e-6) return [NaN, NaN]; + const angle = this._polarThetaAngle(geom, thetaCoord); + return [ + geom.cx + projectedRadius * Math.cos(angle), + geom.cy - projectedRadius * Math.sin(angle), + ]; + } + + _polarProject(theta, radius, geom = this._polarGeometry()) { + return this._polarProjectCoords( + this._axisCoord(this._axis("x"), theta), + this._axisCoord(this._axis("y"), radius), + geom, + ); + } + + _projectDataPoint(xAxisId, yAxisId, x, y, geom = this._polarGeometry()) { + if (geom) return this._polarProject(x, y, geom); + return [this._dataPx(xAxisId, x), this._dataPx(yAxisId, y)]; + } + + _projectSegmentEndpoints(g, cpu, index, geom = this._polarGeometry()) { + const theta0 = this._decodeValue(cpu.x0, g.x0Meta, index); + const theta1 = this._decodeValue(cpu.x1, g.x1Meta, index); + const radial0 = this._decodeValue(cpu.y0, g.y0Meta, index); + const radial1 = this._decodeValue(cpu.y1, g.y1Meta, index); + if (!geom) { + return [ + this._projectDataPoint(g.xAxis, g.yAxis, theta0, radial0, null), + this._projectDataPoint(g.xAxis, g.yAxis, theta1, radial1, null), + ]; + } + const thetaAxis = this._axis("x"); + const radialAxis = this._axis("y"); + const th0 = this._axisCoord(thetaAxis, theta0); + const th1 = this._axisCoord(thetaAxis, theta1); + const r0 = this._axisCoord(radialAxis, radial0); + const r1 = this._axisCoord(radialAxis, radial1); + const rMin = Math.min(geom.rLo, geom.rHi); + const rMax = Math.max(geom.rLo, geom.rHi); + if (Math.max(r0, r1) < rMin || Math.min(r0, r1) > rMax) { + return [[NaN, NaN], [NaN, NaN]]; + } + const dr = r1 - r0; + let t0 = 0; + let t1 = 1; + if (Math.abs(dr) > 1e-30) { + const ta = (rMin - r0) / dr; + const tb = (rMax - r0) / dr; + t0 = Math.max(0, Math.min(ta, tb)); + t1 = Math.min(1, Math.max(ta, tb)); + } + return [ + this._polarProjectCoords( + th0 + (th1 - th0) * t0, + Math.max(rMin, Math.min(rMax, r0 + dr * t0)), + geom, + ), + this._polarProjectCoords( + th0 + (th1 - th0) * t1, + Math.max(rMin, Math.min(rMax, r0 + dr * t1)), + geom, + ), + ]; + } + + // Concentric rings for radial ticks and spokes for angular ticks. Partial + // sectors get radial edges; `grid_shape="linear"` connects spoke + // intersections into polygon rings instead of drawing circular arcs. + _drawPolarGrid(ctx, geom, thetaTicks, rTicks, thetaAxis, rAxis, hideTheta, hideR) { + const sweep = geom.thetaEnd - geom.thetaStart; + const full = sweep >= geom.turn * (1 - 1e-10); + const angularOffset = (value) => this._polarPositiveMod( + value - geom.thetaStart, + geom.turn || 1, + ); + const thetaValues = [...thetaTicks, ...(full ? [] : [geom.thetaStart, geom.thetaEnd])] + .filter((value) => Number.isFinite(value) && this._polarThetaVisible(geom, value)) + .sort((a, b) => angularOffset(a) - angularOffset(b)) + .filter((value, index, values) => ( + index === 0 || Math.abs(angularOffset(value) - angularOffset(values[index - 1])) > 1e-10 + )); + if (full && thetaValues.length > 1) { + const first = angularOffset(thetaValues[0]); + const last = angularOffset(thetaValues[thetaValues.length - 1]); + if (Math.abs((last - first) - geom.turn) <= geom.turn * 1e-10) thetaValues.pop(); + } + const angles = thetaValues.map((value) => this._polarThetaAngle(geom, value)); + const point = (angle, radius) => [ + geom.cx + radius * Math.cos(angle), + geom.cy - radius * Math.sin(angle), + ]; + const ringPath = (radius) => { + if (!(radius > 0) || radius > geom.radius + 1e-6) return; + if (geom.gridShape === "linear" && angles.length >= (full ? 3 : 2)) { + const [x0, y0] = point(angles[0], radius); + ctx.moveTo(x0, y0); + for (let i = 1; i < angles.length; i++) { + const [x, y] = point(angles[i], radius); + ctx.lineTo(x, y); + } + if (full) ctx.closePath(); + return; + } + if (full) { + ctx.moveTo(geom.cx + radius, geom.cy); + ctx.arc(geom.cx, geom.cy, radius, 0, Math.PI * 2); + } else { + const start = this._polarThetaAngle(geom, geom.thetaStart); + const end = this._polarThetaAngle(geom, geom.thetaEnd); + const [x, y] = point(start, radius); + ctx.moveTo(x, y); + ctx.arc(geom.cx, geom.cy, radius, -start, -end, geom.dir > 0); + } + }; + if (!hideR) { + ctx.strokeStyle = this._axisStylePaint(rAxis, "grid_color", this.theme.grid); + ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(rAxis, "grid_width", 1)); + ctx.globalAlpha = this._axisStyleNumber(rAxis, "grid_opacity", 1); + ctx.setLineDash(this._axisGridDash(rAxis)); + ctx.beginPath(); + for (const v of rTicks) { + ringPath(this._polarRadius(geom, v)); + } + ctx.stroke(); + } + if (hideTheta) return; + ctx.strokeStyle = this._axisStylePaint(thetaAxis, "grid_color", this.theme.grid); + ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(thetaAxis, "grid_width", 1)); + ctx.globalAlpha = this._axisStyleNumber(thetaAxis, "grid_opacity", 1); + ctx.setLineDash(this._axisGridDash(thetaAxis)); + ctx.beginPath(); + const innerRadius = Math.max( + 0, + Math.min(geom.radius, this._polarRadius(geom, geom.rLo, { coord: true })), + ); + for (const a of angles) { + const [x0, y0] = point(a, innerRadius); + const [x1, y1] = point(a, geom.radius); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + } + ctx.stroke(); + // Polar spines cannot be represented by the cartesian DIV spines. + ctx.strokeStyle = this._axisStylePaint(thetaAxis, "axis_color", this.theme.axis); + ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(thetaAxis, "axis_width", 1)); + ctx.globalAlpha = 1; + ctx.setLineDash([]); + ctx.beginPath(); + ringPath(geom.radius); + if (innerRadius > 1e-6) ringPath(innerRadius); + if (!full && angles.length >= 2) { + for (const a of [angles[0], angles[angles.length - 1]]) { + const [x0, y0] = point(a, innerRadius); + const [x1, y1] = point(a, geom.radius); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + } + } + ctx.stroke(); + } + + _setPolarUniforms(prog) { + const gl = this.gl; + const u = (n) => uniformOf(gl, prog, n); + // Fragment-stage polar clipping uses device-pixel gl_FragCoord, including + // in the pick framebuffer. Programs without the clip helper optimize this + // uniform away; WebGL treats the resulting null location as a no-op. + gl.uniform2f(u("u_clipRes"), this.canvas.width, this.canvas.height); + const g = this._polarGeometry(); + if (!g) { + gl.uniform1i(u("u_coordMode"), 0); + return; + } + gl.uniform1i(u("u_coordMode"), 1); + gl.uniform4f(u("u_polar"), g.clipCx, g.clipCy, g.clipRx, g.clipRy); + gl.uniform2f(u("u_rrange"), g.rLo, g.rHi); + gl.uniform2f(u("u_zdir"), g.zero, g.dirUnit); + gl.uniform2f(u("u_trange"), g.thetaStart, g.thetaEnd); + gl.uniform1f(u("u_turn"), g.turn); + gl.uniform2f(u("u_rshape"), g.rOrigin, g.hole); + } + // `keepPick` marks a frame whose ONLY trigger is hover-highlight state: the // highlight lives in the color pass, so the pick framebuffer's geometry/view // snapshot stays valid and the frame must not invalidate it. Coalescing is @@ -4440,6 +5341,7 @@ export class ChartView { gl.uniform2f(u("u_ymap"), ym[0], ym[1]); this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); + this._setPolarUniforms(prog); gl.uniform1f(u("u_dpr"), this.dpr); const zoomStyle = this._pointZoomStyle(g); const transitionOn = !!(g._transitionPrevXBuf && g._transitionPrevYBuf); @@ -4561,6 +5463,7 @@ export class ChartView { gl.uniform2f(u("u_ymap"), ym[0], ym[1]); this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); + this._setPolarUniforms(prog); gl.uniform1f(u("u_dpr"), this.dpr); const zoomStyle = this._pointZoomStyle(g); const transitionOn = !!(g._transitionPrevXBuf && g._transitionPrevYBuf); @@ -4614,6 +5517,7 @@ export class ChartView { gl.uniform2f(u("u_ymap"), ym[0], ym[1]); this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); + this._setPolarUniforms(prog); // Size-channel points hover at their encoded size, not the scalar default // (sample traces keep no CPU copy of the size column; they fall back). const sVal = g.sizeMode === 1 && g._cpu?.size ? g._cpu.size[index] : null; @@ -4722,6 +5626,7 @@ export class ChartView { gl.uniform1f(u("u_xconstant"), this._axisConstant(g.xAxis)); gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis)); gl.uniform1f(u("u_yconstant"), this._axisConstant(g.yAxis)); + this._setPolarUniforms(prog); // Grid row/column 0 anchors to the bottom/left edge of the grid rect in // *display* orientation — the raster/SVG exporters' convention (the shim's // imshow pre-flips rows for origin='upper' assuming it). A reversed axis @@ -4758,6 +5663,7 @@ export class ChartView { gl.uniform2f(u("u_ymap"), ym[0], ym[1]); this._setAxisUniforms(this.lineProg, "u_x", g.xMeta, g.xAxis); this._setAxisUniforms(this.lineProg, "u_y", g.yMeta, g.yAxis); + this._setPolarUniforms(this.lineProg); gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height); const transitionOn = !!(g._transitionPrevXBuf && g._transitionPrevYBuf); gl.uniform1i(u("u_transitionActive"), transitionOn ? 1 : 0); @@ -4815,6 +5721,7 @@ export class ChartView { this._setAxisUniforms(prog, "u_x1", g.x1Meta, g.xAxis); this._setAxisUniforms(prog, "u_y0", g.y0Meta, g.yAxis); this._setAxisUniforms(prog, "u_y1", g.y1Meta, g.yAxis); + this._setPolarUniforms(prog); gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height); gl.uniform1f(u("u_width"), (g.trace.style.width ?? 1.5) * this.dpr); gl.uniform1f(u("u_animationProgress"), g._transitionScale ?? 1); @@ -4880,11 +5787,9 @@ export class ChartView { }; const key = (x, y) => `${Math.round(x * 1000)},${Math.round(y * 1000)}`; const dpr = this.dpr; + const polarGeom = this._polarGeometry(); for (let i = 0; i < n; i++) { - const x0 = this._dataPx(g.xAxis, this._decodeValue(cpu.x0, g.x0Meta, i)); - const x1 = this._dataPx(g.xAxis, this._decodeValue(cpu.x1, g.x1Meta, i)); - const y0 = this._dataPx(g.yAxis, this._decodeValue(cpu.y0, g.y0Meta, i)); - const y1 = this._dataPx(g.yAxis, this._decodeValue(cpu.y1, g.y1Meta, i)); + const [[x0, y0], [x1, y1]] = this._projectSegmentEndpoints(g, cpu, i, polarGeom); k0[i] = key(x0, y0); k1[i] = key(x1, y1); lengths[i] = Math.hypot(x1 - x0, y1 - y0) * dpr; add(k0[i], i); add(k1[i], i); @@ -4991,13 +5896,24 @@ export class ChartView { if (!g._lenArr || g._lenArr.length !== n) g._lenArr = new Float32Array(n); const lens = g._lenArr; const dpr = this.dpr; - let px = this._dataPx(g.xAxis, this._decodeValue(g._dashX, g.xMeta, 0)); - let py = this._dataPx(g.yAxis, this._decodeValue(g._dashY, g.yMeta, 0)); + const polarGeom = this._polarGeometry(); + let [px, py] = this._projectDataPoint( + g.xAxis, + g.yAxis, + this._decodeValue(g._dashX, g.xMeta, 0), + this._decodeValue(g._dashY, g.yMeta, 0), + polarGeom, + ); let acc = 0; lens[0] = 0; for (let i = 1; i < n; i++) { - const nx = this._dataPx(g.xAxis, this._decodeValue(g._dashX, g.xMeta, i)); - const ny = this._dataPx(g.yAxis, this._decodeValue(g._dashY, g.yMeta, i)); + const [nx, ny] = this._projectDataPoint( + g.xAxis, + g.yAxis, + this._decodeValue(g._dashX, g.xMeta, i), + this._decodeValue(g._dashY, g.yMeta, i), + polarGeom, + ); if (Number.isFinite(nx) && Number.isFinite(ny) && Number.isFinite(px) && Number.isFinite(py)) { acc += Math.hypot(nx - px, ny - py) * dpr; } @@ -5034,6 +5950,7 @@ export class ChartView { gl.uniform2f(u("u_bmap"), bm[0], bm[1]); this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); + this._setPolarUniforms(prog); this._setAxisUniforms(prog, "u_b", g.baseMeta, g.yAxis); const reveal = Math.max(0, Math.min(1, g._transitionReveal ?? 1)); gl.uniform1f(u("u_revealProgress"), reveal); @@ -5073,6 +5990,10 @@ export class ChartView { gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis)); gl.uniform1f(u("u_yconstant"), this._axisConstant(g.yAxis)); gl.uniform4f(u("u_edgePad"), edgePad[0], edgePad[1], edgePad[2], edgePad[3]); + // Four edge columns are an annular sector under polar — this is the path + // unequal-width slices (a pie or donut) take, since the compact bar path + // ships one scalar width. + this._setPolarUniforms(prog); const [r, gg, b, a] = g.color; gl.uniform4f(u("u_color"), r, gg, b, a); gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style) * (g._transitionOpacity ?? 1) * (g._legendDim ?? 1)); @@ -5112,7 +6033,21 @@ export class ChartView { if (!styleOn) gl.vertexAttrib4f(ATTR_SLOTS.a_style, 1, -1, -1, -1); if (!strokeOn) gl.vertexAttrib4f(ATTR_SLOTS.a_stroke, ...(g.strokeColor || g.color)); if (!radiusOn) gl.vertexAttrib2f(ATTR_SLOTS.a_radius, -1, -1); - gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); + // Unequal widths ship four edge columns, so one instanced draw covers wedges + // of different sweeps: the count follows the WIDEST of them (recorded once at + // build time, from data alone), which keeps every narrower wedge inside the + // same flattening bound. A pie's widest slice sets the cost for the pie. + const rectGeom = this._polarGeometry(); + const rectPolarSegments = rectGeom + ? xyPolarBarSegments(this._polarRectMaxSpan(g) * rectGeom.dirUnit, 2 * Math.PI) + : 0; + if (rectPolarSegments) { + gl.uniform1f(u("u_wedgeGap"), (Number(g.trace.style?.wedge_gap) || 0) * this.dpr); + gl.uniform1i(u("u_polarSegments"), rectPolarSegments); + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 2 * (rectPolarSegments + 1), g.n); + } else { + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); + } } _drawBars(g, pmap, v1map, v0map, v0Const, v0EdgePad = 0) { @@ -5129,6 +6064,10 @@ export class ChartView { this._setAxisUniforms(prog, "u_p", g.posMeta, pAxis); this._setAxisUniforms(prog, "u_v1", g.value1Meta, vAxis); this._setAxisUniforms(prog, "u_v0", g.value0Meta, vAxis); + // Bars name their axes u_p/u_v rather than u_x/u_y, so they need this + // explicitly — without it u_coordMode stays 0 and a polar bar chart draws + // cartesian rectangles inside correct polar chrome. + this._setPolarUniforms(prog); gl.uniform1i(u("u_pmode"), this._axisMode(pAxis)); gl.uniform1f(u("u_pconstant"), this._axisConstant(pAxis)); gl.uniform1i(u("u_vmode"), this._axisMode(vAxis)); @@ -5200,7 +6139,25 @@ export class ChartView { if (!styleOn) gl.vertexAttrib4f(ATTR_SLOTS.a_style, 1, -1, -1, -1); if (!strokeOn) gl.vertexAttrib4f(ATTR_SLOTS.a_stroke, ...(g.strokeColor || g.color)); if (!radiusOn) gl.vertexAttrib2f(ATTR_SLOTS.a_radius, -1, -1); - gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); + // A polar bar sweeps an annular sector: segments+1 vertex PAIRS instead of + // one quad's four corners. The count follows this trace's own angular width + // (xyPolarBarSegments) rather than the full-turn worst case, at the same + // flattening bound — a 22.5-degree wind-rose sector costs 14 vertices, not + // 194. The compact bar path carries ONE scalar width, so the whole instanced + // draw shares one honest count. + const barGeom = this._polarGeometry(); + const polarSegments = barGeom + ? xyPolarBarSegments(Number(g.width) * barGeom.dirUnit, 2 * Math.PI) + : 0; + if (polarSegments) { + gl.uniform1f(u("u_wedgeGap"), (Number(g.trace.style?.wedge_gap) || 0) * this.dpr); + gl.uniform1i(u("u_polarSegments"), polarSegments); + const vAxisId = g.orientation === 1 ? g.xAxis : g.yAxis; + gl.uniform1f(u("u_polarV0C"), this._axisCoord(this._axis(vAxisId), g.value0Const ?? 0)); + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 2 * (polarSegments + 1), g.n); + } else { + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); + } } _dataPxX(value) { @@ -5211,6 +6168,28 @@ export class ChartView { return this._dataPx("y", value); } + // A point-anchored (theta, r) pair in canvas px. The separable _dataPxX / + // _dataPxY pair cannot express polar placement: it reads (0, 0) — the disc + // centre, at any angle — as the bottom-left corner, and strings a set of + // labels out in a horizontal row in theta order. Mirrors the `point()` + // helper in `_annotation_svg` and the `marker` branch of + // `annotation_label_placement` (python/xy/_svg.py), which the two exporters + // already share; without this the browser and the exports disagree about + // where every annotation on a polar chart belongs. + // + // Point-anchored kinds only. `rule` and `band` are genuinely different + // geometry on a disc (a theta rule is a spoke, an r rule is a ring) and stay + // deferred on the cartesian path, exactly as they do in the exporters. + _dataPxPoint(x, y, xAxisId = "x", yAxisId = "y") { + return this._projectDataPoint( + xAxisId, + yAxisId, + Number(x), + Number(y), + this._polarGeometry(), + ); + } + _styleNumber(style, key, fallback) { if (!style || typeof style !== "object") return fallback; const value = Number(style[key]); @@ -5291,8 +6270,11 @@ export class ChartView { return Number.isFinite(gap) && gap >= 0 ? gap : (dim === "x" ? 8 : 4); } - _estimateTickLabel(text, fontSize) { - const lines = String(text ?? "").replace(/\r\n?/g, "\n").split("\n"); + // `maxWidth` word-wraps the block before measuring, so `h` is the height the + // wrapped text actually occupies. Mirrors `_textblock.measure(max_width=...)` + // in python/xy/_textblock.py, including the wrap rule in `xyWrapLines`. + _estimateTickLabel(text, fontSize, maxWidth = null) { + let lines = String(text ?? "").replace(/\r\n?/g, "\n").split("\n"); const context = typeof document !== "undefined" ? ( this._tickMeasureCanvas @@ -5303,12 +6285,13 @@ export class ChartView { // the browser's generic sans-serif while the DOM paints system-ui can // under-reserve long y labels enough to consume the title's 0.4 em gap. if (context) context.font = `${fontSize}px system-ui, sans-serif`; + const advance = (line) => + context?.measureText(line).width || xyTextAdvance(line, fontSize); + const limit = Number(maxWidth); + if (Number.isFinite(limit) && limit > 0) lines = xyWrapLines(lines, advance, limit); return { lines, - w: Math.max( - fontSize * 0.7, - ...lines.map((line) => context?.measureText(line).width || xyTextAdvance(line, fontSize)), - ), + w: Math.max(fontSize * 0.7, ...lines.map(advance)), h: Math.max(fontSize * 1.2, lines.length * fontSize * 1.2), lineStep: fontSize * 1.2, }; @@ -5509,7 +6492,15 @@ export class ChartView { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, this.size.w, this.size.h); const now = this._now(); - const labelCadenceMs = this._viewAnim ? 80 : 0; + // Tick labels are DOM: `this.labels` is emptied and every label, baseline + // and tick div is recreated. A view animation throttles that to 80 ms + // because the ranges are moving. A DATA animation was not throttled at all, + // so an entrance or update transition rebuilt the whole label layer 60 times + // a second while the axes stood still — the labels are identical between + // those frames unless `_transitionView` is also interpolating the view, and + // that case is covered by the same cadence. The final frame runs with + // `_dataAnim` already cleared, so the settled labels always land. + const labelCadenceMs = (this._viewAnim || this._dataAnim) ? 80 : 0; const updateLabels = labelCadenceMs === 0 || this._lastLabelDraw === null || now - this._lastLabelDraw >= labelCadenceMs; @@ -5554,12 +6545,16 @@ export class ChartView { const xEdge = (px) => Math.min(p.x + p.w - 0.5, Math.max(p.x + 0.5, Math.round(px) + 0.5)); const yEdge = (py) => Math.min(p.y + p.h - 0.5, Math.max(p.y + 0.5, Math.round(py) + 0.5)); + const polarGeom = this._polarGeometry(); + if (polarGeom) { + this._drawPolarGrid(ctx, polarGeom, xt.ticks, yt.ticks, xAxis, yAxis, hideX, hideY); + } ctx.strokeStyle = this._axisStylePaint(xmAxis, "grid_color", "transparent"); ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(xmAxis, "grid_width", 1)); ctx.globalAlpha = this._axisStyleNumber(xmAxis, "grid_opacity", 1); ctx.setLineDash(this._axisGridDash(xmAxis)); ctx.beginPath(); - for (const v of (hideX ? [] : xmt)) { + for (const v of (hideX || polarGeom ? [] : xmt)) { const px = this._dataPx("x", v); if (!Number.isFinite(px)) continue; const x = xEdge(px); @@ -5573,7 +6568,7 @@ export class ChartView { ctx.globalAlpha = this._axisStyleNumber(ymAxis, "grid_opacity", 1); ctx.setLineDash(this._axisGridDash(ymAxis)); ctx.beginPath(); - for (const v of (hideY ? [] : ymt)) { + for (const v of (hideY || polarGeom ? [] : ymt)) { const py = this._dataPx("y", v); if (!Number.isFinite(py)) continue; const y = yEdge(py); @@ -5587,7 +6582,7 @@ export class ChartView { ctx.globalAlpha = this._axisStyleNumber(xAxis, "grid_opacity", 1); ctx.setLineDash(this._axisGridDash(xAxis)); ctx.beginPath(); - for (const v of (hideX ? [] : xt.ticks)) { + for (const v of (hideX || polarGeom ? [] : xt.ticks)) { const px = this._dataPx("x", v); if (!Number.isFinite(px)) continue; const x = xEdge(px); @@ -5601,7 +6596,7 @@ export class ChartView { ctx.globalAlpha = this._axisStyleNumber(yAxis, "grid_opacity", 1); ctx.setLineDash(this._axisGridDash(yAxis)); ctx.beginPath(); - for (const v of (hideY ? [] : yt.ticks)) { + for (const v of (hideY || polarGeom ? [] : yt.ticks)) { const py = this._dataPx("y", v); if (!Number.isFinite(py)) continue; const y = yEdge(py); @@ -5640,10 +6635,15 @@ export class ChartView { "pointer-events:none;"; this.labels.appendChild(d); }; - const frameSides = Array.isArray(s.frame_sides) - ? s.frame_sides - : [xAxis.side || "bottom", yAxis.side || "left"]; - const explicitFrameSides = Array.isArray(s.frame_sides); + // Under polar the frame is one ring drawn on the chrome canvas below; + // "side" has no polar meaning, so frame_sides is not consulted. Axis + // spines are background-coloured DIVs and cannot express a circle. + const frameSides = polarGeom + ? [] + : (Array.isArray(s.frame_sides) + ? s.frame_sides + : [xAxis.side || "bottom", yAxis.side || "left"]); + const explicitFrameSides = !polarGeom && Array.isArray(s.frame_sides); if (!hideY || explicitFrameSides) { const yWidth = Math.max(1, this._axisStyleNumber(yAxis, "axis_width", 1)); if (frameSides.includes("left")) rule(yAxis, p.x, p.y, yWidth, p.h); @@ -5667,7 +6667,9 @@ export class ChartView { rule(axis, x, p.y, w, p.h); } - if (!hideX) { + // Edge-anchored tick marks have no polar geometry; the spec records + // tick_length/tick_width/tick_direction as ignored under polar. + if (!hideX && !polarGeom) { const minorTick = tickParts(xmAxis); const minorSide = xAxis.side || "bottom"; const minorEdge = minorSide === "top" ? p.y : p.y + p.h; @@ -5699,7 +6701,7 @@ export class ChartView { } } } - if (!hideY) { + if (!hideY && !polarGeom) { const minorTick = tickParts(ymAxis); const minorSide = yAxis.side || "left"; const minorEdge = minorSide === "right" ? p.x + p.w : p.x; @@ -5900,6 +6902,11 @@ export class ChartView { return pad + fontRoomPx; }; for (const side of this._axisTickLabelSides(xAxis)) { + // Polar places its own labels around the rim below; sides are meaningless + // on a disc. Guarded here rather than around the loop so all four axis + // paths keep calling _axisTickLabelSides (asserted by a source guard in + // tests/pyplot/test_tick_side_rendering.py). + if (polarGeom) break; const sideAxis = { ...xAxis, side }; for (const item of this._layoutTickLabels(sideAxis, "x", xLabelCandidates)) { const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4); @@ -5982,7 +6989,55 @@ export class ChartView { angle, }; }; + if (polarGeom) { + // Angular labels around the rim, radial labels along the 22.5-degree + // spoke. Mirrors _polar_tick_labels in python/xy/_svg.py; the cartesian + // label machinery is edge-relative and neither concept survives a disc. + const RLABEL = (POLAR_RLABEL_DEG * Math.PI) / 180; + const GAP = POLAR_TICK_GAP; + // "off" hides only the label text; "none" (hideX/hideY) kills the chrome. + const offX = this._axisTickLabelStrategy(xAxis) === "off"; + const offY = this._axisTickLabelStrategy(yAxis) === "off"; + if (!hideX && !offX) { + for (const v of (xt.labels || xt.ticks)) { + const a = this._polarThetaAngle(polarGeom, v); + const cos = Math.cos(a); + const sin = Math.sin(a); + const lx = polarGeom.cx + (polarGeom.radius + GAP) * cos; + const ly = polarGeom.cy - (polarGeom.radius + GAP) * sin; + const align = Math.abs(cos) < 0.3 ? "-50%" : (cos > 0 ? "0%" : "-100%"); + const vshift = Math.abs(sin) < 0.3 ? "-50%" : (sin > 0 ? "-100%" : "0%"); + const spinX = Number(xAxis.tick_label_angle) || 0; + label( + this._axisTickText(xAxis, v, xt.step), + `left:${lx}px;top:${ly}px;transform:translate(${align}, ${vshift})` + + (spinX ? ` rotate(${spinX}deg)` : "") + ";", + xAxis, + ); + } + } + if (!hideY && !offY) { + const sectorSweep = Math.abs(polarGeom.dirUnit) + * Math.max(0, polarGeom.thetaEnd - polarGeom.thetaStart); + const labelOffset = Math.min(RLABEL, sectorSweep / 2); + const angle = this._polarThetaAngle(polarGeom, polarGeom.thetaStart) + + Math.sign(polarGeom.dirUnit || 1) * labelOffset; + for (const v of this._polarThinRadialLabels(yt.labels || yt.ticks, polarGeom)) { + const radius = this._polarRadius(polarGeom, v); + if (!(radius > 0) || radius > polarGeom.radius + 1e-6) continue; + const spinY = Number(yAxis.tick_label_angle) || 0; + label( + this._axisTickText(yAxis, v, yt.step), + `left:${polarGeom.cx + radius * Math.cos(angle) + 3}px;` + + `top:${polarGeom.cy - radius * Math.sin(angle) - 3}px;` + + `transform:translate(0, -100%)` + (spinY ? ` rotate(${spinY}deg)` : "") + ";", + yAxis, + ); + } + } + } for (const side of this._axisTickLabelSides(yAxis)) { + if (polarGeom) break; const sideAxis = { ...yAxis, side }; for (const item of this._layoutTickLabels(sideAxis, "y", yLabelCandidates)) { const placement = yLabelPlacement(sideAxis, side === "right", item); @@ -6151,6 +7206,11 @@ export class ChartView { gl.uniform2f(u("u_ymap"), ym[0], ym[1]); this._setAxisUniforms(prog, "u_x", pg.xMeta, pg.xAxis || g.xAxis); this._setAxisUniforms(prog, "u_y", pg.yMeta, pg.yAxis || g.yAxis); + // The pick buffer must use the SAME transform as the colour pass. Left + // cartesian under polar it still returns ids, so the picture stays right + // while hover silently reports whichever row happens to sit at the + // untransformed location. + this._setPolarUniforms(prog); const zoomStyle = this._pointZoomStyle(pg); gl.uniform1f(u("u_size"), pg.size * zoomStyle.sizeFactor); gl.uniform1i(u("u_sizeMode"), pg.sizeMode); @@ -6240,6 +7300,25 @@ export class ChartView { const [y0, y1] = this._axisRange(yAxisId); const xAxis = this._axis(xAxisId); const yAxis = this._axis(yAxisId); + const geom = this._polarGeometry(); + if (geom) { + // Screen -> (theta, r), the inverse of xyPolarPos. cssX/cssY are canvas + // relative, whereas geometry is chart-relative. + const dx = cssX - (geom.cx - this.plot.x); + const dy = (geom.cy - this.plot.y) - cssY; // flip out of screen space + const displayed = Math.hypot(dx, dy) / (geom.radius || 1); + if (displayed > 1 + 1e-10 || displayed < geom.hole - 1e-10) { + return [NaN, NaN]; + } + const theta = this._polarThetaValue(geom, Math.atan2(dy, dx)); + if (!this._polarThetaVisible(geom, theta)) return [NaN, NaN]; + const radialFraction = (displayed - geom.hole) / Math.max(1 - geom.hole, 1e-30); + const rCoord = geom.rOrigin + radialFraction * (geom.rHi - geom.rOrigin); + const rMin = Math.min(geom.rLo, geom.rHi); + const rMax = Math.max(geom.rLo, geom.rHi); + if (rCoord < rMin - 1e-10 || rCoord > rMax + 1e-10) return [NaN, NaN]; + return [theta, this._axisValue(yAxis, rCoord)]; + } const cx0 = this._axisCoord(xAxis, x0); const cx1 = this._axisCoord(xAxis, x1); const cy0 = this._axisCoord(yAxis, y0); @@ -6276,9 +7355,66 @@ export class ChartView { return best; } + _nearestPolarCpuIndex(g, cssX, cssY) { + const cpu = g && g._cpu; + if (!cpu || !cpu.x || !cpu.y) return -1; + const xMeta = cpu.xMeta || g.xMeta; + const yMeta = cpu.yMeta || g.yMeta; + const progress = g._transitionPositionProgress; + const limit = Math.min(cpu.x.length, cpu.y.length, g.n || cpu.x.length); + const geom = this._polarGeometry(); + let best = -1; + let bestDist = Infinity; + for (let i = 0; i < limit; i++) { + const xEncoded = g._transitionPrevXValues && Number.isFinite(progress) + ? g._transitionPrevXValues[i] + (cpu.x[i] - g._transitionPrevXValues[i]) * progress + : cpu.x[i]; + const yEncoded = g._transitionPrevYValues && Number.isFinite(progress) + ? g._transitionPrevYValues[i] + (cpu.y[i] - g._transitionPrevYValues[i]) * progress + : cpu.y[i]; + const x = xEncoded / (xMeta.scale || 1) + xMeta.offset; + const y = yEncoded / (yMeta.scale || 1) + yMeta.offset; + const [chartX, chartY] = this._projectDataPoint(g.xAxis, g.yAxis, x, y, geom); + const dist = Math.hypot(chartX - this.plot.x - cssX, chartY - this.plot.y - cssY); + if (Number.isFinite(dist) && dist < bestDist) { + bestDist = dist; + best = i; + } + } + return best; + } + + _segmentHover(g, cssX, cssY, maxPx) { + const cpu = g && g._segmentCpu; + if (!cpu) return null; + let best = null; + const limit = Math.min(cpu.x0.length, cpu.x1.length, cpu.y0.length, cpu.y1.length, g.n); + const geom = this._polarGeometry(); + for (let i = 0; i < limit; i++) { + const [[x0, y0], [x1, y1]] = this._projectSegmentEndpoints(g, cpu, i, geom); + const ax = x0 - this.plot.x; + const ay = y0 - this.plot.y; + const bx = x1 - this.plot.x; + const by = y1 - this.plot.y; + if (![ax, ay, bx, by].every(Number.isFinite)) continue; + const vx = bx - ax; + const vy = by - ay; + const denom = vx * vx + vy * vy; + const t = denom > 0 + ? Math.max(0, Math.min(1, ((cssX - ax) * vx + (cssY - ay) * vy) / denom)) + : 0; + const dist = Math.hypot(cssX - (ax + t * vx), cssY - (ay + t * vy)); + if (dist <= maxPx && (!best || dist < best.dist)) { + best = { trace: g.trace.id, index: i, g, dist, synthetic: true }; + } + } + return best; + } + _hoverAt(cssX, cssY) { const maxPx = 12; let best = null; + const polarGeom = this._polarGeometry(); for (const g of this.gpuTraces) { if (g.tier === "density") continue; const [dataX, dataY] = this._dataFromCanvas(cssX, cssY, g.xAxis, g.yAxis); @@ -6298,8 +7434,15 @@ export class ChartView { if (hit) return hit; continue; } + if (g._segmentCpu) { + const hit = this._segmentHover(g, cssX, cssY, maxPx); + if (hit && (!best || hit.dist < best.dist)) best = hit; + continue; + } if (!g._cpu || !g._cpu.x || !g._cpu.y) continue; - const idx = this._nearestCpuIndex(g, dataX); + const idx = polarGeom + ? this._nearestPolarCpuIndex(g, cssX, cssY) + : this._nearestCpuIndex(g, dataX); if (idx < 0) continue; const progress = g._transitionPositionProgress; const xEncoded = g._transitionPrevXValues && Number.isFinite(progress) @@ -6310,8 +7453,15 @@ export class ChartView { : g._cpu.y[idx]; const x = xEncoded / (g._cpu.xMeta.scale || 1) + g._cpu.xMeta.offset; const y = yEncoded / (g._cpu.yMeta.scale || 1) + g._cpu.yMeta.offset; - const px = this._dataPx(g.xAxis, x) - this.plot.x; - const py = this._dataPx(g.yAxis, y) - this.plot.y; + const [chartX, chartY] = this._projectDataPoint( + g.xAxis, + g.yAxis, + x, + y, + polarGeom, + ); + const px = chartX - this.plot.x; + const py = chartY - this.plot.y; const dist = Math.hypot(px - cssX, py - cssY); if (dist <= maxPx && (!best || dist < best.dist)) { best = { trace: g.trace.id, index: idx, g, dist, synthetic: true }; @@ -6320,9 +7470,21 @@ export class ChartView { return best; } + // Seam-aware angular containment for wedge hover: |dataX - centre| in + // unwrapped data space misses any wedge straddling theta = 0/turn (a + // wind-rose "N" sector), which draws fine and was silently un-hoverable on + // its wrap side. Distances re-base through the same positive-mod the + // heatmap inverse uses (spec section 3.2: any angular metric must wrap). + _polarAngularDistance(geom, a, b) { + const turn = geom.turn || 1; + const forward = this._polarPositiveMod(a - b, turn); + return Math.min(forward, turn - forward); + } + _barHover(g, dataX, dataY) { const cpu = g._cpu; const horizontal = g.orientation === 1; + const geom = this._polarGeometry(); const limit = Math.min(cpu.x.length, cpu.y.length, g.n || cpu.x.length); for (let i = 0; i < limit; i++) { const x = this._decodeValue(cpu.x, cpu.xMeta, i); @@ -6336,8 +7498,13 @@ export class ChartView { if (dataX >= lo && dataX <= hi && Math.abs(dataY - y) <= g.width / 2) { return { trace: g.trace.id, index: i, g, synthetic: true }; } - } else if (Math.abs(dataX - x) <= g.width / 2 && dataY >= lo && dataY <= hi) { - return { trace: g.trace.id, index: i, g, synthetic: true }; + } else { + const near = geom + ? this._polarAngularDistance(geom, dataX, x) <= g.width / 2 + : Math.abs(dataX - x) <= g.width / 2; + if (near && dataY >= lo && dataY <= hi) { + return { trace: g.trace.id, index: i, g, synthetic: true }; + } } } return null; @@ -6345,16 +7512,31 @@ export class ChartView { _rectHover(g, dataX, dataY) { const r = g._cpuRect; + const geom = this._polarGeometry(); const limit = Math.min(r.x0.length, r.x1.length, r.y0.length, r.y1.length, g.n || r.x0.length); for (let i = 0; i < limit; i++) { const x0 = this._decodeValue(r.x0, r.x0Meta, i); const x1 = this._decodeValue(r.x1, r.x1Meta, i); const y0 = this._decodeValue(r.y0, r.y0Meta, i); const y1 = this._decodeValue(r.y1, r.y1Meta, i); - if ( - dataX >= Math.min(x0, x1) && dataX <= Math.max(x0, x1) && - dataY >= Math.min(y0, y1) && dataY <= Math.max(y0, y1) - ) { + let insideX; + if (geom) { + // Both renderers draw the band as the DIRECT interval between the two + // edges — GLSL takes `abs(a1 - a0)` with `dir = a1 >= a0 ? 1 : -1`, and + // `wedge_angles` takes `min(raw0, raw1) .. max(raw0, raw1)` — so the + // span is unwrapped and edge order carries no meaning. Measuring it + // with a *directional* `mod(x1 - x0, turn)` while anchoring at + // `min(x0, x1)` made the two disagree: a descending pair (350, 300) + // covered 300..610 instead of 300..350. Only the offset needs wrapping, + // so a seam-crossing bar whose edges are emitted unwrapped (-15..15) is + // still reachable from dataX = 355. + const turn = geom.turn || 1; + const span = Math.abs(x1 - x0); + insideX = this._polarPositiveMod(dataX - Math.min(x0, x1), turn) <= span; + } else { + insideX = dataX >= Math.min(x0, x1) && dataX <= Math.max(x0, x1); + } + if (insideX && dataY >= Math.min(y0, y1) && dataY <= Math.max(y0, y1)) { return { trace: g.trace.id, index: i, g, synthetic: true }; } } @@ -6366,12 +7548,18 @@ export class ChartView { if (!h || !g._cpuHeatmap) return null; const [x0, x1] = h.xRange; const [y0, y1] = h.yRange; - if (dataX < x0 || dataX > x1 || dataY < y0 || dataY > y1) return null; + const geom = this._polarGeometry(); + const sampleX = geom + ? x0 + this._polarPositiveMod(dataX - x0, geom.turn) + : dataX; + if (sampleX < Math.min(x0, x1) || sampleX > Math.max(x0, x1) + || dataY < Math.min(y0, y1) || dataY > Math.max(y0, y1)) return null; // Mirror _drawHeatmap's display-orientation anchoring: on a reversed axis // buffer row/column 0 sits at the opposite end of the data range. const [ax0, ax1] = this._axisRange(g.xAxis) ?? [this.view.x0, this.view.x1]; const [ay0, ay1] = this._axisRange(g.yAxis) ?? [this.view.y0, this.view.y1]; - const fx = ((ax0 ?? this.view.x0) > (ax1 ?? this.view.x1)) ? (x1 - dataX) : (dataX - x0); + const fx = ((ax0 ?? this.view.x0) > (ax1 ?? this.view.x1)) + ? (x1 - sampleX) : (sampleX - x0); const fy = ((ay0 ?? this.view.y0) > (ay1 ?? this.view.y1)) ? (y1 - dataY) : (dataY - y0); const col = Math.min(h.w - 1, Math.max(0, Math.floor((fx / (x1 - x0)) * h.w))); const row = Math.min(h.h - 1, Math.max(0, Math.floor((fy / (y1 - y0)) * h.h))); @@ -6607,22 +7795,17 @@ export class ChartView { this._deleteVaos(g); this._deleteVaos(g._homeDecimated); this._deleteVaos(g.drill); - this._deleteBuffers(g, [ - "xBuf", "yBuf", "cBuf", "sBuf", "selBuf", "baseBuf", - "x0Buf", "x1Buf", "x2Buf", "y0Buf", "y1Buf", "y2Buf", - "posBuf", "value1Buf", "value0Buf", - "_transitionPrevXBuf", "_transitionPrevYBuf", - "_transitionPrevPosBuf", "_transitionPrevValue1Buf", "_transitionPrevValue0Buf", - ]); + // TRACE_GPU_BUFFERS is the single list of every buffer field a built trace + // can own; the build paths and this teardown must not drift apart, so both + // sides read the same names (see the constant for how it is enforced). + this._deleteBuffers(g, TRACE_GPU_BUFFERS); // Only geometry is owned independently by the retained M4 overview; // style/channel buffers are shared with the live trace and were deleted // above exactly once. if (g._decimatedRefined) { this._deleteBuffers(g._homeDecimated, ["xBuf", "yBuf", "baseBuf"]); } - this._deleteBuffers(g.drill, [ - "xBuf", "yBuf", "cBuf", "rgbaBuf", "sBuf", "styleBuf", "strokeBuf", "selBuf", "dBuf", - ]); + this._deleteBuffers(g.drill, TRACE_GPU_BUFFERS); const textures = []; if (g.heatmap) textures.push(g.heatmap.tex); for (const d of g.densityCache || []) textures.push(d && d.tex); diff --git a/js/src/51_annotations.ts b/js/src/51_annotations.ts index 4329eb71..a3f43ebe 100644 --- a/js/src/51_annotations.ts +++ b/js/src/51_annotations.ts @@ -284,6 +284,41 @@ function xyTaperPolygon(points, w0, w1) { return left.concat(right.reverse()); } +// Canvas-authored scatter glyphs and clipped annotation marks share the same +// annular-sector boundary as GL/SVG marks. A rectangular canvas clip lets a +// large marker bleed into the hole (or a chord cross a sector's missing +// wedge), so trace the visible outer arc and return along the inner arc. +function xyClipPolarCanvas(ctx, geom) { + const denom = geom.rHi - geom.rOrigin; + const visibleFraction = Math.abs(denom) > 1e-30 + ? geom.hole + (1 - geom.hole) * ((geom.rLo - geom.rOrigin) / denom) + : geom.hole; + const inner = Math.max( + 0, + Math.min(geom.radius, visibleFraction * geom.radius), + ); + const start = -geom.angleStart; + const end = -geom.angleEnd; + const anticlockwise = geom.dir > 0; + ctx.beginPath(); + ctx.moveTo( + geom.cx + geom.radius * Math.cos(start), + geom.cy + geom.radius * Math.sin(start), + ); + ctx.arc(geom.cx, geom.cy, geom.radius, start, end, anticlockwise); + if (inner > 1e-6) { + ctx.lineTo( + geom.cx + inner * Math.cos(end), + geom.cy + inner * Math.sin(end), + ); + ctx.arc(geom.cx, geom.cy, inner, end, start, !anticlockwise); + } else { + ctx.lineTo(geom.cx, geom.cy); + } + ctx.closePath(); + ctx.clip(); +} + Object.assign(ChartView.prototype, { _authoredScatterRgba(g, index, continuousLut = null, paletteRgba = null) { if (g.colorMode === 3 && g._cpu.rgba) { @@ -312,10 +347,12 @@ Object.assign(ChartView.prototype, { ); if (!draws.length) return; const p = this.plot; + const polarGeom = this._polarGeometry(); ctx.save(); ctx.beginPath(); ctx.rect(p.x, p.y, p.w, p.h); ctx.clip(); + if (polarGeom) xyClipPolarCanvas(ctx, polarGeom); for (const { g, opacityScale } of draws) { if (!g._cpu) continue; const style = g.trace.style || {}; @@ -336,8 +373,7 @@ Object.assign(ChartView.prototype, { const sourceIndex = g._visMap ? g._visMap[index] : index; const x = this._decodeValue(g._cpu.x, g.xMeta, sourceIndex); const y = this._decodeValue(g._cpu.y, g.yMeta, sourceIndex); - const px = this._dataPx(g.xAxis, x); - const py = this._dataPx(g.yAxis, y); + const [px, py] = this._projectDataPoint(g.xAxis, g.yAxis, x, y, polarGeom); if (!Number.isFinite(px) || !Number.isFinite(py)) continue; const sizeValue = g.sizeMode === 1 && g._cpu.size ? g.sizeRange[0] + (g.sizeRange[1] - g.sizeRange[0]) * @@ -561,25 +597,35 @@ Object.assign(ChartView.prototype, { const annotations = Array.isArray(this.spec.annotations) ? this.spec.annotations : []; if (!annotations.length) return; const p = this.plot; + const polarGeom = this._polarGeometry(); + const project = (x, y) => this._projectDataPoint( + "x", + "y", + Number(x), + Number(y), + polarGeom, + ); for (const [annotationIndex, ann] of annotations.entries()) { ctx.save(); let targetX = NaN; let targetY = NaN; if (ann.kind === "arrow") { - targetX = this._dataPxX(Number(ann.x1)); - targetY = this._dataPxY(Number(ann.y1)); + [targetX, targetY] = project(ann.x1, ann.y1); } else if (ann.kind === "callout") { - targetX = this._dataPxX(Number(ann.x)); - targetY = this._dataPxY(Number(ann.y)); + [targetX, targetY] = project(ann.x, ann.y); } const connectorTargetInBounds = Number.isFinite(targetX) && Number.isFinite(targetY) && targetX >= p.x && targetX <= p.x + p.w && targetY >= p.y && targetY <= p.y + p.h; if (!connectorTargetInBounds) { - ctx.beginPath(); - ctx.rect(p.x, p.y, p.w, p.h); - ctx.clip(); + if (polarGeom) { + xyClipPolarCanvas(ctx, polarGeom); + } else { + ctx.beginPath(); + ctx.rect(p.x, p.y, p.w, p.h); + ctx.clip(); + } } const style = ann && typeof ann.style === "object" ? ann.style : {}; if (ann.kind === "band") { @@ -641,17 +687,11 @@ Object.assign(ChartView.prototype, { ctx.stroke(); ctx.restore(); } else if (ann.kind === "arrow") { - this._drawArrowLine( - ctx, - this._dataPxX(Number(ann.x0)), - this._dataPxY(Number(ann.y0)), - this._dataPxX(Number(ann.x1)), - this._dataPxY(Number(ann.y1)), - style - ); + const [arrowX0, arrowY0] = this._dataPxPoint(Number(ann.x0), Number(ann.y0)); + const [arrowX1, arrowY1] = this._dataPxPoint(Number(ann.x1), Number(ann.y1)); + this._drawArrowLine(ctx, arrowX0, arrowY0, arrowX1, arrowY1, style); } else if (ann.kind === "callout") { - const px = this._dataPxX(Number(ann.x)); - const py = this._dataPxY(Number(ann.y)); + const [px, py] = project(ann.x, ann.y); const resolved = this._resolvedAnnotationAnchors?.get(annotationIndex); const dx = Number.isFinite(Number(ann.dx)) ? Number(ann.dx) : 0; const dy = Number.isFinite(Number(ann.dy)) ? Number(ann.dy) : 0; @@ -662,13 +702,8 @@ Object.assign(ChartView.prototype, { const labelY = resolved?.y ?? py + dy; this._drawArrowLine(ctx, labelX, labelY, px, py, style); } else if (ann.kind === "marker") { - this._drawAnnotationMarker( - ctx, - this._dataPxX(Number(ann.x)), - this._dataPxY(Number(ann.y)), - style, - ann - ); + const [markerX, markerY] = this._dataPxPoint(Number(ann.x), Number(ann.y)); + this._drawAnnotationMarker(ctx, markerX, markerY, style, ann); } ctx.restore(); } @@ -702,8 +737,7 @@ Object.assign(ChartView.prototype, { px = this._dataPxX(Number(ann.x)); py = p.y + (1 - Number(ann.y)) * p.h; } else { - px = this._dataPxX(Number(ann.x)); - py = this._dataPxY(Number(ann.y)); + [px, py] = this._dataPxPoint(Number(ann.x), Number(ann.y)); } } else if (ann.kind === "rule") { if (ann.axis === "x") { @@ -722,10 +756,8 @@ Object.assign(ChartView.prototype, { py = (this._dataPxY(Number(ann.start)) + this._dataPxY(Number(ann.end))) / 2; } } else if (ann.kind === "arrow") { - const ax0 = this._dataPxX(Number(ann.x0)); - const ay0 = this._dataPxY(Number(ann.y0)); - const ax1 = this._dataPxX(Number(ann.x1)); - const ay1 = this._dataPxY(Number(ann.y1)); + const [ax0, ay0] = this._dataPxPoint(Number(ann.x0), Number(ann.y0)); + const [ax1, ay1] = this._dataPxPoint(Number(ann.x1), Number(ann.y1)); px = (ax0 + ax1) / 2; py = (ay0 + ay1) / 2; // Upward unit normal of the shaft: the label lifts along it (after @@ -735,12 +767,8 @@ Object.assign(ChartView.prototype, { lift = [-(ay1 - ay0) / len, (ax1 - ax0) / len]; if (lift[1] > 0) lift = [-lift[0], -lift[1]]; } - } else if (ann.kind === "callout") { - px = this._dataPxX(Number(ann.x)); - py = this._dataPxY(Number(ann.y)); - } else if (ann.kind === "marker") { - px = this._dataPxX(Number(ann.x)); - py = this._dataPxY(Number(ann.y)); + } else if (ann.kind === "callout" || ann.kind === "marker") { + [px, py] = this._dataPxPoint(Number(ann.x), Number(ann.y)); } if (!Number.isFinite(px) || !Number.isFinite(py)) continue; if (px < p.x - 24 || px > p.x + p.w + 24 || py < p.y - 24 || py > p.y + p.h + 24) { diff --git a/js/src/52_tooltip.ts b/js/src/52_tooltip.ts index 31a911f1..dd37da11 100644 --- a/js/src/52_tooltip.ts +++ b/js/src/52_tooltip.ts @@ -192,15 +192,142 @@ Object.assign(ChartView.prototype, { return { label: fallback, customized: false }; }, + // The trace's own name, when it has one. The hover row carries `trace` (an + // id), never the label, so the default readout used to identify a mark only + // by its coordinates — on a pie that meant "x: 102.6, y: 0.94" for a slice + // whose whole identity is "Cloudpeak $13B". Every other library leads its + // tooltip with the series name; so does this one now. + _tooltipSeriesName(row) { + const traces = Array.isArray(this.spec.traces) ? this.spec.traces : []; + const trace = traces.find((t) => t && t.id === row.trace); + const name = trace && trace.name; + return typeof name === "string" && name.trim() ? name : null; + }, + + // Under polar the two channels are not x and y, and saying so is actively + // misleading: "x: 1.5708" on a radar names a spoke the chart labels "power". + // More than that: the default readout shows VALUES, not angles — on most + // polar charts the angle is where the layout put the mark, and the cursor + // is already sitting on it. A numeric angle row is therefore OMITTED by + // default; an authored spoke label (a radar category) survives because it + // is a name, not an angle; and an explicit `labels={"x": ...}` opts the + // angle row back in, formatted through the axis's own text function so + // degrees keep their sign and radians read as pi-fractions. + _polarTooltipField(channel, value, kind) { + if (this.spec?.coords !== "polar") return null; + const axis = this._axis(channel === "x" ? "x" : "y") || {}; + if (channel === "y") return { label: "r", value: fmtValue(value, kind) }; + // Authored spoke labels first, matched with tolerance. `_axisTickText` + // compares tick values exactly, but the hovered angle arrives as decoded + // offset-encoded f32 (§4/§16) while the tick was authored in f64 — so a + // radar's pi/2 spoke missed its own label by ~1e-7 and fell back to + // "1.57". The tolerance is relative to the spacing, so it can never reach + // a neighbouring spoke. + const values = Array.isArray(axis.tick_values) ? axis.tick_values : null; + const texts = Array.isArray(axis.tick_labels) ? axis.tick_labels : null; + if (values && texts) { + let span = Infinity; + for (let i = 1; i < values.length; i++) { + span = Math.min(span, Math.abs(Number(values[i]) - Number(values[i - 1]))); + } + const tol = Number.isFinite(span) ? span / 8 : 1e-6; + for (let i = 0; i < values.length && i < texts.length; i++) { + if (Math.abs(Number(values[i]) - Number(value)) <= tol) { + return { label: "θ", value: String(texts[i]) }; + } + } + } + const step = this._axisTicks?.("x", 6)?.step ?? 1; + let text; + try { + text = this._axisTickText(axis, value, step); + } catch { + text = null; + } + return { label: "θ", value: text || fmtValue(value, kind), omit: true }; + }, + + // A pie slice or a gauge band is one named wedge: the name IS the datum, + // and theta/r are how the layout happened to place it — "Direct - 40%" + // followed by "theta: 72, r: 1" answers a question nobody asked. A trace + // with MANY wedges (a wind rose, an angular histogram) keeps theta/r, + // because there each wedge's angle and radius are the data. Explicit + // `labels=` overrides still win via the customized path below. + _isNamedSingleWedge(row) { + return this._namedWedge(row) !== null; + }, + + // A pie slice or a gauge band is ONE named wedge whose datum is its angular + // width. Returns that trace when the hovered row is such a wedge. + _namedWedge(row) { + if (this.spec?.coords !== "polar") return null; + const traces = Array.isArray(this.spec.traces) ? this.spec.traces : []; + const trace = traces.find((t) => t && t.id === row.trace); + if (!trace || typeof trace.name !== "string" || !trace.name.trim()) return null; + const wedge = trace.kind === "bar" || trace.kind === "column" || trace.bar !== undefined + || (trace.x0 !== undefined && trace.y0 !== undefined); + const count = trace.n_marks ?? trace.n_points; + return wedge && count === 1 ? trace : null; + }, + + // A single wedge's share of the wedges actually drawn. The angular width IS + // the datum, so the readout that means something is "how much of the whole" + // — and the whole is the span the wedges cover between them, not the axis + // range: a gauge's four bands sweep 240 degrees of a full-turn axis, and + // their shares must add to 100% of the gauge, not 67% of a circle. + _wedgeSharePercent(trace) { + const traces = Array.isArray(this.spec.traces) ? this.spec.traces : []; + let total = 0; + let own = 0; + for (const t of traces) { + const width = Number(t?.bar?.width); + if (!Number.isFinite(width) || width <= 0) continue; + const marks = t.n_marks ?? t.n_points; + if (marks !== 1) return null; // a multi-wedge trace makes "share" undefined + total += width; + if (t.id === trace.id) own = width; + } + if (!(total > 0) || !(own > 0)) return null; + return (own / total) * 100; + }, + _defaultTooltipItems(row, labels = {}, aliases = {}) { const items = []; + const seriesName = this._tooltipSeriesName(row); + if (seriesName) items.push({ kind: "title", value: seriesName }); + const wedge = this._namedWedge(row); + if (seriesName && wedge) { + // The wedge's share is the only number that means anything here: theta + // is where layout put it and the radius is the ring thickness. Skipped + // when the name already carries a percentage (pie_chart bakes one in), + // so a slice never reads "40% ... 40%". + const share = /\d\s*%/.test(seriesName) ? null : this._wedgeSharePercent(wedge); + if (share !== null) { + items.push({ kind: "field", label: "share", value: `${share.toFixed(1)}%` }); + } + return items; + } if (row.x !== undefined) { - const { label } = this._defaultTooltipLabel("x", "x", labels, aliases); - items.push({ kind: "field", label, value: fmtValue(row.x, row.x_kind) }); + const polar = this._polarTooltipField("x", row.x, row.x_kind); + const { label, customized } = this._defaultTooltipLabel("x", "x", labels, aliases); + // A numeric polar angle only appears when the user asked for the row + // by naming it (`labels={"x": ...}`); authored spoke labels always show. + if (!polar || !polar.omit || customized) { + items.push({ + kind: "field", + label: polar && !customized ? polar.label : label, + value: polar ? polar.value : fmtValue(row.x, row.x_kind), + }); + } } if (row.y !== undefined) { - const { label } = this._defaultTooltipLabel("y", "y", labels, aliases); - items.push({ kind: "field", label, value: fmtValue(row.y, row.y_kind) }); + const polar = this._polarTooltipField("y", row.y, row.y_kind); + const { label, customized } = this._defaultTooltipLabel("y", "y", labels, aliases); + items.push({ + kind: "field", + label: polar && !customized ? polar.label : label, + value: fmtValue(row.y, row.y_kind), + }); } if (row.color_value !== undefined) { const { label } = this._defaultTooltipLabel( @@ -227,6 +354,15 @@ Object.assign(ChartView.prototype, { }, _tooltipLookup(row, field) { + // "name" is a pseudo-field: the hovered trace's series name. Rows carry + // only a trace id, but compositions whose category lives in the mark name + // (a pie slice, a wind-rose band) need the tooltip template to reach it — + // `xy.tooltip(title="{name}")` is how a pie shows category + value and + // nothing else. + if (field === "name") { + const name = this._tooltipSeriesName(row); + return name === null ? [undefined, undefined] : [name, undefined]; + } const aliases = (this.spec.tooltip && this.spec.tooltip.aliases) || {}; const key = row[field] !== undefined ? field : aliases[field]; if (!key || row[key] === undefined) return [undefined, undefined]; @@ -323,8 +459,7 @@ Object.assign(ChartView.prototype, { _tooltipAnchorPx() { const a = this._tooltipAnchor; if (!a) return null; - const lx = this._dataPx(a.xAxis, a.x); - const ly = this._dataPx(a.yAxis, a.y); + const [lx, ly] = this._projectDataPoint(a.xAxis, a.yAxis, a.x, a.y); const p = this.plot; if (!Number.isFinite(lx) || !Number.isFinite(ly) || lx < p.x || lx > p.x + p.w || ly < p.y || ly > p.y + p.h) { diff --git a/js/src/53_interaction.ts b/js/src/53_interaction.ts index ad00deb5..6233969e 100644 --- a/js/src/53_interaction.ts +++ b/js/src/53_interaction.ts @@ -236,6 +236,7 @@ Object.assign(ChartView.prototype, { return; } const canPan = this._interactionFlag("pan", true); + const panAxes = this._axisPolicy("pan_axes"); const canZoom = this._interactionFlag("zoom", true); const canNavigate = this._interactionFlag("navigation", true); const canBoxZoom = this._interactionFlag("box_zoom", true); @@ -268,7 +269,7 @@ Object.assign(ChartView.prototype, { this._hideTooltip(); return; } - if (this.dragMode === "pan" && canNavigate && canPan) { + if (this.dragMode === "pan" && canNavigate && canPan && panAxes.length > 0) { drag = { px: e.clientX, py: e.clientY, @@ -279,7 +280,7 @@ Object.assign(ChartView.prototype, { // locked axis's motion in the clamp (it slides inside its home // window once zoomed in) instead of removing it from the gesture. axes: [...new Set([ - ...this._axisPolicy("pan_axes"), + ...panAxes, ...this._axisIds().filter((axisId) => this._axisContained(axisId)), ])], changedAxes: [], @@ -399,9 +400,11 @@ Object.assign(ChartView.prototype, { this._listen(c, "wheel", (e) => { // The drag tool never disables the wheel (box-zoom/select drags are - // drag-only tools) — except `none`, the modebar's escape hatch that - // releases page scroll for embedded charts. - if (this.dragMode === "none") return; + // drag-only tools) — except `none` CHOSEN by the user, the modebar's + // escape hatch that releases page scroll for embedded charts. A chart + // whose resolved default is `none` because no drag tool applies (polar) + // keeps its wheel gesture. + if (this.dragMode === "none" && this._dragModeUserSet) return; if (!this._interactionFlag("navigation", true)) return; if (!this._interactionFlag("zoom", true)) return; if (!this._interactionFlag("wheel_zoom", true)) return; @@ -418,7 +421,12 @@ Object.assign(ChartView.prototype, { clearSelectionOnDoubleClick(); return; } - if (this.dragMode === "none") return; + // Same distinction as the wheel handler above: `none` CHOSEN by the + // user releases every navigation gesture, but a chart whose resolved + // default is `none` because no drag tool applies (polar) keeps its + // documented double-click reset — several pie/gauge/wind-rose examples + // hide the modebar, which otherwise left no reset path at all. + if (this.dragMode === "none" && this._dragModeUserSet) return; if (!this._interactionFlag("navigation", true)) return; if (!this._interactionFlag("double_click_reset", true)) return; this._resetView(true, "reset"); @@ -553,8 +561,19 @@ Object.assign(ChartView.prototype, { // already converted categorical coordinates into display strings. const xValue = this._decodeValue(g._cpu.x, g._cpu.xMeta || g.xMeta, offset); const yValue = this._decodeValue(g._cpu.y, g._cpu.yMeta || g.yMeta, offset); - const x = this._dataPx(g.xAxis || "x", xValue) - this.plot.x; - const y = this._dataPx(g.yAxis || "y", yValue) - this.plot.y; + const [chartX, chartY] = this._projectDataPoint( + g.xAxis || "x", + g.yAxis || "y", + xValue, + yValue, + ); + // A polar view can legitimately cull source rows outside its authored + // sector or radial window. Keyboard traversal still advances over that row, + // but it must not publish a hover or feed NaN coordinates to tooltip + // placement; the next traversal key continues from this source position. + if (!Number.isFinite(chartX) || !Number.isFinite(chartY)) return; + const x = chartX - this.plot.x; + const y = chartY - this.plot.y; const rect = this.canvas.getBoundingClientRect(); const clientX = rect.left + Math.max(0, Math.min(rect.width, x)); const clientY = rect.top + Math.max(0, Math.min(rect.height, y)); @@ -1236,7 +1255,8 @@ Object.assign(ChartView.prototype, { }; const canNavigate = this._interactionFlag("navigation", true); - const canPan = canNavigate && this._interactionFlag("pan", true); + const canPan = canNavigate && this._interactionFlag("pan", true) + && this._axisPolicy("pan_axes").length > 0; const canZoom = canNavigate && this._interactionFlag("zoom", true); const canZoomButtons = canZoom && this._interactionFlag("zoom_buttons", true); const canBoxZoom = canZoom && this._interactionFlag("box_zoom", true); @@ -1737,7 +1757,7 @@ Object.assign(ChartView.prototype, { // A chart can mount beneath an already-stationary pointer, so initialize // from the current hover state instead of waiting for pointerenter. setVisible(root.matches(":hover")); - this._setDragMode(this.dragMode); + this._setDragMode(this.dragMode, { userInitiated: false }); }, // The modebar is unusable chrome once the plot box can't contain it — in a @@ -1762,7 +1782,16 @@ Object.assign(ChartView.prototype, { this._clampModebar(); }, - _setDragMode(mode) { + _setDragMode(mode, { userInitiated = true } = {}) { + // Calls here are explicit tool changes (modebar, keyboard, the + // select-fallback) except the modebar-build re-assert, which passes + // userInitiated: false. The wheel gate needs the distinction: a user + // choosing the `none` tool releases page scroll, but a chart whose + // resolved default is `none` because it HAS no drag tools (polar + // disables pan/box/select) must keep its wheel gesture — radial zoom is + // the only navigation polar has, and gating it on dragMode made it dead + // on arrival. + if (userInitiated) this._dragModeUserSet = true; this.dragMode = mode; // Cursor telegraphs the gesture (grab for pan, crosshair for box-zoom) but // lives in the defeatable :where([data-xy-slot="canvas"]) stylesheet keyed on @@ -2058,9 +2087,16 @@ Object.assign(ChartView.prototype, { const ranges = Object.fromEntries( this._axisIds().map((axisId) => [axisId, [...this._axisRange(axisId, base)]]) ); + // Polar radial zoom scales r_hi about a FIXED r_lo (polar-axes.md §8) — + // the wheel path already does. The modebar used a centred anchor for every + // coordinate system, so the only reachable polar zoom moved the radial + // minimum and carved a hole in the middle of the disc, contradicting the + // documented contract. + const polarRadial = this.spec?.coords === "polar"; for (const axisId of axes) { const [lo, hi] = ranges[axisId]; - const range = this._zoomAxisRange(axisId, lo, hi, f, 0.5); + const anchor = polarRadial && this._axisDim(axisId) === "y" ? 0 : 0.5; + const range = this._zoomAxisRange(axisId, lo, hi, f, anchor); if (range) ranges[axisId] = range; } this._setView({ ranges }, { @@ -2078,7 +2114,13 @@ Object.assign(ChartView.prototype, { if (![c0, c1].every(Number.isFinite) || c0 === c1) return null; const ca = c0 + anchorFrac * (c1 - c0); if (f < 1) { - const minSpan = Math.max(Math.abs(ca), 1e-30) * 1e-12; + // Floor on the interval's own magnitude, not the anchor's: polar radial + // zoom anchors at r_lo = 0, making |ca| = 0 and the old floor 1e-42 — + // no floor at all, so sustained wheel-in sailed past f32 quantization + // into visible banding. |ca| never exceeds max(|c0|, |c1|), so this is + // at least as strict everywhere, including centre-anchored cartesian + // zoom on a symmetric range (which had the same hole). + const minSpan = Math.max(Math.abs(c0), Math.abs(c1), 1e-30) * 1e-12; if (Math.abs((c1 - c0) * f) < minSpan) return null; } const next0 = ca - (ca - c0) * f; @@ -2122,8 +2164,17 @@ Object.assign(ChartView.prototype, { this._axisIds().map((axisId) => [axisId, [...this._axisRange(axisId, base)]]) ); const anchors = {}; + // Polar radial zoom anchors at the CENTRE, always: an interior anchor + // lifts r_lo and carves a hole in the middle of the disc — an annulus view + // that reads as broken, not as zoom. Scaling r_hi about a fixed minimum is + // Plotly's radial semantics and stays legible from any cursor position + // (polar-axes.md §8). One site covers the wheel, the modebar buttons and + // axis-band gestures alike. + const polarRadial = this.spec?.coords === "polar"; for (const axisId of axes) { - const anchor = this._axisDim(axisId) === "x" ? fx : fy; + const anchor = this._axisDim(axisId) === "x" + ? fx + : (polarRadial ? 0 : fy); const [lo, hi] = ranges[axisId]; const range = this._zoomAxisRange(axisId, lo, hi, f, anchor); if (range) ranges[axisId] = range; diff --git a/js/src/56_animation.ts b/js/src/56_animation.ts index e508764d..05772054 100644 --- a/js/src/56_animation.ts +++ b/js/src/56_animation.ts @@ -215,6 +215,13 @@ Object.assign(ChartView.prototype, { this._dataAnimRaf = null; for (const record of records) this._clearTransitionVisual(record.g); this._finishDataAnimation(phase); + // The label layer is throttled while `_dataAnim` is live (see the + // cadence in `_drawChrome`), so force one settled rebuild now that it is + // cleared. Same completion contract as the view animation in + // 53_interaction.ts — without it a transition could leave labels from up + // to one cadence tick earlier on screen. + this._lastLabelDraw = null; + this.draw(); } }; this._dataAnimRaf = requestAnimationFrame(tick); @@ -364,6 +371,19 @@ Object.assign(ChartView.prototype, { }, _prepareBarPositionInterpolation(previous, next, match) { + // BAR_VS mixes the transition into p/v0/v1 in CLIP space, but its polar + // branch needs data space and re-derives theta and both radii from the raw + // attributes — so only the scalar half-width survives the mix. The result + // was not a snap but a hybrid matching neither old nor new data: the wedge + // jumped to its destination angle on frame 0 while its angular width + // animated. polar-axes.md defers polar animation; make that deferral + // guarded like its siblings so the wedge snaps cleanly and says why. + // (Polar scatter and line interpolate correctly — they mix before + // projecting — and entrance `grow` still honours u_animationProgress.) + if (this.spec?.coords === "polar") { + match.fallback ||= "snap:polar-unsupported"; + return false; + } const oldBar = previous._cpuBar; const newBar = next._cpuBar; if (!oldBar || !newBar || previous.orientation !== next.orientation || diff --git a/python/xy/__init__.py b/python/xy/__init__.py index de2b35cf..8ba4f1c5 100644 --- a/python/xy/__init__.py +++ b/python/xy/__init__.py @@ -89,6 +89,10 @@ "unregister_mark": ".plugins", "line": ".components", "line_chart": ".components", + "pie_chart": ".components", + "polar_bar_chart": ".components", + "polar_chart": ".components", + "radar_chart": ".components", "marker": ".components", "modebar": ".components", "scatter": ".components", @@ -112,6 +116,9 @@ "vline": ".components", "x_band": ".components", "write_images": ".export", + "r_axis": ".components", + "theta_axis": ".components", + "wind_rose": ".components", "x_axis": ".components", "y_band": ".components", "y_axis": ".components", @@ -183,6 +190,11 @@ "mark", "marker", "modebar", + "pie_chart", + "polar_bar_chart", + "polar_chart", + "r_axis", + "radar_chart", "register_mark", "registered_marks", "scatter", @@ -198,6 +210,7 @@ "step_chart", "text", "theme", + "theta_axis", "threshold", "threshold_zone", "tooltip", @@ -207,6 +220,7 @@ "violin", "violin_chart", "vline", + "wind_rose", "write_images", "x_axis", "x_band", diff --git a/python/xy/_figure.py b/python/xy/_figure.py index f764f751..579ea114 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -35,6 +35,8 @@ DENSITY_SAMPLE_SEED, DENSITY_SAMPLE_TARGET, DIRECT_SOFT_CEILING, + POLAR_DIRECT_CEILING, + POLAR_MARK_KINDS, PROTOCOL_VERSION, SCATTER_DENSITY_THRESHOLD, default_palette_color, @@ -98,6 +100,7 @@ def __init__( x_label: Optional[str] = None, y_label: Optional[str] = None, padding: Any = None, + coords: str = "cartesian", ) -> None: # width/height: pixels, or "100%" to fill the parent container — the # client measures the container and re-renders on resize @@ -118,6 +121,12 @@ def __init__( self.title_options: list[dict[str, Any]] = [] self.x_label = self._optional_text(x_label, "x_label") self.y_label = self._optional_text(y_label, "y_label") + # "cartesian" (two separable axes) or "polar" (the x axis carries theta, + # the y axis carries r). Polar reinterprets the same two axes rather + # than declaring new ids: axis ids are required to start with 'x'/'y' in + # four separate places, and the interaction axis policies are built on + # that grammar. See spec/design/polar-axes.md. + self.coords = _validate.coords(coords, "coords") self.axis_options: dict[str, dict[str, Any]] = { "x": {"label": self.x_label, "side": "bottom"}, "y": {"label": self.y_label, "side": "left"}, @@ -263,6 +272,13 @@ def set_axis( style: Optional[dict[str, Any]] = None, minor_style: Optional[dict[str, Any]] = None, nonpositive: Optional[str] = None, + theta_unit: Optional[str] = None, + theta_zero: Optional[Any] = None, + theta_direction: Optional[str] = None, + sector: Optional[tuple[float, float]] = None, + grid_shape: Optional[str] = None, + hole: Optional[float] = None, + r_origin: Optional[float] = None, ) -> "Figure": axis_id = self._axis_id(axis_id, "axis id") axis_dim = self._axis_dim(axis_id) @@ -287,6 +303,46 @@ def set_axis( bounds = self._finite_increasing_pair(bounds, f"{axis_id} axis bounds") if type_ == "log" and bounds[0] <= 0: raise ValueError(f"{axis_id} log axis bounds must be positive") + if theta_unit is not None: + theta_unit = _validate.theta_unit(theta_unit, f"{axis_id} axis theta_unit") + if theta_direction is not None: + theta_direction = _validate.theta_direction( + theta_direction, f"{axis_id} axis theta_direction" + ) + if theta_zero is not None: + theta_zero = _validate.theta_zero(theta_zero, f"{axis_id} axis theta_zero") + if sector is not None: + sector = _validate.theta_sector(sector, f"{axis_id} axis sector") + if grid_shape is not None: + grid_shape = _validate.polar_grid_shape(grid_shape, f"{axis_id} axis grid_shape") + if hole is not None: + hole = _validate.polar_hole(hole, f"{axis_id} axis hole") + if r_origin is not None: + r_origin = self._finite_scalar(r_origin, f"{axis_id} axis r_origin") + if hole is not None and r_origin is not None: + raise ValueError(f"{axis_id} axis hole and r_origin are mutually exclusive") + if axis_dim == "y" and any( + option is not None + for option in (theta_unit, theta_direction, theta_zero, sector, grid_shape) + ): + raise ValueError( + f"{axis_id} axis: theta options describe the angular " + "axis and belong on an x axis (xy.theta_axis); the radial axis is the y axis" + ) + if axis_dim == "x" and any(option is not None for option in (hole, r_origin)): + raise ValueError( + f"{axis_id} axis: hole/r_origin describe the radial axis and belong on a " + "y axis (xy.r_axis); the angular axis is the x axis" + ) + if type_ == "log" and r_origin is not None and r_origin <= 0: + raise ValueError(f"{axis_id} log axis r_origin must be positive") + if sector is not None and self.coords == "polar": + unit = theta_unit or "radians" + turn = 360.0 if unit == "degrees" else 2.0 * math.pi + if sector[1] - sector[0] > turn: + raise ValueError( + f"{axis_id} axis sector sweep must not exceed one full turn ({turn:g} {unit})" + ) if side is None: side = "bottom" if axis_dim == "x" else ("right" if axis_id != "y" else "left") elif axis_dim == "x" and side not in {"top", "bottom"}: @@ -371,6 +427,15 @@ def set_axis( "style": styles.compile_axis_style(style, f"{axis_id} axis style"), "minor_style": styles.compile_axis_style(minor_style, f"{axis_id} minor axis style"), "nonpositive": nonpositive, + # Polar angular configuration. Meaningless on a cartesian chart and + # omitted from the wire there, so existing specs stay byte-identical. + "theta_unit": theta_unit, + "theta_zero": theta_zero, + "theta_direction": theta_direction, + "sector": sector, + "grid_shape": grid_shape, + "hole": hole, + "r_origin": r_origin, } if axis_id == "x": self.x_label = self.axis_options[axis_id]["label"] @@ -548,6 +613,7 @@ def _rect_mark_style( stroke: Optional[str], stroke_width: float, fill: Any, + wedge_gap: float = 0.0, ) -> dict[str, Any]: """Validate the rect-family mark styling (rounded corners, border, gradient fill) into the sparse style keys the client renders. @@ -570,6 +636,13 @@ def _rect_mark_style( radius = self._nonnegative_scalar(corner_radius, f"{kind} corner_radius") if radius: style["corner_radius"] = radius + gap = self._nonnegative_scalar(wedge_gap, f"{kind} wedge_gap") + if gap: + # Gap between neighbouring polar wedges, in PX — an angular pad's + # gap is `r · dtheta` wide and so tapers to nothing at the hole. + # Meaningless under cartesian coords, where bars have their own + # width; recorded on the style and read only by the wedge paths. + style["wedge_gap"] = gap stroke = self._optional_css_color(stroke, f"{kind} stroke") stroke_width = self._nonnegative_scalar(stroke_width, f"{kind} stroke_width") if stroke is not None and stroke_width == 0.0: @@ -753,6 +826,155 @@ def _interaction_axes(self, name: str) -> list[str]: value = self.interaction.get(name) return list(self.axis_options) if value is None else self._axis_policy(value, name) + def _validate_coords(self) -> None: + """Refuse mark kinds the polar transform does not yet render correctly. + + A whole-scene check rather than a per-mark one: marks can be appended + at any time, so only payload-build time sees the finished figure. + + The refusal is deliberate. Every unsupported kind here *would* draw + something — a bar would come out as a chord-edged rectangle rather than + an annular sector, an area would fill the wrong region — and a + plausible wrong picture is worse than an error. §28 requires the + decision to ship rather than be silently approximated. + """ + if self.coords != "polar": + return + unsupported = sorted({t.kind for t in self.traces} - POLAR_MARK_KINDS) + if unsupported: + raise ValueError( + f"coords='polar' does not support {unsupported} yet; " + f"supported kinds are {sorted(POLAR_MARK_KINDS)}. " + "See spec/design/polar-axes.md." + ) + unsupported_annotations = sorted( + {str(annotation.get("kind")) for annotation in self.annotations} & {"rule", "band"} + ) + if unsupported_annotations: + raise ValueError( + "coords='polar' does not support rule/band annotations yet; " + f"found {unsupported_annotations}. Point-anchored text, label, marker, " + "arrow, and callout annotations remain supported." + ) + # One angular and one radial axis, no more. A secondary axis binds and + # validates exactly like a Cartesian one, but the polar transform reads + # only the primary pair, so the result is the failure this method exists + # to prevent: an overlapping secondary range draws *pixel-identical* to + # the primary, inviting the reader to decode it against a tick ladder it + # does not belong to, while a disjoint one culls the series away + # entirely — and either way the axis still gets its Cartesian spine and + # title drawn in the gutter of a disc. Refuse instead. + extra_axes = sorted( + ( + set(self.axis_options) + | {t.x_axis for t in self.traces} + | {t.y_axis for t in self.traces} + ) + - {"x", "y"} + ) + if extra_axes: + raise ValueError( + "coords='polar' supports a single angular ('x') and radial ('y') " + f"axis; found {extra_axes}. See spec/design/polar-axes.md." + ) + theta = self.axis_options.get("x", {}) + # A non-linear *angle* has no coherent projection, and the two renderers + # never agreed on one: the client scales theta before projecting it + # while the static exporters ignore the scale outright (their SVG is + # byte-identical across linear/log/symlog), so the same figure points a + # datum at opposite sides of the disc depending on where it is drawn. + # The spec offers a scale row for r only; a log radial axis stays valid. + # Inspecting only the *declared* spelling let an inferred time column + # through: datetime theta shipped with kind="time" pinned to a fixed + # 0..2pi range, so twelve consecutive days wrapped the disc billions of + # times and the spokes were labelled as radians. `theta_axis(domain=)` + # is aliased to `sector`, so there was no escape hatch either. Refuse on + # the resolved kind, and say what a time angle would have to mean. + if self._axis_kind("x") == "time": + raise ValueError( + "coords='polar' does not support a time angular axis; an " + "instant has no angle. Map time onto the turn yourself — e.g. " + "theta = 2*pi * ((t - t0) / period) — and pass the result as a " + "number. A time *radial* axis (r_axis) is supported." + ) + theta_scale = theta.get("type") + if theta_scale is not None and theta_scale != "linear": + raise ValueError( + f"coords='polar' does not support a {theta_scale!r} angular axis; " + "the angle must be linear. A log or symlog *radial* axis " + "(r_axis) is supported. See spec/design/polar-axes.md." + ) + # `reverse` is the Cartesian "flip this axis" switch; on a disc the + # equivalent is a direction of travel, which the angular axis already + # spells as `direction`. It rode the wire as `"reverse": true` and every + # renderer ignored it, so the axis silently drew unreversed — the same + # accepted-but-inert trap as a secondary axis. Point at the switch that + # works instead. (`r_axis(reverse=True)` is honoured and unaffected.) + if theta.get("reverse"): + raise ValueError( + "coords='polar' does not support reverse=True on the angular " + "axis; use theta_axis(direction='clockwise') to reverse the " + "direction of travel. See spec/design/polar-axes.md." + ) + sector = theta.get("sector") + if sector is not None: + unit = theta.get("theta_unit") or "radians" + turn = 360.0 if unit == "degrees" else 2.0 * math.pi + if sector[1] - sector[0] > turn: + raise ValueError( + f"x axis sector sweep must not exceed one full turn ({turn:g} {unit})" + ) + radial = self.axis_options.get("y", {}) + r_origin = radial.get("r_origin") + if r_origin is not None: + # The first resolved limit is the centre-side ring and the second + # is the outer ring. On an ordinary radial axis that means the + # origin lies at/below r_lo; reversing the axis reverses that + # inequality too. Sorting here accepted an origin on the wrong + # side of a reversed view, which then normalized every visible + # radius beyond 1 and culled the entire plot. + r_inner, r_outer = self._range("y") + if radial.get("type") == "log" and r_origin <= 0: + raise ValueError("y log axis r_origin must be positive") + if r_inner < r_outer: + if not r_origin < r_outer: + raise ValueError( + "y axis r_origin must be less than the resolved radial maximum" + ) + if r_origin > r_inner: + raise ValueError("y axis r_origin must not exceed the resolved radial minimum") + else: + if not r_origin > r_outer: + raise ValueError( + "y axis r_origin must be greater than the resolved radial minimum" + ) + if r_origin < r_inner: + raise ValueError( + "y axis r_origin must not be less than the resolved radial maximum" + ) + for t in self.traces: + # heatmap/contour are exempt because a cell grid legitimately + # carries more cells than the *point* ceiling. Narrowing the gate + # to that end un-capped bar/column/errorbar as collateral, and a + # polar bar is the most expensive mark there is — 2*(96+1) verts + # per wedge against a cartesian quad's 4 — so a million of them + # built without a word. Name every capped kind explicitly. + if ( + t.kind in {"line", "scatter", "area", "bar", "column", "errorbar"} + and t.n_points > POLAR_DIRECT_CEILING + ): + # Polar has no decimation or density tier to fall back to + # (polar-axes.md §7), so past the cap the only honest options + # are refusing or an unbounded direct draw. Refuse, and say + # which trace and why. + raise ValueError( + f"polar {t.kind} trace has {t.n_points:,} points, over the " + f"{POLAR_DIRECT_CEILING:,}-point polar ceiling: polar traces " + "always draw every point (no decimation/density tier yet — " + "spec/design/polar-axes.md §7). Reduce the data or bin it " + "before charting." + ) + def _validate_interaction(self) -> None: for name in ("pan_axes", "zoom_axes", "reset_axes", "link_axes"): if name in self.interaction: @@ -1156,8 +1378,30 @@ def _range(self, axis_id: str, *, use_domain: bool = True) -> tuple[float, float if not positive_los: raise ValueError(f"{axis_id} log axis requires at least one positive value") lo, hi = min(positive_los), max(positive_his) + if self.coords == "polar" and self._axis_dim(axis_id) == "x": + categories = self._axis_categories.get(axis_id) + if categories: + # Categorical theta keeps data in category-index coordinates; + # the renderer maps those indices evenly across the authored + # sector (or the default full turn). Returning angular units + # here made category 2 mean two radians and broke bar bands. + return (0.0, float(len(categories) - 1)) + # Numeric theta is used directly as an angle, never rescaled into + # the axis range. The independent `sector` field governs partial + # layout/clipping; this range remains the full-turn tick domain. + unit = self.axis_options.get(axis_id, {}).get("theta_unit") or "radians" + return (0.0, 360.0) if unit == "degrees" else (0.0, 2.0 * math.pi) configured_margin = opts.get("margin") if lo == hi and configured_margin is None: + if self.coords == "polar" and self._axis_dim(axis_id) == "y" and scale != "log": + # Constant-radius data must not bypass the centre-origin + # default below: padding a singleton r=5 to [4.75, 5.25] draws + # a unit circle as a ring floating mid-disc, exactly the + # picture the polar branch exists to forbid. Same rule as the + # non-singleton branch: centre origin, no outer pad. + lo_out = min(0.0, lo) + hi_out = hi if hi > lo_out else lo_out + 1.0 + return (hi_out, lo_out) if opts.get("reverse") else (lo_out, hi_out) pad = abs(lo) * 0.05 or 0.5 lo, hi = lo - pad, hi + pad if scale == "log" and lo <= 0: @@ -1178,6 +1422,44 @@ def _range(self, axis_id: str, *, use_domain: bool = True) -> tuple[float, float pad = (hi - lo) * margin out_lo = lo - pad out_hi = hi + pad + if self.coords == "polar" and self._axis_dim(axis_id) == "y": + # The radial axis starts at the centre unless asked otherwise + # (matplotlib's default rmin=0). A radial axis padded away from + # zero is actively misleading: it puts the smallest datum at the + # centre, so a 5%-variation series reads as radiating from nothing. + # An explicit domain/bounds still wins — it short-circuits above. + # Log radius has no zero and already resolved its positive extent + # above. Linear/symlog keep the established centre-origin default. + # `min(0.0, lo)` collapsed to `lo` once the data went negative, + # which threw the pad away and produced the very picture this + # branch forbids: four readings within 0.7% of each other resolved + # to [-100.8, -100.1] and drew as a full-disc star. Centre origin + # is only meaningful when zero is an end of the range — below zero + # it is vacuous, so keep the ordinary padded extent there. + # A TIME radius is the exception: its zero is 1 January 1970, so a + # centre origin puts every modern instant in a hairline ring at the + # rim (twelve consecutive days out of ~1.7e12 ms resolved to a band + # 0.0006% of the radius wide) and the axis reads as a solid disc + # edge. Zero is not a meaningful radial origin for an instant, so a + # time radius keeps the ordinary padded extent — the same reasoning + # that already exempts a negative floor below. + if self._axis_kind(axis_id) == "time": + return (out_hi, out_lo) if opts.get("reverse") else (out_lo, out_hi) + if scale == "log": + out_lo = lo + elif lo >= 0.0: + out_lo = 0.0 + # else: keep the padded out_lo computed above. + # No outer pad when the centre is the origin: the outermost ring + # should be the data max, matching how matplotlib and Plotly frame + # a polar plot. A negative floor keeps its pad on both sides so the + # range stays symmetric about the data. An explicit `margin` is an + # authored request for that pad, and dropping it was the third way + # the polar radial axis accepted a keyword and ignored it: keep the + # padded outer ring whenever one was asked for. + if (lo >= 0.0 or scale == "log") and configured_margin is None: + out_hi = hi + return (out_hi, out_lo) if opts.get("reverse") else (out_lo, out_hi) anchor = self._zero_baseline_anchor(axis_id) if anchor == "lo" and lo == 0.0 and hi > 0.0: out_lo = 0.0 @@ -1356,6 +1638,24 @@ def _axis_spec(self, axis_id: str, range_: tuple[float, float]) -> dict[str, Any spec["style"] = style if kind == "category": spec["categories"] = list(self._axis_categories.get(axis_id, [])) + if self.coords == "polar" and self._axis_dim(axis_id) == "x": + # Angular configuration rides the x (theta) axis. Defaults are + # spelled out rather than omitted so the client and both exporters + # read one resolved value instead of each re-deriving a fallback. + unit = opts.get("theta_unit") or "radians" + spec["theta_unit"] = unit + spec["theta_zero"] = "E" if opts.get("theta_zero") is None else opts["theta_zero"] + spec["theta_direction"] = opts.get("theta_direction") or "counterclockwise" + turn = 360.0 if unit == "degrees" else 2.0 * math.pi + spec["sector"] = list(opts.get("sector") or (0.0, turn)) + spec["grid_shape"] = opts.get("grid_shape") or "circular" + if self.coords == "polar" and self._axis_dim(axis_id) == "y": + # `hole` is always resolved on the wire. `r_origin` stays optional: + # when absent, renderers use the current visible r_lo, so radial + # zoom keeps the ordinary centre origin without a spec rewrite. + spec["hole"] = opts.get("hole") or 0.0 + if opts.get("r_origin") is not None: + spec["r_origin"] = opts["r_origin"] return spec def _range_columns(self, t: Trace, axis_id: str) -> list[Column]: diff --git a/python/xy/_hosts.py b/python/xy/_hosts.py index 75801fd7..76fc2997 100644 --- a/python/xy/_hosts.py +++ b/python/xy/_hosts.py @@ -38,12 +38,15 @@ class FigureHost(Protocol): show_modebar: bool show_tooltip: bool palette: Union[list[str], dict[str, str], None] + coords: str @property def palette_cycle(self) -> Optional[list[str]]: ... def palette_color(self, index: int, *, stacklevel: int = 3) -> str: ... + def _validate_coords(self) -> None: ... + # -- shared validators (static on `Figure`, aliases of `_validate`) -- @staticmethod def _finite_scalar(value: Any, label: str) -> float: ... diff --git a/python/xy/_native.py b/python/xy/_native.py index 15db51b2..d2d52027 100644 --- a/python/xy/_native.py +++ b/python/xy/_native.py @@ -24,7 +24,7 @@ from .config import MAX_CONTOUR_WORK, MAX_SCREEN_DIM -ABI_VERSION = 46 +ABI_VERSION = 47 # Rust reports invalid arguments (and, via the ffi_guard panic shield, any # internal panic) by returning `usize::MAX` from size-returning entry points. diff --git a/python/xy/_payload.py b/python/xy/_payload.py index 8f8fe0ff..40a61b32 100644 --- a/python/xy/_payload.py +++ b/python/xy/_payload.py @@ -260,6 +260,7 @@ def axis_range(axis_id: str) -> tuple[float, float]: ranges[axis_id] = r = self._range(axis_id) return r + self._validate_coords() spec_traces = [] for t in self.traces: xr = axis_range(t.x_axis) @@ -300,6 +301,8 @@ def axis_range(axis_id: str) -> tuple[float, float]: } for entry in self.title_options ] + if self.coords != "cartesian": + spec["coords"] = self.coords if self.palette is not None: # Chart-level categorical cycle (`xy.theme(palette=...)`). Every # trace already bakes its own color and every categorical channel @@ -514,6 +517,13 @@ def _m4_decimate( each bucket covers a uniform strip of *screen*, not of raw data (§28); monotone transforms keep per-bucket min/max rows identical, so y stays raw and the gathered rows ship untransformed.""" + if self.coords == "polar": + # M4 buckets on a monotonic screen-x column. Under polar the x + # column is an angle: a spiral revisits the same screen columns and + # a multi-turn series is not monotonic at all, so the buckets carry + # no screen meaning. Ship direct until polar-aware decimation + # exists (spec/design/polar-axes.md §7). + return "direct", arrays if t.n_points <= DECIMATION_THRESHOLD: return "direct", arrays eps = float(np.finfo(np.float64).eps) @@ -583,7 +593,12 @@ def _emit_error_band( def _emit_scatter( self, t: Trace, pw: "_PayloadWriter", xr: tuple, yr: tuple, px_width: int ) -> dict[str, Any]: - if t.use_density(): + if t.use_density() and self.coords != "polar": + # Polar forces direct: density bins an axis-aligned (x, y) grid, + # and equal (theta, r) bins near the origin cover far fewer pixels, + # so uniform data would render centre-concentrated. Trace.use_density + # has no Figure reference, so the chart-level flag is applied here at + # the call site (spec/design/polar-axes.md §7). t.shipped_sel = None # no per-point marks, no pick mapping t.drill_mode = False # full view: density until a zoom drills in entry = self._density_trace_spec(t, xr, yr, *DENSITY_GRID, pw) diff --git a/python/xy/_pdf.py b/python/xy/_pdf.py index a7e41dea..c4dc813e 100644 --- a/python/xy/_pdf.py +++ b/python/xy/_pdf.py @@ -141,7 +141,10 @@ def _local(tag: Any) -> str: def _check_attrs(el: ET.Element, tag: str, allowed: frozenset[str]) -> None: for name in el.attrib: - if name not in allowed: + if name not in allowed and not name.startswith("data-"): + # data-* is inert marker metadata (the polar chrome tags its rings + # and spokes for tests); it carries no geometry or paint, so the + # strict subset can ignore it rather than refuse the document. _unsupported(f"<{tag}> attribute {name!r}") @@ -186,6 +189,8 @@ def _rgba(css: str) -> tuple[float, float, float, float]: "svg-nested": frozenset({"x", "y", "width", "height", "viewBox"}), "defs": frozenset(), "clipPath": frozenset({"id"}), + "clip-circle": frozenset({"cx", "cy", "r"}), + "clip-path-shape": frozenset({"d", "clip-rule"}), "clip-rect": frozenset({"x", "y", "width", "height"}), "linearGradient": frozenset({"id", "x1", "y1", "x2", "y2", "gradientUnits"}), "stop": frozenset({"offset", "stop-color", "stop-opacity"}), @@ -604,7 +609,7 @@ class _Converter: def __init__(self) -> None: self.pdf = _Pdf() self.ops: list[str] = [] - self.clips: dict[str, tuple[float, float, float, float]] = {} + self.clips: dict[str, tuple] = {} self.gradients: dict[str, dict[str, Any]] = {} self.fonts: dict[str, tuple[str, int]] = {} # basefont -> (resname, obj) self.gstates: dict[tuple, tuple[str, int]] = {} @@ -619,6 +624,23 @@ def __init__(self) -> None: def _cache(self) -> dict[str, Any]: return self._cache_stack[-1] + # Quarter-circle Bezier constant: the control-point distance that makes a + # cubic best fit a 90-degree arc. + _CIRCLE_KAPPA = 0.5522847498307936 + + def _append_circle_path(self, cx: float, cy: float, radius: float) -> None: + """A circle as four cubic Beziers, in PDF user space (y already flipped + by the caller's coordinate transform, so plain +/- works).""" + k = self._CIRCLE_KAPPA * radius + x0, x1 = cx - radius, cx + radius + y0, y1 = cy - radius, cy + radius + self.ops.append(f"{_f(x1)} {_f(cy)} m") + self.ops.append(f"{_f(x1)} {_f(cy + k)} {_f(cx + k)} {_f(y1)} {_f(cx)} {_f(y1)} c") + self.ops.append(f"{_f(cx - k)} {_f(y1)} {_f(x0)} {_f(cy + k)} {_f(x0)} {_f(cy)} c") + self.ops.append(f"{_f(x0)} {_f(cy - k)} {_f(cx - k)} {_f(y0)} {_f(cx)} {_f(y0)} c") + self.ops.append(f"{_f(cx + k)} {_f(y0)} {_f(x1)} {_f(cy - k)} {_f(x1)} {_f(cy)} c") + self.ops.append("h") + def _push(self) -> None: self.ops.append("q") self._cache_stack.append(dict(self._cache)) @@ -815,16 +837,42 @@ def _collect_defs(self, root: ET.Element) -> None: _check_attrs(el, tag, _ALLOWED_ATTRS["clipPath"]) cid = el.get("id") children = list(el) - if cid is None or len(children) != 1 or _local(children[0].tag) != "rect": - _unsupported(" without a single ") - rect = children[0] - _check_attrs(rect, "clipPath rect", _ALLOWED_ATTRS["clip-rect"]) - self.clips[cid] = ( - _float(rect.get("x"), 0.0, "clip x"), - _float(rect.get("y"), 0.0, "clip y"), - _float(rect.get("width"), 0.0, "clip width"), - _float(rect.get("height"), 0.0, "clip height"), - ) + child_tag = _local(children[0].tag) if len(children) == 1 else None + if cid is None or child_tag not in ("rect", "circle", "path"): + _unsupported(" without a single , or ") + shape = children[0] + if child_tag == "path": + # Polar hole/sector clips: an annular or partial-sector + # region the SVG emitter writes as one path of arcs. PDF + # clips take any path, so the parsed segments (arcs already + # lowered to cubics by _parse_path) apply verbatim. + _check_attrs(shape, "clipPath path", _ALLOWED_ATTRS["clip-path-shape"]) + d = shape.get("d") + if d is None: + _unsupported(" path without d") + rule = shape.get("clip-rule", "nonzero") + if rule not in ("nonzero", "evenodd"): + _unsupported(f"clip-rule {rule!r}") + self.clips[cid] = ("path", _parse_path(d), rule) + elif child_tag == "circle": + # The polar disc clip. PDF has no circle primitive, so the + # emitter draws it as four Bezier quarter-arcs. + _check_attrs(shape, "clipPath circle", _ALLOWED_ATTRS["clip-circle"]) + self.clips[cid] = ( + "circle", + _float(shape.get("cx"), 0.0, "clip cx"), + _float(shape.get("cy"), 0.0, "clip cy"), + _float(shape.get("r"), 0.0, "clip r"), + ) + else: + _check_attrs(shape, "clipPath rect", _ALLOWED_ATTRS["clip-rect"]) + self.clips[cid] = ( + "rect", + _float(shape.get("x"), 0.0, "clip x"), + _float(shape.get("y"), 0.0, "clip y"), + _float(shape.get("width"), 0.0, "clip width"), + _float(shape.get("height"), 0.0, "clip height"), + ) elif tag == "linearGradient": _check_attrs(el, tag, _ALLOWED_ATTRS["linearGradient"]) gid = el.get("id") @@ -1139,10 +1187,23 @@ def _render_g(self, el: ET.Element, state: _State) -> None: m = _URL_RE.match(clip_ref.strip()) if m is None or m.group(1) not in self.clips: _unsupported(f"clip-path {clip_ref!r}") - x, y, w, h = self.clips[m.group(1)] + clip = self.clips[m.group(1)] self._push() - self.ops.append(f"{_f(x)} {_f(y)} {_f(w)} {_f(h)} re") - self.ops.append("W n") + clip_op = "W n" + if clip[0] == "circle": + _, cx, cy, radius = clip + self._append_circle_path(cx, cy, radius) + elif clip[0] == "path": + self._emit_segments(clip[1]) + # PDF winding: `W` is nonzero, `W*` evenodd — the same + # vocabulary as SVG's clip-rule, so an annular hole clip + # stays a hole. + if clip[2] == "evenodd": + clip_op = "W* n" + else: + _, x, y, w, h = clip + self.ops.append(f"{_f(x)} {_f(y)} {_f(w)} {_f(h)} re") + self.ops.append(clip_op) clipped = True self._render_children(el, child) if clipped: diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 42fa8302..a5fab1b0 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -54,6 +54,7 @@ _legend_layout, _lut, _physical_density_alpha, + _PolarProjection, _px_size, _resolve_static_css_vars, _Scale, @@ -62,6 +63,7 @@ _tick_label_anchor, _title_entries, _title_metrics, + affine_fast_path, annotation_label_placement, apply_export_background, axis_ticks, @@ -70,6 +72,9 @@ legend_items, legend_options_with_slot, minor_axis_ticks, + polar_heatmap_rgba, + polar_tick_label_layout, + polar_wedge_points, slot_font_size, slot_styles, slot_text_color, @@ -96,7 +101,8 @@ _AFFINE_CHANNEL_POINTS, _STROKED_TRIANGLES, _STYLED_TEXT, -) = range(18) + _POLAR_CLIP, +) = range(19) # Anchor-byte rotation flags — must match TEXT_ROTATED/TEXT_ROTATED_CW in # src/raster.rs. CCW reads bottom-to-top (y-axis titles), CW top-to-bottom # (right-margin titles, matplotlib rotation=270). @@ -213,6 +219,21 @@ def clip(self, x: float, y: float, w: float, h: float) -> None: self._f(w) self._f(h) + def polar_clip(self, polar: _PolarProjection) -> None: + """Clip subsequent commands to one annular sector. + + Coordinates/radii follow the display list's device-scale convention; + angles remain dimensionless. A later rectangular ``clip`` resets this + state, matching the marks→chrome transition in ``render_raster``. + """ + self.buf.append(_POLAR_CLIP) + self._f(polar.cx) + self._f(polar.cy) + self._f(polar.inner_radius) + self._f(polar.radius) + self._raw_f(polar.sector_a0) + self._raw_f(polar.sector_a1 - polar.sector_a0) + def fill(self, pts: Sequence[tuple[float, float]], color: tuple[int, ...]) -> None: if len(pts) < 3: return @@ -770,6 +791,135 @@ def _grad_stops(fill_spec: dict, mark_color: str) -> list: return [(float(o), _parse_color(_css(c, mark_color))) for o, c in fill_spec.get("stops", [])] +def _emit_polar_grid( + cmd: _Cmd, + polar: _PolarProjection, + theta_ticks: list[float], + r_ticks: list[float], + theta_style: dict[str, Any], + r_style: dict[str, Any], + default_grid: str, + hide_theta: bool, + hide_r: bool, +) -> None: + """Concentric rings and radial spokes, in display-list commands. + + The rasterizer has no arc, wedge or circle opcode — its only curves are + pre-flattened polylines — so each ring ships as a closed polyline from + `_PolarProjection.ring`. The SVG exporter draws the same rings as exact + `` elements; both read the same tick list, so they agree on which + rings exist even though the curve is expressed differently. + """ + theta_ticks = polar.filter_theta_values(theta_ticks) + r_ticks = [value for value in r_ticks if bool(polar.visible_mask(value))] + if not hide_r: + for v in r_ticks: + radius = float(polar.norm_radius(v)) * polar.radius + if radius <= 0.0: + continue + ring = ( + polar.polygon_ring(v, theta_ticks) + if polar.grid_shape == "linear" + else polar.ring(v) + ) + if len(ring) < 2: + continue + cmd.stroke( + [*ring, ring[0]] if polar.full_sector else ring, + float(r_style.get("grid_width", 1)), + _parse_color( + _css(r_style.get("grid_color"), default_grid), + float(r_style.get("grid_opacity", 1.0)), + ), + dash=_AXIS_GRID_DASHES.get(str(r_style.get("grid_dash", "solid"))), + ) + if hide_theta: + return + for v in theta_ticks: + angle = float(polar.angle(v)) + inner = polar.inner_radius + cmd.stroke( + [ + ( + polar.cx + inner * math.cos(angle), + polar.cy - inner * math.sin(angle), + ), + ( + polar.cx + polar.radius * math.cos(angle), + polar.cy - polar.radius * math.sin(angle), + ), + ], + float(theta_style.get("grid_width", 1)), + _parse_color( + _css(theta_style.get("grid_color"), default_grid), + float(theta_style.get("grid_opacity", 1.0)), + ), + dash=_AXIS_GRID_DASHES.get(str(theta_style.get("grid_dash", "solid"))), + ) + + +def _polar_label_paint(axis: dict[str, Any], slot_paint: Any, default_text: str) -> tuple[int, ...]: + """Axis tick_label_color/tick_color first, chart slot second. + + Mirrors `tick_color` inside `_svg._polar_tick_labels`. Without the axis + lookup the `text=False`/`show=False` shorthands — which work by setting + tick_label_color transparent — silently did nothing on a polar chart. + """ + axis_style = axis.get("style") or {} + own = _css(axis_style.get("tick_label_color", axis_style.get("tick_color")), "") + return _parse_color(own) if own else slot_paint("tick_label", default_text) + + +def _emit_polar_tick_labels( + cmd: _Cmd, + polar: _PolarProjection, + theta_values: list[float], + r_values: list[float], + theta_step: float, + r_step: float, + theta_axis: dict[str, Any], + r_axis: dict[str, Any], + theta_size: float, + r_size: float, + theta_color: tuple[int, ...], + r_color: tuple[int, ...], + hide_theta: bool, + hide_r: bool, +) -> None: + """Emit polar tick labels as display-list text, from the shared placement. + + Placement lives in `_svg.polar_tick_label_layout`; this is only the sink, + so the two exporters cannot drift on rim offsets, quadrant anchors or the + radial spoke angle. + """ + angular, radial = polar_tick_label_layout( + polar, + theta_values, + r_values, + theta_step, + r_step, + theta_axis, + r_axis, + theta_size, + r_size, + hide_theta, + hide_r, + ) + for placed, paint in ((angular, theta_color), (radial, r_color)): + for item in placed: + cmd.text( + item.x, + item.y, + # The layout speaks SVG's anchor vocabulary; the display list + # calls the same thing "center". + _TEXT_ANCHOR_CODES["center" if item.anchor == "middle" else item.anchor], + item.size, + paint, + item.text, + angle=item.spin, + ) + + @_textblock.cached_measurements def render_raster( spec: dict[str, Any], @@ -785,6 +935,13 @@ def render_raster( width, height, compact, plot = layout(spec) xa, ya = spec["x_axis"], spec["y_axis"] x_scales, y_scales, sx, sy, extra_x_axes, extra_y_axes = _axis_scales(spec, plot) + # Polar reinterprets the same two axes: x carries theta, y carries r. The + # projection comes from _svg so the vector and raster exports cannot drift. + polar = ( + _PolarProjection(spec["x_axis"], spec["y_axis"], plot) + if spec.get("coords") == "polar" + else None + ) cols = spec["columns"] cmd = _Cmd(scale) @@ -848,7 +1005,9 @@ def render_raster( hide_y = ya.get("tick_label_strategy") == "none" cmd.clip(px0, py0, plot["w"], plot["h"]) - for v in [] if hide_x else xmt: + if polar is not None: + _emit_polar_grid(cmd, polar, xt, yt, xstyle, ystyle, default_grid, hide_x, hide_y) + for v in [] if hide_x or polar is not None else xmt: gx = float(sx(v)) cmd.stroke( [(gx, py0), (gx, py1)], @@ -859,7 +1018,7 @@ def render_raster( ), dash=_AXIS_GRID_DASHES.get(str(xmstyle.get("grid_dash", "solid"))), ) - for v in [] if hide_y else ymt: + for v in [] if hide_y or polar is not None else ymt: gy = float(sy(v)) cmd.stroke( [(px0, gy), (px1, gy)], @@ -870,7 +1029,7 @@ def render_raster( ), dash=_AXIS_GRID_DASHES.get(str(ymstyle.get("grid_dash", "solid"))), ) - for v in [] if hide_x else xt: + for v in [] if hide_x or polar is not None else xt: gx = float(sx(v)) cmd.stroke( [(gx, py0), (gx, py1)], @@ -881,7 +1040,7 @@ def render_raster( ), dash=_AXIS_GRID_DASHES.get(str(xstyle.get("grid_dash", "solid"))), ) - for v in [] if hide_y else yt: + for v in [] if hide_y or polar is not None else yt: gy = float(sy(v)) cmd.stroke( [(px0, gy), (px1, gy)], @@ -893,6 +1052,12 @@ def render_raster( dash=_AXIS_GRID_DASHES.get(str(ystyle.get("grid_dash", "solid"))), ) + # Grid/frame chrome is drawn before the shaped clip. Marks then share one + # analytic annular-sector clip in the native painter, matching SVG's + # polar clipPath without flattening every mark at the boundary. + if polar is not None: + cmd.polar_clip(polar) + spec_palette: Sequence[str] = spec.get("palette") or DEFAULT_PALETTE for palette_i, t in enumerate(spec["traces"]): style = t.get("style") or {} @@ -903,39 +1068,88 @@ def render_raster( if t.get("tier") == "density" and t.get("density"): _emit_grid(cmd, "density", t["density"], blob, cols, trace_sx, trace_sy, style) elif kind == "line": - _emit_line(cmd, t, blob, cols, trace_sx, trace_sy, style, color) + _emit_line(cmd, t, blob, cols, trace_sx, trace_sy, style, color, polar) elif kind in ("area", "error_band"): - _emit_area(cmd, t, blob, cols, trace_sx, trace_sy, style, color, plot) + _emit_area(cmd, t, blob, cols, trace_sx, trace_sy, style, color, plot, polar) elif kind == "scatter": - _emit_scatter(cmd, t, blob, cols, trace_sx, trace_sy, style, color) + _emit_scatter(cmd, t, blob, cols, trace_sx, trace_sy, style, color, polar) elif kind == "hexbin": _emit_hexbin(cmd, t, blob, cols, trace_sx, trace_sy, style, color) elif kind in {"errorbar", "stem", "box_whisker", "box_median", "contour", "segments"}: - _emit_segments(cmd, t, blob, cols, trace_sx, trace_sy, style, color) + _emit_segments(cmd, t, blob, cols, trace_sx, trace_sy, style, color, polar) elif kind in ("bar", "column") and t.get("bar"): - _emit_bars(cmd, t, blob, cols, trace_sx, trace_sy, style, color, plot) + _emit_bars(cmd, t, blob, cols, trace_sx, trace_sy, style, color, plot, polar) elif kind == "heatmap" and t.get("heatmap"): _emit_grid( - cmd, "heatmap", t["heatmap"], blob, cols, trace_sx, trace_sy, style, borrowed + cmd, + "heatmap", + t["heatmap"], + blob, + cols, + trace_sx, + trace_sy, + style, + borrowed, + polar, ) elif kind == "triangle_mesh": _emit_triangle_mesh(cmd, t, blob, cols, trace_sx, trace_sy, style, color) elif all(k in t for k in ("x0", "x1", "y0", "y1")): - _emit_rects(cmd, t, blob, cols, trace_sx, trace_sy, style, color, plot) + _emit_rects(cmd, t, blob, cols, trace_sx, trace_sy, style, color, plot, polar) - _emit_annotations(cmd, spec.get("annotations") or [], sx, sy, plot, width, height) + _emit_annotations(cmd, spec.get("annotations") or [], sx, sy, plot, width, height, polar=polar) # Chrome (unclipped): baselines, labels, title, legend. cmd.clip(0, 0, width, height) # Text annotations are unclipped like matplotlib Text (clip_on=False): # margin titles and edge labels may live outside the plot rectangle. - _emit_annotations(cmd, spec.get("annotations") or [], sx, sy, plot, width, height, phase="text") + _emit_annotations( + cmd, + spec.get("annotations") or [], + sx, + sy, + plot, + width, + height, + phase="text", + polar=polar, + ) # "none" silences the whole axis chrome (sparklines); "off" hides only the # label text and keeps baselines and the axis title (mpl shared axes). frame_sides = spec.get("frame_sides") explicit_frame_sides = frame_sides is not None if frame_sides is None: frame_sides = [xa.get("side", "bottom"), ya.get("side", "left")] + if polar is not None: + # One annular-sector outline replaces the four straight spines; "side" + # has no polar meaning, so frame_sides is deliberately not consulted. + frame_sides = [] + explicit_frame_sides = False + if not hide_x: + width_ = float(xstyle.get("axis_width", 1)) + paint = _parse_color(_css(xstyle.get("axis_color"), default_axis)) + outer = polar.frame_points(xt) + if outer: + if polar.full_sector: + cmd.stroke([*outer, outer[0]], width_, paint) + if polar.inner_radius > 0.0: + inner = ( + polar.polygon_ring(polar.r_lo, xt) + if polar.grid_shape == "linear" + else polar.ring(polar.r_lo) + ) + if inner: + cmd.stroke([*inner, inner[0]], width_, paint) + else: + inner = ( + polar.polygon_ring(polar.r_lo, xt) + if polar.inner_radius > 0.0 and polar.grid_shape == "linear" + else polar.ring(polar.r_lo) + if polar.inner_radius > 0.0 + else [(polar.cx, polar.cy)] + ) + boundary = [*outer, *reversed(inner)] + cmd.stroke([*boundary, boundary[0]], width_, paint) if not hide_y or explicit_frame_sides: if "left" in frame_sides: cmd.stroke( @@ -992,7 +1206,7 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float]: return length / 2, length / 2 return 0.0, length - if not hide_x: + if not hide_x and polar is None: inward, outward = tick_span(xmstyle) side = xa.get("side", "bottom") edge = py0 if side == "top" else py1 @@ -1023,7 +1237,7 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float]: float(xstyle.get("tick_width", 1)), _parse_color(_css(xstyle.get("tick_color"), default_axis)), ) - if not hide_y: + if not hide_y and polar is None: inward, outward = tick_span(ymstyle) side = ya.get("side", "left") edge = px1 if side == "right" else px0 @@ -1177,8 +1391,26 @@ def emit_tick_labels( angle=float(item["angle"]), ) - emit_tick_labels(xa, xlab, xstep, sx, is_x=True) - emit_tick_labels(ya, ylab, ystep, sy, is_x=False) + if polar is not None: + _emit_polar_tick_labels( + cmd, + polar, + xlab, + ylab, + xstep, + ystep, + xa, + ya, + slot_font_size(slots.get("tick_label") or {}, _axis_tick_font_size(xa)), + slot_font_size(slots.get("tick_label") or {}, _axis_tick_font_size(ya)), + _polar_label_paint(xa, slot_paint, default_text), + _polar_label_paint(ya, slot_paint, default_text), + hide_x or xa.get("tick_label_strategy") == "off", + hide_y or ya.get("tick_label_strategy") == "off", + ) + else: + emit_tick_labels(xa, xlab, xstep, sx, is_x=True) + emit_tick_labels(ya, ylab, ystep, sy, is_x=False) for axis_id, axis, axis_scale in extra_x_axes: _ticks, tick_labels, step = extra_x_ticks[axis_id] emit_tick_labels(axis, tick_labels, step, axis_scale, is_x=True) @@ -1186,6 +1418,9 @@ def emit_tick_labels( _ticks, tick_labels, step = extra_y_ticks[axis_id] emit_tick_labels(axis, tick_labels, step, axis_scale, is_x=False) legacy_title = spec.get("title") if not spec.get("title_options") else None + # The width layout measured the title band at; wrapping anywhere else would + # draw more lines than `title_room` reserved (see _svg._title_wrap_width). + title_wrap_width = plot.get("title_wrap_width") if legacy_title: title_slot = slots.get("title") or {} title_italic, title_bold = _native_font_emphasis( @@ -1194,18 +1429,25 @@ def emit_tick_labels( "font_weight": title_slot.get("font-weight", 400), } ) - cmd.text( + legacy_size = slot_font_size(title_slot, 14.0) + legacy_block = _textblock.measure(legacy_title, legacy_size, max_width=title_wrap_width) + # Lines run downward from the baseline, so lift the block by its trailing + # lines: the last line keeps the historical single-line baseline. A + # one-line title has no trailing lines and emits exactly as before. + legacy_trailing = (legacy_block.line_count - 1) * legacy_block.line_step + _emit_text_block( + cmd, width / 2, - plot["y"] - plot["top_axis_room"] - (10 if compact else 12), + plot["y"] - plot["top_axis_room"] - (10 if compact else 12) - legacy_trailing, 1, - slot_font_size(title_slot, 14.0), + legacy_size, slot_paint("title", default_text), - str(legacy_title), + "\n".join(legacy_block.lines), italic=title_italic, bold=title_bold, ) for title_entry in [] if legacy_title else _title_entries(spec): - title_style, title_size, title_block = _title_metrics(spec, title_entry) + title_style, title_size, title_block = _title_metrics(spec, title_entry, title_wrap_width) title_italic, title_bold = _native_font_emphasis( { "font_style": title_style.get("font-style"), @@ -1233,7 +1475,9 @@ def emit_tick_labels( {"left": 0, "center": 1, "right": 2}.get(loc, 1), title_size, _parse_color(slot_text_color(title_style, default_text)), - str(title_entry["text"]), + # The wrapped lines, not the raw string: one long line inside a + # two-line band is the clipping bug this reservation exists to stop. + "\n".join(title_block.lines), italic=title_italic, bold=title_bold, ) @@ -1348,6 +1592,7 @@ def _emit_line( sy: _Scale, style: dict[str, Any], color: str, + polar: "Optional[_PolarProjection]" = None, ) -> None: xv, yv = _column(blob, cols[t["x"]]), _column(blob, cols[t["y"]]) if style.get("step"): @@ -1360,7 +1605,28 @@ def _emit_line( # the byte packer. cap = str(style.get("linecap", "round")) cap = cap if cap in _CAP_CODES else "round" - if style.get("curve") == "smooth" and len(xv) >= 3 and sx.affine and sy.affine: + if polar is not None: + # Chords between projected points (polar-axes.md §5). The smooth branch + # is skipped outright: its Bezier control points are only exact under an + # affine map, and `smooth_stroke` bakes that map into Rust. Vertices + # outside the radial range split the stroke into visible runs — the + # same cull the client shader applies. The shaped clip contains paint at + # the boundary, but it cannot restore gap semantics after an invalid + # data vertex has been projected through the centre. + px, py = polar(xv, yv) + visible = polar.position_mask(xv, yv) + indices = np.flatnonzero(visible) + runs = ( + [np.arange(len(xv))] + if bool(visible.all()) + else np.split(indices, np.flatnonzero(np.diff(indices) > 1) + 1) + ) + for run in runs: + if len(run) < 2: + continue + points = list(zip(px[run].tolist(), py[run].tolist(), strict=True)) + cmd.stroke(points, width, c, dash=style.get("dash"), cap=cap) + elif style.get("curve") == "smooth" and len(xv) >= 3 and affine_fast_path(sx, sy, polar): cmd.smooth_stroke(xv, yv, sx, sy, width, c, dash=style.get("dash"), cap=cap) else: pts = _scene.curve_points(xv, yv, sx, sy, False) @@ -1400,9 +1666,18 @@ def _emit_annotations( height: float, *, phase: str = "marks", + polar: "Optional[_PolarProjection]" = None, ) -> None: px0, py0 = plot["x"], plot["y"] text_phase = phase == "text" + + def point(x: float, y: float) -> tuple[float, float]: + """Jointly project point-anchored geometry under polar coordinates.""" + if polar is not None: + px, py = polar(x, y) + return float(px), float(py) + return float(sx(x)), float(sy(y)) + for ann in annotations: # Geometry (rules/bands/arrows/markers) draws in the clipped marks # pass; every label draws in the unclipped chrome pass, matching @@ -1444,14 +1719,14 @@ def _emit_annotations( _rgba(style.get("color"), "#64748b", float(style.get("opacity", 0.14))), ) elif ann.get("kind") in ("arrow", "callout"): - if _annotation_connector_unclipped(ann, sx, sy, plot): + if _annotation_connector_unclipped(ann, sx, sy, plot, polar): cmd.clip(0, 0, width, height) restore_plot_clip = True if ann.get("kind") == "arrow": - x0, y0 = float(sx(float(ann["x0"]))), float(sy(float(ann["y0"]))) - x1, y1 = float(sx(float(ann["x1"]))), float(sy(float(ann["y1"]))) + x0, y0 = point(float(ann["x0"]), float(ann["y0"])) + x1, y1 = point(float(ann["x1"]), float(ann["y1"])) else: # pointer from the offset label back to the data point - x1, y1 = float(sx(float(ann["x"]))), float(sy(float(ann["y"]))) + x1, y1 = point(float(ann["x"]), float(ann["y"])) x0, y0 = x1 + float(ann.get("dx", 0.0)), y1 + float(ann.get("dy", 0.0)) if all(np.isfinite(v) for v in (x0, y0, x1, y1)): shapes = _arrow_shapes(x0, y0, x1, y1, style) @@ -1477,7 +1752,7 @@ def _emit_annotations( else: cmd.stroke(decoration["points"], stroke_width, color) elif ann.get("kind") == "marker": - mx, my = float(sx(float(ann["x"]))), float(sy(float(ann["y"]))) + mx, my = point(float(ann["x"]), float(ann["y"])) if np.isfinite(mx) and np.isfinite(my): alpha = float(style.get("opacity", 1.0)) stroke_w = float(style.get("stroke_width", 0.0)) @@ -1496,9 +1771,11 @@ def _emit_annotations( ) if restore_plot_clip: cmd.clip(plot["x"], plot["y"], plot["w"], plot["h"]) + if polar is not None: + cmd.polar_clip(polar) if text_phase and ann.get("text"): x, y, label_anchor, vertical_align = annotation_label_placement( - ann, style, sx, sy, plot, width, height + ann, style, sx, sy, plot, width, height, polar ) if not (np.isfinite(x) and np.isfinite(y)): continue @@ -1644,33 +1921,70 @@ def _emit_area( style: dict[str, Any], color: str, plot: dict[str, float], + polar: "Optional[_PolarProjection]" = None, ) -> None: xv = _column(blob, cols[t["x"]]) yv = _column(blob, cols[t["y"]]) bv = _column(blob, cols[t["base"]]) smooth = style.get("curve") == "smooth" - top = _scene.curve_points(xv, yv, sx, sy, smooth) - base = _scene.curve_points(xv[::-1], bv[::-1], sx, sy, smooth) - poly = np.vstack([top, base]) + if polar is not None: + # Chord-bounded polygon (polar-axes.md §5); smoothing is skipped because + # its control points are only exact under an affine map. Radii clamp to + # the radial range — the fill at each theta is [base, top] ∩ + # [r_lo, r_hi], and a base below r_lo would otherwise mirror through + # the centre (mirrors the SVG area branch and AREA_VS). Vertices + # outside the theta sector (or NaN) are CULLED, splitting the fill + # into visible runs — the SVG path applies position_mask inside + # _curve_path and the client NaN-culls in the shader; painting them + # here drew chords across the sector boundary and let NaN reach the + # display list (§19). + radial_min, radial_max = sorted((polar.r_lo, polar.r_hi)) + top_r = np.clip(yv, radial_min, radial_max) + base_r = np.clip(bv, radial_min, radial_max) + visible = polar.position_mask(xv, top_r) & polar.position_mask(xv, base_r) + if bool(visible.all()): + runs = [np.arange(len(xv))] + else: + indices = np.flatnonzero(visible) + runs = ( + [] + if indices.size == 0 + else np.split(indices, np.flatnonzero(np.diff(indices) > 1) + 1) + ) + pieces = [] + for run in runs: + if len(run) < 2: + continue + run_top = np.column_stack(polar(xv[run], top_r[run])) + run_base = np.column_stack(polar(xv[run][::-1], base_r[run][::-1])) + pieces.append((run_top, run_base)) + else: + top = _scene.curve_points(xv, yv, sx, sy, smooth) + base = _scene.curve_points(xv[::-1], bv[::-1], sx, sy, smooth) + pieces = [(top, base)] op = _fill_opacity(style, 0.35) fill_spec = style.get("fill") - if isinstance(fill_spec, dict): - xs, ys = poly[:, 0], poly[:, 1] - bbox = (xs.min(), ys.min(), xs.max() - xs.min(), ys.max() - ys.min()) - g0, g1 = _grad_line( - fill_spec.get("space", "mark"), fill_spec.get("dir", "down"), bbox, plot - ) - stops = [(o, (c[0], c[1], c[2], int(c[3] * op))) for o, c in _grad_stops(fill_spec, color)] - cmd.grad(poly.tolist(), g0, g1, stops) - else: - cmd.fill(poly.tolist(), _rgba(style.get("color"), color, op)) - lw = float(style.get("line_width", 1.2)) - if lw > 0: - lop = _stroke_opacity(style, 0.35) * float(style.get("line_opacity", 1.0)) - line_color = _rgba(style.get("line_color"), style.get("color") or color, lop) - cmd.stroke(top, lw, line_color, dash=style.get("dash")) - if style.get("stroke_perimeter"): - cmd.stroke(base, lw, line_color, dash=style.get("dash")) + for top, base in pieces: + poly = np.vstack([top, base]) + if isinstance(fill_spec, dict): + xs, ys = poly[:, 0], poly[:, 1] + bbox = (xs.min(), ys.min(), xs.max() - xs.min(), ys.max() - ys.min()) + g0, g1 = _grad_line( + fill_spec.get("space", "mark"), fill_spec.get("dir", "down"), bbox, plot + ) + stops = [ + (o, (c[0], c[1], c[2], int(c[3] * op))) for o, c in _grad_stops(fill_spec, color) + ] + cmd.grad(poly.tolist(), g0, g1, stops) + else: + cmd.fill(poly.tolist(), _rgba(style.get("color"), color, op)) + lw = float(style.get("line_width", 1.2)) + if lw > 0: + lop = _stroke_opacity(style, 0.35) * float(style.get("line_opacity", 1.0)) + line_color = _rgba(style.get("line_color"), style.get("color") or color, lop) + cmd.stroke(top, lw, line_color, dash=style.get("dash")) + if style.get("stroke_perimeter"): + cmd.stroke(base, lw, line_color, dash=style.get("dash")) def _trace_paint_rgba( @@ -1711,10 +2025,16 @@ def _emit_authored_scatter( sy: _Scale, style: dict[str, Any], color: str, + polar: "Optional[_PolarProjection]" = None, ) -> None: """Paint bounded pyplot-authored paths/glyphs in display-list space.""" xv, yv = _column(blob, cols[t["x"]]), _column(blob, cols[t["y"]]) - px, py = sx(xv), sy(yv) + px, py = polar(xv, yv) if polar is not None else (sx(xv), sy(yv)) + # Out-of-range radii are culled like the client shader culls them. The + # shaped clip contains glyph extent at the boundary, but a below-range + # position itself mirrors into the visible annulus and must still be + # rejected before projection. + visible = polar.position_mask(xv, yv) if polar is not None else None n = len(xv) if not n: return @@ -1764,6 +2084,8 @@ def read(index: int) -> np.ndarray: filled = bool(marker_path and marker_path.get("filled", True)) for index in range(n): + if visible is not None and not visible[index]: + continue fill = tuple(int(value) for value in fills[index]) stroke = tuple(int(value) for value in strokes[index]) diameter = max(0.0, 2 * (float(radii[index]) - float(widths[index]) / 2)) @@ -1812,11 +2134,12 @@ def _emit_scatter( sy: _Scale, style: dict[str, Any], color: str, + polar: "Optional[_PolarProjection]" = None, ) -> None: ch = t.get("color") or {} size_ch = t.get("size") or {} if style.get("marker_path") or style.get("marker_glyph"): - _emit_authored_scatter(cmd, t, blob, cols, sx, sy, style, color) + _emit_authored_scatter(cmd, t, blob, cols, sx, sy, style, color, polar) return def read(index: int) -> np.ndarray: @@ -1846,8 +2169,7 @@ def read(index: int) -> np.ndarray: color_mode = ch.get("mode") size_mode = size_ch.get("mode") if ( - sx.affine - and sy.affine + affine_fast_path(sx, sy, polar) and not t.get("channels") and (t.get("stroke") is None or t["stroke"].get("mode") == "match_fill") and (color_mode in {"continuous", "categorical"} or size_mode == "continuous") @@ -1874,8 +2196,7 @@ def read(index: int) -> np.ndarray: # spans and applies the same affine math while painting. Keep the existing # command as the full-fidelity fallback for log axes and channel styling. if ( - sx.affine - and sy.affine + affine_fast_path(sx, sy, polar) and ch.get("mode") not in {"continuous", "categorical", "direct_rgba"} and size_ch.get("mode") != "continuous" and not t.get("channels") @@ -1889,7 +2210,7 @@ def read(index: int) -> np.ndarray: return xv, yv = _column(blob, cols[t["x"]]), _column(blob, cols[t["y"]]) - px, py = sx(xv), sy(yv) + px, py = polar(xv, yv) if polar is not None else (sx(xv), sy(yv)) n = len(xv) if n == 0: return @@ -1928,6 +2249,18 @@ def read(index: int) -> np.ndarray: _paint.effective_rgba(stroke_intrinsic, t, read, component="stroke", default_opacity=0.8) * 255.0 ).astype(np.uint8) + if polar is not None: + # Cull out-of-range radii the way the client shader does: below r_lo a + # sprite mirrors through the centre. The shaped clip contains glyph + # extent at valid boundaries, but cannot distinguish that mirrored + # invalid position from an honest in-range one. + visible = polar.position_mask(xv, yv) + if not bool(visible.all()): + px, py, radii, fills = px[visible], py[visible], radii[visible], fills[visible] + symbols, widths, strokes = symbols[visible], widths[visible], strokes[visible] + n = len(px) + if n == 0: + return if ( np.all(widths == widths[0]) and np.all(symbols == symbols[0]) @@ -1964,6 +2297,7 @@ def _emit_segments( sy: _Scale, style: dict[str, Any], color: str, + polar: "Optional[_PolarProjection]" = None, ) -> None: x0 = _column(blob, cols[t["x0"]]) x1 = _column(blob, cols[t["x1"]]) @@ -1979,6 +2313,44 @@ def read(index: int) -> np.ndarray: _paint.effective_rgba(intrinsic, t, read, component="stroke", default_opacity=1.0) * 255.0 ).astype(np.uint8) widths = _paint.style_values(t, "width", n, read, float(style.get("width", 1.2))) + if polar is None: + px0, py0, px1, py1 = sx(x0), sy(y0), sx(x1), sy(y1) + else: + c0 = np.asarray(polar.r_scale.coord(y0), dtype=np.float64) + c1 = np.asarray(polar.r_scale.coord(y1), dtype=np.float64) + lo = min(polar.r_lo_coord, polar.r_hi_coord) + hi = max(polar.r_lo_coord, polar.r_hi_coord) + keep = ( + np.isfinite(x0) + & np.isfinite(x1) + & np.isfinite(c0) + & np.isfinite(c1) + & (np.maximum(c0, c1) >= lo) + & (np.minimum(c0, c1) <= hi) + ) + dr = c1 - c0 + ta = np.zeros(n, dtype=np.float64) + tb = np.ones(n, dtype=np.float64) + moving = np.abs(dr) > 1e-30 + ta[moving] = (lo - c0[moving]) / dr[moving] + tb[moving] = (hi - c0[moving]) / dr[moving] + t0 = np.maximum(0.0, np.minimum(ta, tb)) + t1 = np.minimum(1.0, np.maximum(ta, tb)) + clipped_x0 = x0 + (x1 - x0) * t0 + clipped_x1 = x0 + (x1 - x0) * t1 + clipped_c0 = np.clip(c0 + dr * t0, lo, hi) + clipped_c1 = np.clip(c0 + dr * t1, lo, hi) + keep &= polar.theta_visible_mask(clipped_x0) + keep &= polar.theta_visible_mask(clipped_x1) + clipped_y0 = polar.r_scale.value(clipped_c0) + clipped_y1 = polar.r_scale.value(clipped_c1) + px0, py0 = polar(clipped_x0[keep], clipped_y0[keep]) + px1, py1 = polar(clipped_x1[keep], clipped_y1[keep]) + colors = colors[keep] + widths = widths[keep] + n = len(widths) + if n == 0: + return dash = style.get("dash") if dash: # The batched segments primitive cannot dash; fall back to one dashed @@ -1986,8 +2358,7 @@ def read(index: int) -> np.ndarray: dash_pattern = ( [float(value) for value in dash.split(",")] if isinstance(dash, str) else list(dash) ) - px0, py0, px1, py1 = sx(x0), sy(y0), sx(x1), sy(y1) - for index in range(len(x0)): + for index in range(n): cmd.stroke( [(float(px0[index]), float(py0[index])), (float(px1[index]), float(py1[index]))], float(widths[index]), @@ -1995,12 +2366,9 @@ def read(index: int) -> np.ndarray: dash=dash_pattern, ) return - if n == 0: - return if np.all(widths == widths[0]): - cmd.segments(sx(x0), sy(y0), sx(x1), sy(y1), float(widths[0]), colors) + cmd.segments(px0, py0, px1, py1, float(widths[0]), colors) else: - px0, py0, px1, py1 = sx(x0), sy(y0), sx(x1), sy(y1) for index in range(n): cmd.stroke( [ @@ -2149,6 +2517,35 @@ def _bar_geom( cmd.stroke(poly, sw, stroke_c, closed=True) +def _polar_wedge_fill( + cmd: _Cmd, + style: dict[str, Any], + color: str, + plot: dict[str, float], + fills: np.ndarray, +) -> Callable[[list[tuple[float, float]], int], None]: + """Paint one flattened wedge, honoring a gradient `fill=` like the cartesian + path does. + + The polar branches used to call `cmd.fill(poly, flat)` unconditionally, so a + gradient reached the SVG (`fill="url(#g3)"`) and the browser but came out + flat in the PNG — a three-way divergence with the raster the odd one out. + Per-item colors still win when there is no gradient, since each wedge in a + pie carries its own. + """ + if isinstance(style.get("fill"), dict): + grad_only, _stroke_c, _sw = _fill_maker(cmd, style, color, plot) + + def paint(poly: list[tuple[float, float]], index: int) -> None: + grad_only(poly) + else: + + def paint(poly: list[tuple[float, float]], index: int) -> None: + cmd.fill(poly, tuple(int(value) for value in fills[index])) + + return paint + + def _fill_maker( cmd: _Cmd, style: dict[str, Any], @@ -2234,6 +2631,7 @@ def _emit_bars( style: dict[str, Any], color: str, plot: dict[str, float], + polar: "Optional[_PolarProjection]" = None, ) -> None: b = t["bar"] pos = _column(blob, cols[b["pos"]]) @@ -2250,6 +2648,30 @@ def read(index: int) -> np.ndarray: return _column(blob, cols[index]) fills, strokes, widths, radii = _rect_style_arrays(t, len(pos), color, read, 0.85) + if polar is not None: + # Annular sectors, flattened: the display list has no arc opcode, so the + # same wedge the SVG exporter draws with `A` ships as a polygon here. + paint = _polar_wedge_fill(cmd, style, color, plot, fills) + for i in range(len(pos)): + poly = polar_wedge_points( + polar, + float(pos[i]) - half, + float(pos[i]) + half, + float(min(v0[i], v1[i])), + float(max(v0[i], v1[i])), + corner_radius=float(np.max(radii[i])) if len(radii) else 0.0, + wedge_gap=float(style.get("wedge_gap", 0.0) or 0.0), + ) + if len(poly) < 3: + continue + paint(poly, i) + if widths[i] > 0: + cmd.stroke( + [*poly, poly[0]], + float(widths[i]), + tuple(int(v) for v in strokes[i]), + ) + return if not isinstance(style.get("fill"), dict) and not np.any(radii) and not np.any(widths): if horizontal: xa, xb = sx(np.minimum(v0, v1)), sx(np.maximum(v0, v1)) @@ -2318,6 +2740,7 @@ def _emit_rects( style: dict[str, Any], color: str, plot: dict[str, float], + polar: "Optional[_PolarProjection]" = None, ) -> None: x0v, x1v = _column(blob, cols[t["x0"]]), _column(blob, cols[t["x1"]]) y0v, y1v = _column(blob, cols[t["y0"]]), _column(blob, cols[t["y1"]]) @@ -2326,6 +2749,26 @@ def read(index: int) -> np.ndarray: return _column(blob, cols[index]) fills, strokes, widths, radii = _rect_style_arrays(t, len(x0v), color, read, 0.85) + if polar is not None: + # Four edge columns are an annular sector, flattened (no arc opcode). + # This is the path unequal-width slices — a pie or donut — take. + paint = _polar_wedge_fill(cmd, style, color, plot, fills) + for i in range(len(x0v)): + poly = polar_wedge_points( + polar, + float(x0v[i]), + float(x1v[i]), + float(min(y0v[i], y1v[i])), + float(max(y0v[i], y1v[i])), + corner_radius=float(np.max(radii[i])) if len(radii) else 0.0, + wedge_gap=float(style.get("wedge_gap", 0.0) or 0.0), + ) + if len(poly) < 3: + continue + paint(poly, i) + if widths[i] > 0: + cmd.stroke([*poly, poly[0]], float(widths[i]), tuple(int(v) for v in strokes[i])) + return if not isinstance(style.get("fill"), dict) and not np.any(radii) and not np.any(widths): xa, xb = sx(x0v), sx(x1v) ya, yb = sy(y0v), sy(y1v) @@ -2384,8 +2827,34 @@ def _emit_grid( sy: _Scale, style: dict[str, Any], borrowed: tuple[np.ndarray, ...] = (), + polar: "Optional[_PolarProjection]" = None, ) -> None: if kind == "heatmap": + if polar is not None: + rgba = np.ascontiguousarray( + polar_heatmap_rgba( + g, + blob, + cols, + style, + polar, + borrowed, + output_scale=cmd.s, + ) + ) + out_h, out_w = rgba.shape[:2] + plot = polar.plot + cmd.image( + plot["x"], + plot["y"], + plot["w"], + plot["h"], + out_w, + out_h, + rgba.tobytes(), + nearest=True, + ) + return w, h = int(g["w"]), int(g["h"]) if not (sx.affine and sy.affine): # Heatmap cells are uniform in *data* space, but the native image diff --git a/python/xy/_svg.py b/python/xy/_svg.py index d4d6c176..5bd17d5a 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -25,13 +25,13 @@ from datetime import UTC, datetime from itertools import pairwise from os import PathLike -from typing import Any, Optional +from typing import Any, NamedTuple, Optional import numpy as np from . import _fontmetrics, _native, _paint, _png, _textblock from ._arrowgeom import arrow_shapes as _arrow_shapes -from .config import DEFAULT_PALETTE +from .config import DEFAULT_PALETTE, polar_bar_segments def escape(data: str, entities: dict[str, str] | None = None) -> str: @@ -505,6 +505,42 @@ def _linear_ticks(lo: float, hi: float, target: int = 6) -> tuple[list[float], f return out, step +# Angular tick ladders. `_nice_step`'s [1, 2, 2.5, 5, 10] cannot produce 15, +# 30, 45 or 90, so feeding it degrees yields 0/50/100/150 — a grid nobody reads +# angles on. Fixed ladders instead, in the style of the time-tick steps. +# Mirrored by DEGREE_STEPS/RADIAN_STEPS in js/src/30_ticks.ts. +_DEGREE_STEPS = (1.0, 2.0, 5.0, 10.0, 15.0, 30.0, 45.0, 60.0, 90.0, 120.0, 180.0, 360.0) +_RADIAN_STEPS = tuple( + math.pi * f for f in (1 / 12, 1 / 8, 1 / 6, 1 / 4, 1 / 3, 1 / 2, 2 / 3, 1.0, 2.0) +) + + +def _angular_ticks(lo: float, hi: float, unit: str, target: int = 6) -> tuple[list[float], float]: + """Ticks for an angular axis, on a ladder humans read angles on. + + Mirrors `angularTicks` in js/src/30_ticks.ts. + """ + a, b = min(lo, hi), max(lo, hi) + if not (np.isfinite(a) and np.isfinite(b)): + return [], 1.0 + if a == b: + return [a], 1.0 + ladder = _DEGREE_STEPS if unit == "degrees" else _RADIAN_STEPS + rough = (b - a) / max(1, target) + step = next((s for s in ladder if s >= rough * (1 - 1e-12)), ladder[-1]) + v = math.ceil(a / step) * step + out: list[float] = [] + while v <= b + step * 1e-9 and len(out) < 200: + out.append(0.0 if abs(v) < step * 1e-9 else v) + v += step + # A full turn puts a tick at both ends of the seam; they are the same + # spoke, so the duplicate label is dropped rather than overdrawn. + turn = 360.0 if unit == "degrees" else 2.0 * math.pi + if len(out) > 1 and abs((out[-1] - out[0]) - turn) < step * 1e-9: + out.pop() + return out, step + + def _log_ticks(lo: float, hi: float, target: int = 6) -> tuple[list[float], list[float], float]: """Returns (ticks, labeled_ticks, step).""" a, b = min(lo, hi), max(lo, hi) @@ -740,12 +776,46 @@ def _collapsed_to_zero(formatted: Optional[str]) -> bool: return False +def _fmt_angle(value: float, unit: str, step: float = 1.0) -> str: + """Angular tick text. Mirrors `fmtAngle` in js/src/30_ticks.ts. + + Degrees get a degree sign; radians are written as multiples of pi, because + "2.094" is not a readable angle and "2pi/3" is. `step` sets the degree + precision: the generated ladder is all integers, but authored fractional + tick_values (a 22.5° compass grid) mislabel under a hardcoded step of 1. + """ + if unit == "degrees": + return f"{_fmt_linear(value, step or 1.0)}°" + if abs(value) < 1e-12: + return "0" + frac = value / math.pi + for denominator in (1, 2, 3, 4, 6, 8, 12): + scaled = frac * denominator + nearest = round(scaled) + # 1e-6, not 1e-9 — mirrors fmtAngle in js/src/30_ticks.ts: hover + # values arrive f32-decoded, and pi/2 misses its f64 self by ~2e-8. + if nearest and abs(scaled - nearest) < 1e-6: + numerator = "" if abs(nearest) == 1 else str(abs(nearest)) + sign = "-" if nearest < 0 else "" + body = f"{sign}{numerator}π" + return body if denominator == 1 else f"{body}/{denominator}" + return _fmt_linear(value, 0.01) + + def _fmt_axis(axis: dict[str, Any], v: float, step: float) -> str: + # Mirrors the same first branch in `fmtAxis` (js/src/30_ticks.ts). kind = axis.get("kind") if kind == "category": cats = axis.get("categories") or [] i = round(v) return str(cats[i]) if 0 <= i < len(cats) else "" + if axis.get("theta_unit"): + # An authored `format` wins over the angular default. It used to lose: + # this branch ran first, so `theta_axis(format="{:.0f} deg")` shipped, was + # accepted, and was then overwritten by the built-in degree/radian text in + # every renderer. The default only applies when nothing was authored. + authored = _fmt_number_spec(v, axis.get("format")) + return authored if authored is not None else _fmt_angle(v, axis["theta_unit"], step) if kind == "time": return _fmt_time_spec(v, axis.get("format")) or _fmt_time(v, step) formatted = _fmt_number_spec(v, axis.get("format")) @@ -843,6 +913,330 @@ def affine(self) -> bool: return not (self.log or self.symlog) +# Direction that theta=0 points, as an angle in radians measured +# counterclockwise from due East. Mirrored by THETA_ZERO in +# js/src/50_chartview.ts. +THETA_ZERO = {"E": 0.0, "N": math.pi / 2.0, "W": math.pi, "S": -math.pi / 2.0} + + +class _PolarProjection: + """(theta, r) -> px for a polar chart — spec/design/polar-axes.md §3. + + The joint replacement for the separable `_Scale` pair: polar position needs + both coordinates at once, so this is *not* two 1-D maps. `theta` and `r` + still arrive in scaled data space (a `_Scale.coord` has already applied any + log/symlog), and this class only performs the final placement. + + Screen space grows downward, so the y term is a **subtraction**. The GLSL + twin in `xyPolar` (js/src/40_gl.ts) adds instead, because clip space grows + upward. `tests/test_polar_transform.py` binds both to the same fixtures. + """ + + def __init__( + self, + theta_axis: dict[str, Any], + r_axis: dict[str, Any], + plot: dict[str, float], + ) -> None: + self.plot = plot + self.theta_axis = theta_axis + self.r_axis = r_axis + self.unit = theta_axis.get("theta_unit", "radians") + self.unit_scale = math.pi / 180.0 if self.unit == "degrees" else 1.0 + self.turn = 360.0 if self.unit == "degrees" else 2.0 * math.pi + zero = theta_axis.get("theta_zero", "E") + self.zero = THETA_ZERO[zero] if isinstance(zero, str) else float(zero) + self.direction = theta_axis.get("theta_direction", "counterclockwise") + self.dir = -1.0 if self.direction == "clockwise" else 1.0 + sector = theta_axis.get("sector") or (0.0, self.turn) + self.sector_start, self.sector_end = (float(sector[0]), float(sector[1])) + self.sector_span = self.sector_end - self.sector_start + self.full_sector = self.sector_span >= self.turn * (1.0 - 1e-9) + self.sector_a0 = self.zero + self.dir * self.unit_scale * self.sector_start + self.sector_a1 = self.zero + self.dir * self.unit_scale * self.sector_end + self.grid_shape = theta_axis.get("grid_shape", "circular") + self.categories = tuple(theta_axis.get("categories") or ()) + self.category_count = len(self.categories) + + r_lo, r_hi = r_axis["range"] + self.r_lo, self.r_hi = float(r_lo), float(r_hi) + self.r_scale = _Scale(r_axis, 0.0, 1.0) + self.r_lo_coord = float(self.r_scale.coord(self.r_lo)) + self.r_hi_coord = float(self.r_scale.coord(self.r_hi)) + origin = r_axis.get("r_origin") + self.r_origin = self.r_lo if origin is None else float(origin) + self.r_origin_coord = float(self.r_scale.coord(self.r_origin)) + self.hole = float(r_axis.get("hole") or 0.0) + + # Full turns retain the original normative layout exactly. A partial + # sector instead fills the plot with its own bounding box: a gauge must + # not reserve dead space for the missing part of the circle. + if self.full_sector: + self.radius = min(plot["w"], plot["h"]) / 2.0 + self.cx = plot["x"] + plot["w"] / 2.0 + self.cy = plot["y"] + plot["h"] / 2.0 + else: + lo_angle = min(self.sector_a0, self.sector_a1) + hi_angle = max(self.sector_a0, self.sector_a1) + angles = [self.sector_a0, self.sector_a1] + for cardinal in (0.0, math.pi / 2.0, math.pi, 3.0 * math.pi / 2.0): + first = math.ceil((lo_angle - cardinal) / (2.0 * math.pi)) + last = math.floor((hi_angle - cardinal) / (2.0 * math.pi)) + angles.extend( + cardinal + turn_index * 2.0 * math.pi for turn_index in range(first, last + 1) + ) + angles_array = np.asarray(angles, dtype=np.float64) + inner = max(0.0, min(1.0, float(self.norm_radius(self.r_lo)))) + xs = np.concatenate((np.cos(angles_array), inner * np.cos(angles_array))) + ys = np.concatenate((-np.sin(angles_array), -inner * np.sin(angles_array))) + if inner <= 1e-12: + xs = np.append(xs, 0.0) + ys = np.append(ys, 0.0) + xmin, xmax = float(np.min(xs)), float(np.max(xs)) + ymin, ymax = float(np.min(ys)), float(np.max(ys)) + xspan = max(xmax - xmin, 1e-12) + yspan = max(ymax - ymin, 1e-12) + self.radius = min(plot["w"] / xspan, plot["h"] / yspan) + left = plot["x"] + (plot["w"] - self.radius * xspan) / 2.0 + top = plot["y"] + (plot["h"] - self.radius * yspan) / 2.0 + self.cx = left - self.radius * xmin + self.cy = top - self.radius * ymin + + def theta_value(self, theta: Any) -> Any: + """Category code or numeric theta -> angular value in declared units.""" + th = np.asarray(theta, dtype=np.float64) + if not self.category_count: + return th + divisor = ( + float(self.category_count) + if self.full_sector + else float(max(self.category_count - 1, 1)) + ) + return self.sector_start + th * self.sector_span / divisor + + def angle(self, theta: Any) -> Any: + """Data theta -> screen angle in radians, ccw from East.""" + th = self.theta_value(theta) * self.unit_scale + return self.zero + self.dir * th + + def theta_from_angle(self, angle: Any, *, near: Optional[float] = None) -> Any: + """Screen angle -> numeric theta/category code. + + The result is wrapped relative to the authored sector. ``near`` is a + heatmap range start and selects the equivalent full-turn value nearest + that grid, matching the fragment shader at the angular seam. + """ + raw = (np.asarray(angle, dtype=np.float64) - self.zero) / (self.dir * self.unit_scale) + anchor = self.sector_start if near is None else float(near) + raw = anchor + np.mod(raw - anchor, self.turn) + if not self.category_count: + return raw + divisor = ( + float(self.category_count) + if self.full_sector + else float(max(self.category_count - 1, 1)) + ) + return (raw - self.sector_start) * divisor / (self.sector_span or 1.0) + + def theta_visible_mask(self, theta: Any) -> np.ndarray: + """Which angular values fall in the authored sector.""" + raw = np.asarray(self.theta_value(theta), dtype=np.float64) + return self._angular_value_visible_mask(raw) + + def _angular_value_visible_mask(self, raw: Any) -> np.ndarray: + raw = np.asarray(raw, dtype=np.float64) + if self.full_sector: + return np.isfinite(raw) + offset = np.mod(raw - self.sector_start, self.turn) + return np.isfinite(raw) & (offset <= self.sector_span + self.turn * 1e-9) + + def angle_visible(self, angle: float) -> bool: + raw = (float(angle) - self.zero) / (self.dir * self.unit_scale) + return bool(self._angular_value_visible_mask(raw)) + + def filter_theta_values(self, values: Sequence[float]) -> list[float]: + if not values: + return [] + mask = self.theta_visible_mask(values) + return [float(value) for value, keep in zip(values, mask, strict=True) if bool(keep)] + + def norm_radius(self, r: Any) -> Any: + coord = np.asarray(self.r_scale.coord(r), dtype=np.float64) + span = self.r_hi_coord - self.r_origin_coord + if abs(span) <= 1e-30: + return np.full_like(coord, np.nan, dtype=np.float64) + base = (coord - self.r_origin_coord) / span + return self.hole + (1.0 - self.hole) * base + + def radius_value(self, normalized: Any) -> Any: + """Inverse of ``norm_radius`` back to radial data space.""" + normalized = np.asarray(normalized, dtype=np.float64) + base = (normalized - self.hole) / max(1.0 - self.hole, 1e-30) + coord = self.r_origin_coord + base * (self.r_hi_coord - self.r_origin_coord) + return self.r_scale.value(coord) + + @property + def inner_fraction(self) -> float: + return max(0.0, min(1.0, float(self.norm_radius(self.r_lo)))) + + @property + def inner_radius(self) -> float: + return self.inner_fraction * self.radius + + def visible_mask(self, r: Any) -> np.ndarray: + """Which radii have an honest polar position — `xyPolarPos`'s cull. + + Below the radial minimum a point would mirror through the centre into + the opposite quadrant (still *inside* the disc, so no clip saves it); + above the maximum it lands past the outer ring. Even though both static + exporters now have a shaped mark clip, invalid data vertices must still + split paths consistently with the client's shader NaN cull + (`rn < 0 || rn > 1 + 1e-6` in js/src/40_gl.ts). Same epsilon, so the + outermost home-view point survives everywhere. + """ + coord = np.asarray(self.r_scale.coord(r), dtype=np.float64) + lo = min(self.r_lo_coord, self.r_hi_coord) + hi = max(self.r_lo_coord, self.r_hi_coord) + return np.isfinite(coord) & (coord >= lo - 1e-6) & (coord <= hi + 1e-6) + + def position_mask(self, theta: Any, r: Any) -> np.ndarray: + return self.theta_visible_mask(theta) & self.visible_mask(r) + + def __call__(self, theta: Any, r: Any) -> tuple[Any, Any]: + a = self.angle(theta) + rn = self.norm_radius(r) * self.radius + return self.cx + rn * np.cos(a), self.cy - rn * np.sin(a) + + def ring(self, r: float, steps: int = 180) -> list[tuple[float, float]]: + """A constant-r sector arc (a closed circle for a full turn). + + The raster display list has no arc, wedge or circle opcode — every + curve is a pre-flattened polygon (`_round_rect_pts` is the existing + precedent) — so grid rings flatten here and both exporters consume the + same points. + """ + rn = float(self.norm_radius(r)) * self.radius + count = steps if self.full_sector else steps + 1 + return [ + ( + self.cx + + rn * math.cos(self.sector_a0 + (self.sector_a1 - self.sector_a0) * i / steps), + self.cy + - rn * math.sin(self.sector_a0 + (self.sector_a1 - self.sector_a0) * i / steps), + ) + for i in range(count) + ] + + def polygon_ring(self, r: float, theta_values: Sequence[float]) -> list[tuple[float, float]]: + values = self.filter_theta_values(theta_values) + if not values: + return [] + values.sort( + key=lambda value: float( + np.mod(float(self.theta_value(value)) - self.sector_start, self.turn) + ) + ) + values = [ + value + for index, value in enumerate(values) + if index == 0 + or not math.isclose( + float( + np.mod( + float(self.theta_value(value)) - float(self.theta_value(values[index - 1])), + self.turn, + ) + ), + 0.0, + rel_tol=0, + abs_tol=self.turn * 1e-10, + ) + ] + if not self.full_sector: + if not math.isclose( + float(self.theta_value(values[0])), self.sector_start, rel_tol=0, abs_tol=1e-9 + ): + values.insert(0, self._theta_data_for_sector(self.sector_start)) + if not math.isclose( + float(self.theta_value(values[-1])), self.sector_end, rel_tol=0, abs_tol=1e-9 + ): + values.append(self._theta_data_for_sector(self.sector_end)) + x, y = self(values, np.full(len(values), r, dtype=np.float64)) + return list(zip(np.asarray(x, dtype=float), np.asarray(y, dtype=float), strict=True)) + + def _theta_data_for_sector(self, value: float) -> float: + if not self.category_count: + return value + divisor = ( + float(self.category_count) + if self.full_sector + else float(max(self.category_count - 1, 1)) + ) + return (value - self.sector_start) * divisor / (self.sector_span or 1.0) + + def wedge_angles(self, theta0: float, theta1: float) -> Optional[tuple[float, float]]: + """Visible screen-angle interval for an authored angular band.""" + raw0 = float(self.theta_value(theta0)) + raw1 = float(self.theta_value(theta1)) + if not (math.isfinite(raw0) and math.isfinite(raw1)): + return None + if self.full_sector: + return ( + self.zero + self.dir * self.unit_scale * raw0, + self.zero + self.dir * self.unit_scale * raw1, + ) + + low, high = min(raw0, raw1), max(raw0, raw1) + midpoint = (low + high) / 2.0 + sector_midpoint = (self.sector_start + self.sector_end) / 2.0 + nearest_turn = round((sector_midpoint - midpoint) / self.turn) + best: Optional[tuple[float, float]] = None + best_span = -1.0 + for turn_index in (nearest_turn - 1, nearest_turn, nearest_turn + 1): + shifted_low = low + turn_index * self.turn + shifted_high = high + turn_index * self.turn + clipped_low = max(self.sector_start, shifted_low) + clipped_high = min(self.sector_end, shifted_high) + span = clipped_high - clipped_low + if span > best_span and span > 1e-12: + best = (clipped_low, clipped_high) + best_span = span + if best is None: + return None + clipped0, clipped1 = best if raw0 <= raw1 else (best[1], best[0]) + return ( + self.zero + self.dir * self.unit_scale * clipped0, + self.zero + self.dir * self.unit_scale * clipped1, + ) + + def frame_points( + self, theta_values: Sequence[float] = (), steps: int = 180 + ) -> list[tuple[float, float]]: + if self.grid_shape == "linear" and theta_values: + return self.polygon_ring(self.r_hi, theta_values) + return self.ring(self.r_hi, steps) + + @property + def affine(self) -> bool: + """Never affine — see `affine_fast_path`.""" + return False + + +def affine_fast_path( + sx: "_Scale", sy: "_Scale", polar: "Optional[_PolarProjection]" = None +) -> bool: + """May an emitter bake a straight-line data->pixel map into Rust? + + Several emitters hand Rust two affine scales and let it project while + painting. A polar chart on linear axes satisfies `sx.affine and sy.affine` + while being emphatically non-affine, so every such gate must ask this + instead — one predicate rather than a `polar is None` conjunct repeated at + each site, which is how one gate got missed and shipped a colormapped polar + scatter projected as cartesian (§6). + """ + return polar is None and sx.affine and sy.affine + + def _colormap_key(colormap: Any) -> str: """A stable, document-unique id fragment for a colormap — a built-in name, or the digest of a custom ramp's stops (two colorbars in one document must @@ -1377,11 +1771,84 @@ def _poly_path(px: np.ndarray, py: np.ndarray) -> str: return _native.svg_poly_path(px, py) -def _curve_path(xv: np.ndarray, yv: np.ndarray, sx: _Scale, sy: _Scale, smooth: bool) -> str: +def _polar_visible_runs( + xv: np.ndarray, yv: np.ndarray, polar: "_PolarProjection" +) -> list[np.ndarray]: + """Index runs of consecutive vertices the polar transform keeps. + + The same split `_curve_path` performs, exposed so a filled area can close + each run against its own base instead of stitching every run to one base. + """ + visible = polar.position_mask(xv, yv) + if visible.size == 0: + return [] + idx = np.flatnonzero(visible) + if idx.size == 0: + return [] + runs = np.split(idx, np.flatnonzero(np.diff(idx) > 1) + 1) + return [run for run in runs if len(run) >= 2] + + +def _area_fill_path( + xv: np.ndarray, + yv: np.ndarray, + bv: np.ndarray, + sx: _Scale, + sy: _Scale, + smooth: bool, + polar: "Optional[_PolarProjection]" = None, +) -> str: + """Closed fill path between a top curve and its base, or "" if nothing is + visible. Under polar each visible run closes separately.""" + if polar is None: + top = _curve_path(xv, yv, sx, sy, smooth, None) + base = _curve_path(xv[::-1], bv[::-1], sx, sy, smooth, None) + return f"{top} L {base[2:]} Z" if top and base else "" + parts = [] + for run in _polar_visible_runs(xv, yv, polar): + top = _curve_path(xv[run], yv[run], sx, sy, smooth, polar) + base = _curve_path(xv[run][::-1], bv[run][::-1], sx, sy, smooth, polar) + if top and base: + parts.append(f"{top} L {base[2:]} Z") + return " ".join(parts) + + +def _curve_path( + xv: np.ndarray, + yv: np.ndarray, + sx: _Scale, + sy: _Scale, + smooth: bool, + polar: "Optional[_PolarProjection]" = None, +) -> str: """Pixel-space path for a polyline; smooth -> exact cubic Béziers of the monotone-cubic Hermite (affine axes), else polyline. The Bézier control points of a Hermite segment are P0 + h/3·(1, m0) and P1 - h/3·(1, m1), - and affine axis maps carry control points exactly.""" + and affine axis maps carry control points exactly. + + Under `polar` the separable (sx, sy) pair is replaced by the joint + projection and the result is always a polyline: consecutive data points are + joined by straight **chords**, which is Plotly's polar semantics and what + makes radar/spider edges come out straight (polar-axes.md §5). Vertices + outside the radial range are culled like the client shader culls them — + the path splits into visible runs, dropping any chord with a culled + endpoint whole (§8).""" + if len(xv) == 0: + # `visible.all()` is vacuously true on an empty array, so this fell + # through to the native poly-path builder, which rejects a zero-length + # buffer. A log radial axis annihilating every row, or an all-NaN + # series, therefore crashed the export instead of drawing nothing. + return "" + if polar is not None: + px, py = polar(xv, yv) + visible = polar.position_mask(xv, yv) + if bool(visible.all()): + return _poly_path(px, py) + runs = np.split( + np.flatnonzero(visible), + np.flatnonzero(np.diff(np.flatnonzero(visible)) > 1) + 1, + ) + return " ".join(_poly_path(px[run], py[run]) for run in runs if len(run) >= 2) px, py = sx(xv), sy(yv) if not smooth or len(xv) < 3 or not (sx.affine and sy.affine): return _poly_path(px, py) @@ -2017,19 +2484,34 @@ def _decode_title_geometry(spec: dict[str, Any], blob: bytes) -> dict[str, Any]: return {**spec, "title_options": decoded} if changed else spec +def _title_wrap_width(width: float, left: float, right: float) -> float: + """Width a chart title wraps at, in CSS px. + + Deliberately derived from the *authored/default* horizontal gutters rather + than the final plot rect: the measured left gutter depends on the plot + height, which depends on the title band, so wrapping at the final width + would be circular. `_recut_polar_plot` and the measured gutters may narrow + the plot afterwards; the title keeps this width so what layout reserved is + what gets drawn. Mirrored by `_titleWrapWidth` in js/src/50_chartview.ts. + """ + return max(40.0, float(width) - float(left) - float(right)) + + def _title_metrics( - spec: dict[str, Any], entry: dict[str, Any] + spec: dict[str, Any], + entry: dict[str, Any], + wrap_width: float | None = None, ) -> tuple[dict[str, Any], float, _textblock.TextBlock]: base = slot_styles(spec).get("title") or {} style = {**base, **(entry.get("style") or {})} size = _px_size(style.get("font-size"), 14.0) - return style, size, _textblock.measure(entry["text"], size) + return style, size, _textblock.measure(entry["text"], size, max_width=wrap_width) -def _title_room(spec: dict[str, Any], compact: bool) -> float: +def _title_room(spec: dict[str, Any], compact: bool, wrap_width: float | None = None) -> float: room = 0.0 for entry in _title_entries(spec): - _style, _size, block = _title_metrics(spec, entry) + _style, _size, block = _title_metrics(spec, entry, wrap_width) pad = float(entry.get("pad", 8.0)) if entry.get("automatic_y", True): candidate = max(26.0 if compact else 30.0, block.height + pad) @@ -2058,11 +2540,14 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: top = 6 if compact else 10 bottom = 36 if compact else 42 axes = _axes_by_id(spec) - title_room = _title_room(spec, compact) # The first pass uses the authored/default horizontal allocation. A second # pass after the measured left gutter catches an auto-collision decision # whose final plot width changes the chosen label set. provisional_w = max(40.0, width - left - right) + # Resolved before the title band, because the band's height now depends on + # how many lines the title wraps into at this width. + title_wrap_width = _title_wrap_width(width, left, right) + title_room = _title_room(spec, compact, title_wrap_width) top_axis_room, bottom_axis_room, measured_bottom_room = _x_axis_rooms( axes, provisional_w, compact ) @@ -2135,21 +2620,312 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: # Emitters place the figure title above this gutter; recording it here # keeps layout() the single source of the top-axis reservation. "title_room": title_room, + # The width the title band was measured at. Emitters must wrap at the + # same width or they draw more lines than `title_room` reserved. + "title_wrap_width": title_wrap_width, "top_axis_room": top_axis_room, "bottom_axis_room": bottom_axis_room, } + if spec.get("coords") == "polar": + _recut_polar_plot(spec, plot, width, height, compact) return width, height, compact, plot +# Room reserved outside the outer ring for angular tick labels. Cartesian +# gutters are per-side because labels hug two edges; a polar chart carries them +# all the way around, so the allowance is uniform. +# Mirrored by POLAR_LABEL_ROOM in js/src/50_chartview.ts. +_POLAR_LABEL_ROOM = 30.0 +# Ceiling on the measured allowance: past this a long label shrinks the disc +# more than it helps, so it truncates against the canvas instead. +_POLAR_LABEL_ROOM_MAX = 90.0 + +# Angle of the spoke the radial tick labels run along, in degrees off the theta +# zero direction. Matplotlib's default `rlabel_position`; keeping the labels off +# the zero spoke stops them colliding with the theta=0 angular label. Shared by +# both exporters so they cannot drift apart. +# Mirrored by POLAR_RLABEL_DEG in js/src/50_chartview.ts. +_POLAR_RLABEL_DEG = 22.5 + +# Gap in px between the outer ring and the angular tick labels. +# Mirrored by POLAR_TICK_GAP in js/src/50_chartview.ts. +_POLAR_TICK_GAP = 8.0 + +# Gutter reserved for a legend beside a disc. A Cartesian legend overlays the +# plot because data rarely reaches a corner; a disc inscribed in its rect leaves +# no corner at all, so an inside legend lands on the marks — an `upper right` box +# covered a wind rose's whole north-east quadrant and the outer radial label +# under it. Both incumbents' answer is to move it out (Plotly puts polar legends +# in the figure margin), which needs room the disc gives back. +# +# A FRACTION OF THE CANVAS, clamped, rather than a measurement of the label set: +# every renderer knows the canvas width to the pixel, so all three reserve the +# identical box, while a measured reservation would drift with each renderer's +# font metrics (DejaVu here, system-ui in the browser). A flat constant was tried +# first and is the wrong shape — 96 px ellipsized `Partner (30%)`, an ordinary +# pie slice's default name, while being a fifth of a phone canvas and a +# fifteenth of a wide one. +# +# The floor keeps a narrow chart's legend readable; the ceiling stops a wide one +# from spending 300 px on four short rows. A label still wider than the gutter +# ellipsizes with its full text in `title`/ARIA, exactly as the static exporters +# already ellipsize against the plot width. +# Mirrored by xyPolarLegendRoom in js/src/50_chartview.ts. +_POLAR_LEGEND_ROOM_FRACTION = 0.22 +_POLAR_LEGEND_ROOM_MIN = 120.0 +_POLAR_LEGEND_ROOM_MAX = 200.0 + + +def _polar_legend_room(width: float) -> float: + """Side-gutter width for a polar legend on a `width`-px canvas. + + `floor`, not `round`: Python and JavaScript disagree about half-way cases + (banker's rounding versus round-half-up) and the two must land on the same + integer pixel. + """ + scaled = math.floor(float(width) * _POLAR_LEGEND_ROOM_FRACTION) + return min(_POLAR_LEGEND_ROOM_MAX, max(_POLAR_LEGEND_ROOM_MIN, float(scaled))) + + +_POLAR_LEGEND_BAND = 64.0 + + +def _polar_legend_reserve(spec: dict[str, Any], compact: bool, width: float) -> tuple[str, float]: + """Side and px a polar legend gutter claims: ``("right", 158.0)`` etc. + + ``("", 0.0)`` when nothing is reserved — a non-polar figure, no legend rows, + an authored ``anchor`` (an explicit plot-relative placement the author owns), + or an authored 4-tuple ``padding`` (which already states the box the plot + should occupy, and is the documented way to hand-reserve a caption band). + + Mirrored by `_polarLegendReserve` in js/src/50_chartview.ts. + """ + if spec.get("coords") != "polar" or not spec.get("show_legend", True): + return "", 0.0 + padding = spec.get("padding") + if isinstance(padding, list) and len(padding) == 4: + return "", 0.0 + options = spec.get("legend") or {} + anchor = options.get("anchor") + if anchor and len(anchor) in (2, 4): + return "", 0.0 + rows = options.get("items") or legend_items(spec.get("traces") or []) + if not rows and not (spec.get("extra_legends") or []): + return "", 0.0 + if compact: + return "bottom", _POLAR_LEGEND_BAND + loc = str(options.get("loc") or "upper right") + return ("left" if "left" in loc else "right"), _polar_legend_room(width) + + +def _polar_label_room(theta_axis: dict[str, Any]) -> float: + """Room outside the ring for the angular tick labels. + + Measured, not fixed: authored category names ("EAST-NORTH-EAST") are far + wider than an angle, and a constant allowance hard-clipped them at the + canvas edge. Only the widest AUTHORED label is measured — generated angle + text is bounded and already fits the floor — and the result is capped so a + pathological label shrinks the disc rather than erasing it. + + Mirrored by `polarLabelRoom` in js/src/50_chartview.ts. + """ + room = _POLAR_LABEL_ROOM + labels = theta_axis.get("tick_labels") + if not labels: + return room + size = _axis_tick_font_size(theta_axis) + widest = max((_textblock.measure(str(text), size).width for text in labels), default=0.0) + return min(_POLAR_LABEL_ROOM_MAX, max(room, widest + _POLAR_TICK_GAP + _AXIS_TEXT_EDGE_PAD)) + + +def _recut_polar_plot( + spec: dict[str, Any], + plot: dict[str, float], + width: float, + height: float, + compact: bool = False, +) -> None: + """Re-cut the plot rect for a disc, in place. + + Mirrored by `_recutPolarPlot` in js/src/50_chartview.ts — the two must agree + or the same chart renders at a different size and centre in the browser than + in an export. + + Two things happen here, both after the cartesian gutter passes have + converged so they cannot perturb that fixed point. + + First, the cartesian tick-label gutters are given back. They exist to hold + labels hugging the left and bottom edges; a polar chart carries its labels + all the way around the rim instead, so leaving them reserved pushed the disc + right and up (a 400x400 chart centred its circle at x=219) and shrank it for + no reason. The horizontal and vertical reservations are symmetrised rather + than simply zeroed, so a colorbar or right-side axis that genuinely claimed + space still keeps it. + + Second, a uniform allowance is reserved all the way around for the angular + tick labels. The radius is `min(w, h) / 2` with no fill factor + (polar-axes.md §3), so that room has to come out of the rect rather than out + of the transform — otherwise every renderer would need the same fudge factor + and they would eventually disagree about it. + + Third, a legend gutter (`_polar_legend_reserve`) is taken off the rect and + recorded as `plot["legend_box"]`, so the legend sits beside the disc instead + of on top of it. `_legend_layout` places and bounds itself in that box. + """ + theta_axis = spec.get("x_axis") or {} + if theta_axis.get("tick_label_strategy") == "none": + return + # The legend gutter is taken off the canvas edge FIRST, before the disc is + # fitted to what is left, so the disc never occupies the gutter and the + # legend never occupies the disc. Recorded as four floats rather than a + # nested rect so `plot` stays a flat float map. + canvas_x0 = 0.0 + legend_side, legend_room = _polar_legend_reserve(spec, compact, width) + if legend_room: + if legend_side == "left": + box = (0.0, plot["y"], legend_room, plot["h"]) + canvas_x0 = legend_room + plot["x"] = max(plot["x"], legend_room) + elif legend_side == "right": + width -= legend_room + box = (width, plot["y"], legend_room, plot["h"]) + else: + height -= legend_room + box = (plot["x"], height, plot["w"], legend_room) + plot["legend_box_x"], plot["legend_box_y"] = box[0], box[1] + plot["legend_box_w"], plot["legend_box_h"] = box[2], box[3] + plot["w"] = max(40.0, min(plot["w"], width - plot["x"])) + plot["h"] = max(40.0, min(plot["h"], height - plot["y"])) + # The top gutter also holds the figure title, which emitters place at + # `plot.y - top_axis_room - pad`; it is a floor, never given back. + reserved_top = plot["y"] + reserved_right = width - plot["x"] - plot["w"] + reserved_bottom = height - plot["y"] - plot["h"] + + room = _polar_label_room(theta_axis) + authored_pad = spec.get("padding") + if isinstance(authored_pad, list) and len(authored_pad) == 4: + # An explicit `padding` states the box the author wants the plot to + # occupy — most often to reserve a band under the disc for a legend or + # caption, which is what every donut composition needs. Reclaiming the + # gutters below would throw that away (a chart authored with + # `padding=[0, 0, 140, 0]` came out with its disc filling the canvas, + # the reserved band gone). So an authored box is only inset by the + # uniform label room, and the disc centres in what is left. + left = plot["x"] + room + right = plot["x"] + plot["w"] - room + top = plot["y"] + room + bottom = plot["y"] + plot["h"] - room + box_w, box_h = right - left, bottom - top + if box_w >= 40.0 and box_h >= 40.0: + plot["x"], plot["y"], plot["w"], plot["h"] = left, top, box_w, box_h + plot["top_axis_room"] = plot["top_axis_room"] + room + return + side = max(room, reserved_right) + # A radial-axis title is still drawn in the left gutter — a disc gives it no + # natural home — and `_axis_label_geometry` positions it outward from the + # plot edge past the tick-label room. So when one is set, the original + # gutter is kept whole rather than part-reclaimed: shaving it put the title + # at x = -10, off the canvas. Charts with no radial title (the common case) + # still get the full reclaim. + y_axis = spec.get("y_axis") or {} + titled = bool(y_axis.get("label")) and _axis_text_paint_visible(y_axis, "label_color") + # `canvas_x0` is a left legend gutter; the label room still applies inside it. + # With no gutter it is 0 and `side >= room`, so this is the previous value. + left = max(max(side, plot["x"]) if titled else side, canvas_x0 + room) + right = width - side + # Vertically the title side is fixed, so only the bottom can be + # symmetrised — and only when the theta axis has no title of its own, + # because that title is drawn in the bottom gutter and reclaiming the band + # pushed it below the canvas edge. + x_axis = spec.get("x_axis") or {} + x_titled = bool(x_axis.get("label")) and _axis_text_paint_visible(x_axis, "label_color") + # A horizontal colorbar is placed relative to the plot's BOTTOM edge, so + # extending the rect downward walks it off the canvas. Its gutter is real + # chrome, not a tick-label gutter: keep it whole, like a theta title. + colorbar = spec.get("colorbar") or {} + keeps_bottom = x_titled or colorbar.get("orientation") == "horizontal" + bottom_reserve = reserved_bottom if keeps_bottom else min(reserved_bottom, reserved_top) + bottom = height - max(room, bottom_reserve) + top = reserved_top + room + + # Measure BEFORE clamping: clamping first made the guard below unreachable, + # so a chart too small for the label room silently got a 40px floor rect + # instead of keeping its circle. Mirrored by _recutPolarPlot's early return. + box_w = right - left + box_h = bottom - top + if box_w < 40.0 or box_h < 40.0: + # Too small for the label room. Do NOT fall back to the cartesian rect: + # its own 40px floor can be wider than the canvas, and a disc centred + # in it leaves the page (an 80x80 chart drew its circle out to x=86). + # Take the largest centred box the canvas itself allows instead. + margin = min(4.0, width / 8.0, height / 8.0) + plot["x"] = margin + plot["y"] = max(margin, min(reserved_top, height / 4.0)) + plot["w"] = max(8.0, width - 2 * margin) + plot["h"] = max(8.0, height - plot["y"] - margin) + return + plot["x"] = left + plot["y"] = top + plot["w"] = box_w + plot["h"] = box_h + # The top slice is angular-label room, so it belongs to the axis + # reservation: without this the title would ride the rect down and the + # topmost angular label would land on top of it. + plot["top_axis_room"] = plot["top_axis_room"] + room + # Re-square the legend gutter against the FINAL rect so the box tracks the + # disc it sits beside rather than the pre-recut rect it was cut from. + if legend_room: + if legend_side in ("left", "right"): + plot["legend_box_y"], plot["legend_box_h"] = plot["y"], plot["h"] + else: + plot["legend_box_x"], plot["legend_box_w"] = plot["x"], plot["w"] + + +def _tick_window(axis: dict[str, Any]) -> tuple[float, float]: + """The value window ticks are drawn in — the sector for an angular axis.""" + lo, hi = axis["range"] + if axis.get("theta_unit") is not None: + if axis.get("kind") == "category": + lo, hi = 0.0, float(max(0, len(axis.get("categories") or []) - 1)) + else: + lo, hi = axis.get("sector") or (lo, hi) + return float(lo), float(hi) + + +def _tick_window_filter(axis: dict[str, Any], lo: float, hi: float) -> Callable[[float], bool]: + """Predicate keeping the tick values that fall inside the axis window. + + An angular window may cross the 0/turn seam — ``sector=(300, 420)``, or the + compass-natural ``(-30, 30)``. The plain ``low <= v <= high`` test throws + away every tick authored on the far side of that seam (0, 30 and 60 for the + first; 330, 340, 350 for the second) while a *data point* at the very same + angle plots inside the sector, because mark culling is modular. Ticks now + use the same modular containment as + `_PolarProjection._angular_value_visible_mask`, so the spokes and the marks + agree about what the sector contains. + """ + low, high = min(lo, hi), max(lo, hi) + unit = axis.get("theta_unit") + if unit is None or axis.get("kind") == "category": + return lambda value: low <= value <= high + turn = 360.0 if unit == "degrees" else 2.0 * math.pi + span = high - low + # NaN falls out of both branches: np.mod propagates it and the comparison + # is False, matching the linear test it replaces. + return lambda value: bool(np.mod(value - low, turn) <= span + turn * 1e-9) + + def axis_ticks( axis: dict[str, Any], length_px: float, is_x: bool ) -> tuple[list[float], list[float], float]: """(ticks, labeled ticks, step) for an axis at a given pixel length — shared tick density so SVG and PNG label the same values.""" + kind = axis.get("kind") + lo, hi = _tick_window(axis) if axis.get("tick_values") is not None: - lo, hi = axis["range"] - low, high = min(lo, hi), max(lo, hi) - ticks = [float(v) for v in axis["tick_values"] if low <= float(v) <= high] + keep = _tick_window_filter(axis, lo, hi) + ticks = [float(v) for v in axis["tick_values"] if keep(float(v))] step = abs(ticks[1] - ticks[0]) if len(ticks) > 1 else 1.0 return ticks, ticks, step requested = axis.get("tick_count") @@ -2157,8 +2933,21 @@ def axis_ticks( target = max(1, min(200, int(requested))) else: target = max(3, int(length_px / 80)) if is_x else max(3, int(length_px / 45)) - kind = axis.get("kind") - lo, hi = axis["range"] + # Category theta is category-index space, even though it also carries the + # angular descriptors. Its labels and tick positions must win over angle + # formatting/generation; the projection maps the codes into the sector. + # Each category is also one spoke/polygon vertex, so the default must not + # thin them by pixel density. An explicit tick_count remains the opt-in + # control for authors who want fewer spokes. + if kind == "category": + categories = axis.get("categories") or [] + if axis.get("theta_unit") is not None and requested is None: + target = len(categories) + t = [float(v) for v in _category_ticks(lo, hi, len(axis.get("categories") or []), target)] + return t, t, 1.0 + if axis.get("theta_unit") is not None: + t, step = _angular_ticks(lo, hi, axis["theta_unit"], target) + return t, t, step if axis.get("scale") == "log" or kind == "log": return _log_ticks(lo, hi, target) if axis.get("scale") == "symlog": @@ -2176,9 +2965,6 @@ def inverse(value: float) -> float: ticks.append(0.0) ticks.sort(reverse=lo > hi) return ticks, ticks, abs(float(inverse(step))) - if kind == "category": - t = [float(v) for v in _category_ticks(lo, hi, len(axis.get("categories") or []), target)] - return t, t, 1.0 if kind == "time": t, step = _time_ticks(lo, hi, target) return t, t, step @@ -2190,13 +2976,8 @@ def minor_axis_ticks(axis: dict[str, Any]) -> list[float]: values = axis.get("minor_tick_values") if values is None: return [] - lo, hi = axis["range"] - low, high = min(lo, hi), max(lo, hi) - return [ - float(value) - for value in values - if np.isfinite(float(value)) and low <= float(value) <= high - ] + keep = _tick_window_filter(axis, *_tick_window(axis)) + return [float(value) for value in values if np.isfinite(float(value)) and keep(float(value))] def _axis_tick_label_strategy(axis: dict[str, Any]) -> str: @@ -2479,7 +3260,552 @@ def _axis_label_geometry( } -@_textblock.cached_measurements +def polar_wedge_points( + polar: "_PolarProjection", + theta0: float, + theta1: float, + r0: float, + r1: float, + steps: Optional[int] = None, + corner_radius: float = 0.0, + wedge_gap: float = 0.0, +) -> list[tuple[float, float]]: + """An annular sector as a closed polygon — the flattened twin of + `_polar_wedge_path`, for the raster display list (no arc opcode). + + Both are driven by the same angles and radii, so the two exports agree to + within the flattening. `steps` defaults to `config.polar_bar_segments` over + this wedge's own sweep — a 22.5-degree wind-rose sector is flattened with six + segments rather than the full-turn worst case of 96, at the same sagitta + bound. Pass an explicit count only to pin one. + """ + # Clamp both radii into the visible radial interval: a bar crossing r_lo or + # r_hi retains the visible part instead of becoming an invalid endpoint. + # The client clamps identically in BAR_VS; the static shaped clips then + # contain stroke antialiasing at the exact annular-sector boundary. + floor = polar.inner_fraction + # Order the NORMALIZED fractions before clamping: on a reversed radial + # axis `norm_radius` is decreasing, so norm(r1) < norm(r0) for r1 > r0 and + # taking them positionally dropped every wedge from both static exports + # while the shader (which min/maxes u_rrange) kept drawing them. + lo_frac, hi_frac = sorted((float(polar.norm_radius(r0)), float(polar.norm_radius(r1)))) + outer = min(1.0, max(floor, hi_frac)) * polar.radius + inner = min(1.0, max(floor, lo_frac)) * polar.radius + if outer <= 0.0 or outer <= inner: + return [] + angles = polar.wedge_angles(theta0, theta1) + if angles is None: + return [] + a0, a1 = angles + if steps is None: + steps = polar_bar_segments(a1 - a0, 2.0 * math.pi) + + if corner_radius > 0.0 and inner > 0.0: + return _rounded_wedge_points(polar, a0, a1, inner, outer, corner_radius, steps, wedge_gap) + + # A constant ANGULAR pad makes the gap between neighbours `r · dtheta` wide, + # so it tapers to nothing at the hole and is widest at the rim — the seam + # between two pie slices visibly converges toward the centre. A constant + # gap in px needs an angular inset that grows as the radius shrinks; the + # two radial edges then become straight lines a fixed distance apart, which + # is what d3's padAngle/padRadius pair and every pie in the wild produce. + inset = _wedge_edge_inset(wedge_gap, a0, a1) + + def arc(radius: float, reverse: bool) -> list[tuple[float, float]]: + d = inset(radius) + start, end = (a1 - d, a0 + d) if reverse else (a0 + d, a1 - d) + out = [] + for i in range(steps + 1): + angle = start + (end - start) * (i / steps) + out.append((polar.cx + radius * math.cos(angle), polar.cy - radius * math.sin(angle))) + return out + + if inner <= 0.0: + return [(polar.cx, polar.cy), *arc(outer, False)] + return [*arc(outer, False), *arc(inner, True)] + + +def _wedge_edge_inset(wedge_gap: float, a0: float, a1: float): + """Per-radius angular inset that realises a constant px gap between wedges. + + Half the gap is taken off each side, and `gap / (2r)` radians at radius `r` + is `gap / 2` px of arc — so neighbouring slices end up separated by the same + number of pixels from the hole to the rim. Clamped so a gap wider than the + slice collapses it rather than inverting the edges. + """ + half = max(0.0, float(wedge_gap)) / 2.0 + sign = 1.0 if a1 >= a0 else -1.0 + span = abs(a1 - a0) + + def inset(radius: float) -> float: + if half <= 0.0 or radius <= 1e-9: + return 0.0 + return sign * min(half / radius, span / 2.0) + + return inset + + +def _rounded_wedge_points( + polar: "_PolarProjection", + a0: float, + a1: float, + inner: float, + outer: float, + corner_radius: float, + steps: int, + wedge_gap: float = 0.0, +) -> list[tuple[float, float]]: + """An annular sector with rounded corners, as a closed polygon. + + `corner_radius` on a slice is what every donut, progress ring and gauge + design in the wild asks for, and it has no rectangle to hang off. The + definition used here is the one the client's fragment SDF uses, so the + three renderers agree: unroll the wedge into an (arc, radial) frame — where + it *is* a rectangle, of half-height `hr` and half-width `sweep/2 · dist` at + each radius — round it there with the standard rounded-rect profile, and + roll it back. The corners then follow the arc instead of being chorded off. + + Sampled rather than expressed as SVG arcs: the rounded profile is not a + circular arc once rolled back (its angular inset varies with radius), so a + polyline is the honest shape rather than an approximation of one. Plain + wedges keep their exact `A` arcs — this path is only taken when a radius is + actually asked for. + """ + r_mid = (inner + outer) / 2.0 + hr = (outer - inner) / 2.0 + sweep = abs(a1 - a0) + mid = (a0 + a1) / 2.0 + sign = 1.0 if a1 >= a0 else -1.0 + + def half_angle(lr: float) -> float: + dist = r_mid + lr + if dist <= 1e-9: + return 0.0 + # Taking a constant number of px off the arc half-width at every + # radius is exactly the constant-width gap (see `_wedge_edge_inset`); + # the corner radius then clamps against the reduced width. + ha_px = max(sweep * 0.5 * dist - max(0.0, wedge_gap) / 2.0, 0.0) + rad = min(corner_radius, hr, ha_px) + over = abs(lr) - (hr - rad) + if over <= 0.0: + half_px = ha_px + else: + half_px = (ha_px - rad) + math.sqrt(max(0.0, rad * rad - over * over)) + return half_px / dist + + def at(dist: float, angle: float) -> tuple[float, float]: + return polar.cx + dist * math.cos(angle), polar.cy - dist * math.sin(angle) + + out: list[tuple[float, float]] = [] + # Outer rim, then the trailing edge inward, then the inner rim back, then + # the leading edge outward. Each edge samples the rounded profile, so the + # corner arcs fall out of the same walk rather than being spliced in. + for i in range(steps + 1): + t = i / steps + out.append(at(outer, mid - sign * half_angle(hr) + sign * half_angle(hr) * 2.0 * t)) + for i in range(1, steps + 1): + lr = hr - 2.0 * hr * (i / steps) + out.append(at(r_mid + lr, mid + sign * half_angle(lr))) + for i in range(1, steps + 1): + t = i / steps + out.append(at(inner, mid + sign * half_angle(-hr) - sign * half_angle(-hr) * 2.0 * t)) + for i in range(1, steps): + lr = -hr + 2.0 * hr * (i / steps) + out.append(at(r_mid + lr, mid - sign * half_angle(lr))) + return out + + +def _polar_wedge_path( + polar: "_PolarProjection", + theta0: float, + theta1: float, + r0: float, + r1: float, + corner_radius: float = 0.0, + wedge_gap: float = 0.0, +) -> str: + """An annular sector as an SVG path: outer arc, inner arc reversed, closed. + + A polar bar is a wedge, not a rectangle — a 180-degree bar with chorded ends + would read as a triangle. SVG expresses the two arcs exactly with `A`; the + raster exporter flattens the same sector because its display list has no arc + opcode (polar-axes.md §5/§6). + """ + floor = polar.inner_fraction + # Order the NORMALIZED fractions before clamping: on a reversed radial + # axis `norm_radius` is decreasing, so norm(r1) < norm(r0) for r1 > r0 and + # taking them positionally dropped every wedge from both static exports + # while the shader (which min/maxes u_rrange) kept drawing them. + lo_frac, hi_frac = sorted((float(polar.norm_radius(r0)), float(polar.norm_radius(r1)))) + outer = min(1.0, max(floor, hi_frac)) * polar.radius + inner = min(1.0, max(floor, lo_frac)) * polar.radius + if outer <= 0.0 or outer <= inner: + return "" + angles = polar.wedge_angles(theta0, theta1) + if angles is None: + return "" + a0, a1 = angles + if corner_radius > 0.0 and inner > 0.0: + # Rounded corners are not circular arcs once rolled back out of the + # unrolled frame, so the shared polygon is the honest shape here too. + pts = _rounded_wedge_points( + polar, + a0, + a1, + inner, + outer, + corner_radius, + # Same span-proportional count `polar_wedge_points` flattens with, so + # a rounded wedge and its raster twin sample the identical profile. + polar_bar_segments(a1 - a0, 2.0 * math.pi), + wedge_gap, + ) + if len(pts) < 3: + return "" + head = f"M {_num(pts[0][0])} {_num(pts[0][1])}" + rest = " ".join(f"L {_num(x)} {_num(y)}" for x, y in pts[1:]) + return f"{head} {rest} Z" + # `sweep` is in SVG's screen sense: y grows downward, so a counterclockwise + # data sweep draws as a clockwise-negative arc. + sweep = 0 if a1 > a0 else 1 + large = 1 if abs(a1 - a0) > math.pi else 0 + + def at(radius: float, angle: float) -> tuple[float, float]: + return polar.cx + radius * math.cos(angle), polar.cy - radius * math.sin(angle) + + if abs(a1 - a0) >= 2.0 * math.pi * (1.0 - 1e-9): + # A full turn makes the arc endpoints coincide, and SVG omits such an + # arc segment entirely — a 100% donut slice rendered as nothing. Each + # circle is drawn as two half-turn arcs instead; the inner ring winds + # the opposite way so the default nonzero fill leaves the hole open. + def full_circle(radius: float, sweep_flag: int) -> str: + x0, y0 = at(radius, a0) + xm, ym = at(radius, a0 + math.pi) + arc = f"A {_num(radius)} {_num(radius)} 0 1 {sweep_flag}" + return ( + f"M {_num(x0)} {_num(y0)} {arc} {_num(xm)} {_num(ym)} {arc} {_num(x0)} {_num(y0)} Z" + ) + + if inner <= 0.0: + return full_circle(outer, sweep) + return f"{full_circle(outer, sweep)} {full_circle(inner, 1 - sweep)}" + + # The gap is a constant number of PIXELS, so its angular cost grows as the + # radius shrinks (`_wedge_edge_inset`). Both arcs stay exact `A` commands — + # only their endpoints move inward — and the radial edges become straight + # lines a fixed distance apart, which `L` already draws. + inset = _wedge_edge_inset(wedge_gap, a0, a1) + d_out, d_in = inset(outer), inset(max(inner, 1e-9)) + ox0, oy0 = at(outer, a0 + d_out) + ox1, oy1 = at(outer, a1 - d_out) + if inner <= 0.0: + return ( + f"M {_num(polar.cx)} {_num(polar.cy)} L {_num(ox0)} {_num(oy0)} " + f"A {_num(outer)} {_num(outer)} 0 {large} {sweep} {_num(ox1)} {_num(oy1)} Z" + ) + ix1, iy1 = at(inner, a1 - d_in) + ix0, iy0 = at(inner, a0 + d_in) + return ( + f"M {_num(ox0)} {_num(oy0)} " + f"A {_num(outer)} {_num(outer)} 0 {large} {sweep} {_num(ox1)} {_num(oy1)} " + f"L {_num(ix1)} {_num(iy1)} " + f"A {_num(inner)} {_num(inner)} 0 {large} {1 - sweep} {_num(ix0)} {_num(iy0)} Z" + ) + + +def _polar_radial_tick_length(polar: "_PolarProjection") -> float: + """Label-density length for the radial axis under polar. + + Radial labels march along a `_POLAR_RLABEL_DEG` spoke, so their usable run + is the annulus width projected onto that spoke — about a fifth of the plot + at the default 22.5 degrees. Mirrored by _radialTickLength in + js/src/50_chartview.ts. + """ + span = polar.radius * (1.0 - polar.inner_fraction) + return max(1.0, span * abs(math.sin(math.radians(_POLAR_RLABEL_DEG)))) + + +def _polar_thin_radial_labels(labels: list[float], length_px: float) -> list[float]: + """Stride-thin radial tick LABELS to what the spoke can hold. + + The grid rings and the labels come from one tick list, so sizing the whole + list to the spoke thinned the rings too — a 520px disc dropped from three + rings to two. Ring density stays tied to the plot; only the labels, which + are the things that actually collide, are thinned. Endpoints are kept so + the radial extent stays readable. + """ + capacity = max(2, int(length_px / 45)) + if len(labels) <= capacity: + return labels + stride = math.ceil(len(labels) / capacity) + thinned = labels[::stride] + if labels and labels[-1] not in thinned: + thinned.append(labels[-1]) + return thinned + + +def _polar_frame_path(polar: "_PolarProjection") -> str: + """SVG path for the visible annular sector, shared by clip and frame.""" + return _polar_wedge_path( + polar, + polar._theta_data_for_sector(polar.sector_start), + polar._theta_data_for_sector(polar.sector_end), + polar.r_lo, + polar.r_hi, + ) + + +def _polar_linear_frame_path(polar: "_PolarProjection", theta_values: Sequence[float]) -> str: + """Polygon-grid counterpart of ``_polar_frame_path``.""" + outer = polar.polygon_ring(polar.r_hi, theta_values) + if len(outer) < 2: + return _polar_frame_path(polar) + + def polyline(points: Sequence[tuple[float, float]], close: bool = False) -> str: + commands = [f"M {_num(points[0][0])} {_num(points[0][1])}"] + commands.extend(f"L {_num(x)} {_num(y)}" for x, y in points[1:]) + if close: + commands.append("Z") + return " ".join(commands) + + parts = [polyline(outer, polar.full_sector)] + if polar.inner_radius > 0.0: + inner = polar.polygon_ring(polar.r_lo, theta_values) + if inner: + parts.append(polyline(inner, polar.full_sector)) + else: + inner = [(polar.cx, polar.cy)] + if not polar.full_sector: + parts.append(polyline([outer[0], inner[0]])) + parts.append(polyline([outer[-1], inner[-1]])) + return " ".join(parts) + + +def _polar_grid( + grid: list[str], + polar: "_PolarProjection", + theta_ticks: list[float], + r_ticks: list[float], + theta_style: dict[str, Any], + r_style: dict[str, Any], + default_grid: str, + hide_theta: bool, + hide_r: bool, +) -> None: + """Concentric rings for the radial ticks, spokes for the angular ones. + + SVG has ``, so rings are exact here rather than flattened; the + raster exporter has no arc opcode and consumes `_PolarProjection.ring` + instead. Both read the same tick lists, so the two outputs agree on *which* + rings exist even though they differ in how the curve is expressed. + """ + theta_ticks = polar.filter_theta_values(theta_ticks) + r_ticks = [value for value in r_ticks if bool(polar.visible_mask(value))] + r_grid = escape(_css(r_style.get("grid_color"), default_grid)) + r_width = _num(float(r_style.get("grid_width", 1))) + r_attrs = _axis_grid_attrs(r_style) + if not hide_r: + for v in r_ticks: + radius = float(polar.norm_radius(v)) * polar.radius + if radius <= 0.0: + continue # the r=0 ring is a point at the centre + if polar.grid_shape == "linear": + points = polar.polygon_ring(v, theta_ticks) + if len(points) < 2: + continue + commands = " ".join(f"{_num(x)},{_num(y)}" for x, y in points) + tag = "polygon" if polar.full_sector else "polyline" + grid.append( + f'<{tag} data-xy-grid="ring" points="{commands}" fill="none" ' + f'stroke="{r_grid}" stroke-width="{r_width}"{r_attrs}/>' + ) + elif polar.full_sector: + grid.append( + f'' + ) + else: + a0, a1 = polar.sector_a0, polar.sector_a1 + x0 = polar.cx + radius * math.cos(a0) + y0 = polar.cy - radius * math.sin(a0) + x1 = polar.cx + radius * math.cos(a1) + y1 = polar.cy - radius * math.sin(a1) + large = 1 if abs(a1 - a0) > math.pi else 0 + sweep = 0 if a1 > a0 else 1 + grid.append( + f'' + ) + if hide_theta: + return + t_grid = escape(_css(theta_style.get("grid_color"), default_grid)) + t_width = _num(float(theta_style.get("grid_width", 1))) + t_attrs = _axis_grid_attrs(theta_style) + for v in theta_ticks: + angle = float(polar.angle(v)) + inner = polar.inner_radius + x0 = polar.cx + inner * math.cos(angle) + y0 = polar.cy - inner * math.sin(angle) + x1 = polar.cx + polar.radius * math.cos(angle) + y1 = polar.cy - polar.radius * math.sin(angle) + grid.append( + f'' + ) + + +class PolarTickLabel(NamedTuple): + """One placed polar tick label, in renderer-neutral terms. + + `anchor` is the SVG vocabulary ("start"/"middle"/"end"); the raster + exporter maps it to its own enum at the call site. `dy` is already folded + into `y`; it is carried separately only so a caller can re-derive the + unshifted anchor point if it ever needs one. + """ + + x: float + y: float + anchor: str + size: float + text: str + spin: float + + +def polar_tick_label_layout( + polar: "_PolarProjection", + theta_values: list[float], + r_values: list[float], + theta_step: float, + r_step: float, + theta_axis: dict[str, Any], + r_axis: dict[str, Any], + theta_size: float, + r_size: float, + hide_theta: bool, + hide_r: bool, +) -> "tuple[list[PolarTickLabel], list[PolarTickLabel]]": + """Where every polar tick label goes: (angular, radial). + + The placement — rim offset, quadrant anchor, baseline nudge, the 22.5-degree + radial spoke — lives here once so the two exporters cannot drift on it; each + keeps only its own sink loop. The cartesian label machinery is + edge-relative (a side in {top, bottom, left, right} plus a 1-D collision + axis) and neither concept survives a disc, so polar places its own rather + than bending that code. + + Mirrored by the polar label loop in js/src/50_chartview.ts, which places DOM + nodes with CSS translate percentages instead of anchors. + """ + angular: list[PolarTickLabel] = [] + radial: list[PolarTickLabel] = [] + theta_spin = float(theta_axis.get("tick_label_angle") or 0.0) + r_spin = float(r_axis.get("tick_label_angle") or 0.0) + if not hide_theta: + for v in polar.filter_theta_values(theta_values): + angle = float(polar.angle(v)) + # Just outside the rim, nudged along the outward normal so the + # glyph box clears the ring rather than straddling it. + x = polar.cx + (polar.radius + _POLAR_TICK_GAP) * math.cos(angle) + y = polar.cy - (polar.radius + _POLAR_TICK_GAP) * math.sin(angle) + cos_a, sin_a = math.cos(angle), math.sin(angle) + anchor = "middle" if abs(cos_a) < 0.3 else ("start" if cos_a > 0 else "end") + # The baseline sits at the glyph bottom, so a label above the circle + # needs no shift while one below needs close to a full ascent. + dy = 0.0 if abs(sin_a) < 0.3 else (-0.1 * theta_size if sin_a > 0 else 0.8 * theta_size) + # _tick_text, not _fmt_angle: authored tick_labels (the category + # names on a radar chart) must win over the angle. + angular.append( + PolarTickLabel( + x, y + dy, anchor, theta_size, _tick_text(theta_axis, v, theta_step), theta_spin + ) + ) + if not hide_r: + # Matplotlib's default rlabel_position: off the zero spoke, so the + # radial labels do not pile onto the theta=0 angular label. + angle = polar.zero + polar.dir * math.radians(_POLAR_RLABEL_DEG) + if not polar.angle_visible(angle): + angle = (polar.sector_a0 + polar.sector_a1) / 2.0 + for v in r_values: + if not bool(polar.visible_mask(v)): + continue + radius = float(polar.norm_radius(v)) * polar.radius + if radius <= 0.0: + continue + radial.append( + PolarTickLabel( + polar.cx + radius * math.cos(angle) + 3.0, + polar.cy - radius * math.sin(angle) - 3.0, + "start", + r_size, + _tick_text(r_axis, v, r_step), + r_spin, + ) + ) + return angular, radial + + +def _polar_tick_labels( + labels: list[str], + polar: "_PolarProjection", + theta_values: list[float], + r_values: list[float], + theta_step: float, + r_step: float, + theta_axis: dict[str, Any], + r_axis: dict[str, Any], + slots: dict[str, Any], + default_text: str, + hide_theta: bool, + hide_r: bool, +) -> None: + """Emit polar tick labels as SVG text, from the shared placement.""" + slot = slots.get("tick_label") or {} + attrs = slot_text_attrs(slot) + + def tick_color(axis: dict[str, Any]) -> str: + """Axis tick_label_color/tick_color first, chart slot second. + + Same precedence the cartesian labels use: the axis's own setting is the + narrower selector and wins. Reading only the slot made the `text=False` + and `show=False` shorthands — which work by setting tick_label_color to + a transparent value — silently do nothing on a polar chart. + """ + axis_style = axis.get("style") or {} + own = _css(axis_style.get("tick_label_color", axis_style.get("tick_color")), "") + return escape(own or slot_text_color(slot, default_text)) + + angular, radial = polar_tick_label_layout( + polar, + theta_values, + r_values, + theta_step, + r_step, + theta_axis, + r_axis, + slot_font_size(slot, _axis_tick_font_size(theta_axis)), + slot_font_size(slot, _axis_tick_font_size(r_axis)), + hide_theta, + hide_r, + ) + for kind, placed, axis in (("theta", angular, theta_axis), ("r", radial, r_axis)): + color = tick_color(axis) + for item in placed: + spin = ( + f' transform="rotate({_num(item.spin)} {_num(item.x)} {_num(item.y)})"' + if item.spin + else "" + ) + labels.append( + f'{escape(item.text)}' + ) + + def render_svg(spec: dict[str, Any], blob: bytes, *, id_prefix: str = "") -> str: spec = _decode_title_geometry(spec, blob) spec = _resolve_static_css_vars(spec) @@ -2488,12 +3814,42 @@ def render_svg(spec: dict[str, Any], blob: bytes, *, id_prefix: str = "") -> str x_scales, y_scales, sx, sy, extra_x_axes, extra_y_axes = _axis_scales(spec, plot) svg = _Svg(id_prefix) cols = spec["columns"] - # One plot-rect clipPath serves the marks group and every legend. + # Polar reinterprets the same two axes: x carries theta, y carries r. + polar = _PolarProjection(xa, ya, plot) if spec.get("coords") == "polar" else None + # One plot-rect clipPath serves the marks group and every legend. Polar + # clips to the disc instead, so nothing bleeds into the corners outside the + # outer ring. clip_id = svg.uid("clip") + # A polar legend lives in its own gutter OUTSIDE the plot rect, so the shared + # clip has to cover the union of the two boxes or the legend is clipped away + # entirely. Union, not replacement: the same id still bounds in-plot chrome. + clip_x0, clip_y0 = plot["x"], plot["y"] + clip_x1, clip_y1 = plot["x"] + plot["w"], plot["y"] + plot["h"] + if "legend_box_w" in plot: + clip_x0 = min(clip_x0, plot["legend_box_x"]) + clip_y0 = min(clip_y0, plot["legend_box_y"]) + clip_x1 = max(clip_x1, plot["legend_box_x"] + plot["legend_box_w"]) + clip_y1 = max(clip_y1, plot["legend_box_y"] + plot["legend_box_h"]) svg.defs.append( - f'' + f'' ) + # Marks clip to the disc under polar so nothing bleeds into the corners the + # outer ring does not cover. This is a SECOND id: `clip_id` also bounds + # every legend, and a legend sitting outside the circle would vanish. + marks_clip_id = clip_id + if polar is not None: + marks_clip_id = svg.uid("clip") + if polar.full_sector and polar.inner_fraction <= 0.0: + svg.defs.append( + f'' + ) + else: + svg.defs.append( + f'' + ) def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list[float], float]: return axis_ticks(axis, length_px, axis is xa) @@ -2501,6 +3857,9 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list # -- grid + tick labels + baselines ------------------------------------ xt, xlab, xstep = ticks_for(xa, plot["w"]) yt, ylab, ystep = ticks_for(ya, plot["h"]) + if polar is not None: + # Rings keep full density; only the labels ride the spoke. + ylab = _polar_thin_radial_labels(ylab, _polar_radial_tick_length(polar)) xmt, ymt = minor_axis_ticks(xa), minor_axis_ticks(ya) dom_style = (spec.get("dom") or {}).get("style") or {} xstyle, ystyle = xa.get("style") or {}, ya.get("style") or {} @@ -2515,7 +3874,11 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list # label text and keeps grid, baselines and the axis title (mpl shared axes). hide_x = xa.get("tick_label_strategy") == "none" hide_y = ya.get("tick_label_strategy") == "none" + if polar is not None: + _polar_grid(grid, polar, xt, yt, xstyle, ystyle, default_grid, hide_x, hide_y) for v in xmt: + if polar is not None: + break if hide_x: break px = float(sx(v)) @@ -2527,6 +3890,8 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list f"{_axis_grid_attrs(xmstyle)}/>" ) for v in ymt: + if polar is not None: + break if hide_y: break py = float(sy(v)) @@ -2538,6 +3903,8 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list f"{_axis_grid_attrs(ymstyle)}/>" ) for v in xt: + if polar is not None: + break if hide_x: break px = float(sx(v)) @@ -2549,6 +3916,8 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list f"{_axis_grid_attrs(xstyle)}/>" ) for v in yt: + if polar is not None: + break if hide_y: break py = float(sy(v)) @@ -2641,8 +4010,26 @@ def append_tick_labels( f"{_text_block_content(item['text'], x, block.line_step)}" ) - append_tick_labels(xa, xlab, xstep, sx, is_x=True) - append_tick_labels(ya, ylab, ystep, sy, is_x=False) + if polar is not None: + # "off" hides only the label text (cartesian keeps grid and titles); + # "none" — folded into hide_x/hide_y — silences the whole axis chrome. + _polar_tick_labels( + labels, + polar, + xlab, + ylab, + xstep, + ystep, + xa, + ya, + slots, + default_text, + hide_x or xa.get("tick_label_strategy") == "off", + hide_y or ya.get("tick_label_strategy") == "off", + ) + else: + append_tick_labels(xa, xlab, xstep, sx, is_x=True) + append_tick_labels(ya, ylab, ystep, sy, is_x=False) extra_x_ticks: dict[str, tuple[list[float], list[float], float]] = {} for axis_id, axis, axis_scale in extra_x_axes: ticks, tick_labels, step = axis_ticks(axis, plot["w"], True) @@ -2690,7 +4077,7 @@ def line_attrs(style: dict[str, Any], color: str) -> str: yv = _column(blob, cols[t["y"]]) if style.get("step"): xv, yv = _step_arrays(xv, yv, style["step"]) - d = _curve_path(xv, yv, trace_sx, trace_sy, style.get("curve") == "smooth") + d = _curve_path(xv, yv, trace_sx, trace_sy, style.get("curve") == "smooth", polar) marks.append(f'') elif kind in ("area", "error_band"): @@ -2698,8 +4085,13 @@ def line_attrs(style: dict[str, Any], color: str) -> str: yv = _column(blob, cols[t["y"]]) bv = _column(blob, cols[t["base"]]) smooth = style.get("curve") == "smooth" - top_path = _curve_path(xv, yv, trace_sx, trace_sy, smooth) - base_path = _curve_path(xv[::-1], bv[::-1], trace_sx, trace_sy, smooth) + if polar is not None: + radial_min, radial_max = sorted((polar.r_lo, polar.r_hi)) + yv = np.clip(yv, radial_min, radial_max) + bv = np.clip(bv, radial_min, radial_max) + # Still needed for the (non-perimeter) outline below; the fill + # builds its own paired paths so each visible run can close alone. + top_path = _curve_path(xv, yv, trace_sx, trace_sy, smooth, polar) fill_spec = style.get("fill") fill = ( svg.gradient(fill_spec, color, plot) @@ -2707,10 +4099,17 @@ def line_attrs(style: dict[str, Any], color: str) -> str: else escape(color) ) op = _fill_opacity(style, 0.35) - joined = f"{top_path} L {base_path[2:]} Z" # strip the M of the return path - marks.append(f'') + # A polar area can be culled away entirely — every vertex outside + # the authored sector, or a log radial axis annihilating each row — + # or split into several visible runs. The flat join then produced + # " L Z", malformed path data that also reached the PDF + # converter's _parse_path, or stitched the first top run onto the + # base with a stray L. Close each visible run on its own. + joined = _area_fill_path(xv, yv, bv, trace_sx, trace_sy, smooth, polar) + if joined: + marks.append(f'') lw = float(style.get("line_width", 1.2)) - if lw > 0: + if lw > 0 and (joined or top_path): lop = _stroke_opacity(style, 0.35) * float(style.get("line_opacity", 1.0)) line_color = style.get("line_color") or color outline_path = joined if style.get("stroke_perimeter") else top_path @@ -2727,41 +4126,57 @@ def line_attrs(style: dict[str, Any], color: str) -> str: ) elif kind == "scatter": - marks.extend(_scatter_marks(t, blob, cols, trace_sx, trace_sy, style, color)) + marks.extend(_scatter_marks(t, blob, cols, trace_sx, trace_sy, style, color, polar)) elif kind == "hexbin": marks.append(_hexbin_marks(t, blob, cols, trace_sx, trace_sy, style, color)) elif kind in {"errorbar", "stem", "box_whisker", "box_median", "contour", "segments"}: - marks.append(_segment_marks(t, blob, cols, trace_sx, trace_sy, style, color)) + marks.append(_segment_marks(t, blob, cols, trace_sx, trace_sy, style, color, polar)) elif kind in ("bar", "column") and t.get("bar"): - marks.append(_bar_marks(t, blob, cols, trace_sx, trace_sy, style, color, svg, plot)) + marks.append( + _bar_marks(t, blob, cols, trace_sx, trace_sy, style, color, svg, plot, polar) + ) elif kind == "heatmap" and t.get("heatmap"): - marks.append(_heatmap_image(t["heatmap"], blob, cols, trace_sx, trace_sy, style)) + marks.append(_heatmap_image(t["heatmap"], blob, cols, trace_sx, trace_sy, style, polar)) elif kind == "triangle_mesh": marks.append(_triangle_mesh_marks(t, blob, cols, trace_sx, trace_sy, style, color)) elif all(k in t for k in ("x0", "x1", "y0", "y1")): # histogram / rect family - marks.append(_rect_marks(t, blob, cols, trace_sx, trace_sy, style, color, svg, plot)) + marks.append( + _rect_marks(t, blob, cols, trace_sx, trace_sy, style, color, svg, plot, polar) + ) # -- chrome text ---------------------------------------------------------- chrome: list[str] = [] legacy_title = spec.get("title") if not spec.get("title_options") else None + title_wrap_width = plot.get("title_wrap_width") if legacy_title: title_slot = slots.get("title") or {} + legacy_size = slot_font_size(title_slot, 14.0) + legacy_block = _textblock.measure(legacy_title, legacy_size, max_width=title_wrap_width) + # Wrapped lines run downward from the baseline, so lift the block by its + # trailing lines: the LAST line keeps the historical single-line baseline + # and the extra lines fill the room `_title_room` reserved above it. A + # one-line title has no trailing lines and is byte-identical to before. + legacy_trailing = (legacy_block.line_count - 1) * legacy_block.line_step + legacy_y = plot["y"] - plot["top_axis_room"] - (10 if compact else 12) - legacy_trailing + legacy_x = width / 2 + legacy_text = "\n".join(legacy_block.lines) + legacy_content = _text_block_content(legacy_text, legacy_x, legacy_block.line_step) chrome.append( - f'' - f"{escape(str(legacy_title))}" + f"{legacy_content}" ) for title_entry in [] if legacy_title else _title_entries(spec): - title_style, title_size, title_block = _title_metrics(spec, title_entry) + title_style, title_size, title_block = _title_metrics(spec, title_entry, title_wrap_width) # Matplotlib's `axes.titleweight`/`axes.labelweight` both default to # "normal", so chrome text stays at 400 unless a style or rcParam asks # for more. Keep this in step with the `title`/`axis_title` slot rules @@ -2782,13 +4197,18 @@ def line_attrs(style: dict[str, Any], color: str) -> str: "right": plot["x"] + plot["w"], }.get(loc, plot["x"] + plot["w"] / 2.0) anchor = {"left": "start", "center": "middle", "right": "end"}.get(loc, "middle") + # `title_block.lines` is the wrapped set — drawing `entry["text"]` here + # would put the whole title on one line inside a band reserved for two. + title_content = _text_block_content( + "\n".join(title_block.lines), title_x, title_block.line_step + ) chrome.append( f'' - f"{_text_block_content(title_entry['text'], title_x, title_block.line_step)}" + f"{title_content}" ) def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: @@ -2875,7 +4295,7 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: ) annotation_marks, unclipped_annotation_marks, annotation_labels = _annotation_svg( - spec.get("annotations") or [], sx, sy, plot, width, height + spec.get("annotations") or [], sx, sy, plot, width, height, polar ) marks.extend(annotation_marks) labels.extend(annotation_labels) @@ -2886,6 +4306,29 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: explicit_frame_sides = frame_sides is not None if frame_sides is None: frame_sides = [xa.get("side", "bottom"), ya.get("side", "left")] + if polar is not None: + # One annular-sector outline replaces the four straight spines; "side" + # has no polar meaning, so frame_sides is deliberately not consulted. + frame_sides = [] + if not hide_x: + frame_paint = escape(_css(xstyle.get("axis_color"), default_axis)) + frame_width = _num(float(xstyle.get("axis_width", 1))) + if polar.full_sector and polar.inner_fraction <= 0.0 and polar.grid_shape != "linear": + baselines += ( + f'' + ) + else: + frame_path = ( + _polar_linear_frame_path(polar, xt) + if polar.grid_shape == "linear" + else _polar_frame_path(polar) + ) + baselines += ( + f'' + ) if not hide_y or explicit_frame_sides: for side, x in (("left", plot["x"]), ("right", plot["x"] + plot["w"])): if side in frame_sides: @@ -2936,7 +4379,7 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: return length / 2, length / 2, float(style.get("tick_width", 1)) return 0.0, length, float(style.get("tick_width", 1)) - if not hide_x: + if not hide_x and polar is None: inward, outward, tick_width = tick_span(xmstyle) side = xa.get("side", "bottom") edge = plot["y"] if side == "top" else plot["y"] + plot["h"] @@ -2969,7 +4412,7 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: f'stroke="{escape(_css(xstyle.get("tick_color"), default_axis))}" ' f'stroke-width="{_num(tick_width)}"/>' ) - if not hide_y: + if not hide_y and polar is None: inward, outward, tick_width = tick_span(ymstyle) side = ya.get("side", "left") edge = plot["x"] + plot["w"] if side == "right" else plot["x"] @@ -3079,7 +4522,7 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: "", *grid, "", - f'', + f'', *marks, "", *unclipped_annotation_marks, @@ -3101,6 +4544,7 @@ def annotation_label_placement( plot: dict[str, float], width: float, height: float, + polar: "Optional[_PolarProjection]" = None, ) -> tuple[float, float, Optional[str], Optional[str]]: """Where an annotation's `text=` hangs, as `(x, y, anchor, vertical_align)`. @@ -3131,10 +4575,21 @@ def annotation_label_placement( vertical_align = vertical_align or "middle" return x, y, anchor or "end", vertical_align if kind == "arrow": - x = (float(sx(float(ann["x0"]))) + float(sx(float(ann["x1"])))) / 2 - y = (float(sy(float(ann["y0"]))) + float(sy(float(ann["y1"])))) / 2 + if polar is not None: + x0, y0 = polar(float(ann["x0"]), float(ann["y0"])) + x1, y1 = polar(float(ann["x1"]), float(ann["y1"])) + x = (float(x0) + float(x1)) / 2 + y = (float(y0) + float(y1)) / 2 + else: + x = (float(sx(float(ann["x0"]))) + float(sx(float(ann["x1"])))) / 2 + y = (float(sy(float(ann["y0"]))) + float(sy(float(ann["y1"])))) / 2 return x, y, anchor or "middle", vertical_align or "middle" if kind == "marker": + if polar is not None: + # (theta, r) projects jointly; the separable pair would read the + # disc centre (r = 0, any angle) as the bottom-left corner. + ax, ay = polar(float(ann["x"]), float(ann["y"])) + return float(ax), float(ay), anchor, vertical_align return float(sx(float(ann["x"]))), float(sy(float(ann["y"]))), anchor, vertical_align x, y = float(ann.get("x", 0.0)), float(ann.get("y", 0.0)) space = style.get("coordinate_space") @@ -3146,6 +4601,12 @@ def annotation_label_placement( return px0 + x * plot["w"], float(sy(y)), anchor, vertical_align if space == "xaxis_transform": return float(sx(x)), py0 + (1.0 - y) * plot["h"], anchor, vertical_align + if polar is not None: + # Data-space (theta, r) projects jointly; the separable pair would read + # the disc centre (r = 0, at any angle) as the bottom-left corner. The + # fraction-space branches above are already renderer-neutral. + ax, ay = polar(x, y) + return float(ax), float(ay), anchor, vertical_align return float(sx(x)), float(sy(y)), anchor, vertical_align @@ -3185,6 +4646,7 @@ def _annotation_connector_unclipped( sx: Callable[[float], float], sy: Callable[[float], float], plot: dict[str, float], + polar: "Optional[_PolarProjection]" = None, ) -> bool: """Whether an arrow may leave the axes because its target is in bounds. @@ -3200,7 +4662,14 @@ def _annotation_connector_unclipped( else: return False try: - px, py = float(sx(float(target[0]))), float(sy(float(target[1]))) + x, y = float(target[0]), float(target[1]) + if polar is not None: + if not bool(polar.position_mask(x, y)): + return False + px, py = polar(x, y) + px, py = float(px), float(py) + else: + px, py = float(sx(x)), float(sy(y)) except (TypeError, ValueError): return False return ( @@ -3218,11 +4687,30 @@ def _annotation_svg( plot: dict[str, float], width: float, height: float, + polar: "Optional[_PolarProjection]" = None, ) -> tuple[list[str], list[str], list[str]]: marks: list[str] = [] unclipped_marks: list[str] = [] labels: list[str] = [] px0, py0 = plot["x"], plot["y"] + + def point(x: float, y: float) -> tuple[float, float]: + """A point-anchored annotation's position. + + Under polar the pair is (theta, r) and must project jointly — the + separable sx/sy would read them as cartesian, putting `(0, 0)` (the + disc centre, at any angle) in the bottom-left corner instead. + + Only point-anchored kinds route through here. `rule` and `band` are + genuinely different geometry on a disc — a theta rule is a spoke, an r + rule is a ring, a band is an annulus or a sector — and stay deferred + (polar-axes.md §9) rather than being drawn as straight cartesian bars. + """ + if polar is not None: + px, py = polar(x, y) + return float(px), float(py) + return float(sx(x)), float(sy(y)) + for ann in annotations: style = ann.get("style") or {} color = escape(_css(style.get("color"), "#667085")) @@ -3257,13 +4745,15 @@ def _annotation_svg( ) elif kind in ("arrow", "callout"): connector_marks = ( - unclipped_marks if _annotation_connector_unclipped(ann, sx, sy, plot) else marks + unclipped_marks + if _annotation_connector_unclipped(ann, sx, sy, plot, polar) + else marks ) if kind == "arrow": - x0, y0 = float(sx(float(ann["x0"]))), float(sy(float(ann["y0"]))) - x1, y1 = float(sx(float(ann["x1"]))), float(sy(float(ann["y1"]))) + x0, y0 = point(float(ann["x0"]), float(ann["y0"])) + x1, y1 = point(float(ann["x1"]), float(ann["y1"])) else: # pointer from the offset label back to the data point - x1, y1 = float(sx(float(ann["x"]))), float(sy(float(ann["y"]))) + x1, y1 = point(float(ann["x"]), float(ann["y"])) x0, y0 = x1 + float(ann.get("dx", 0.0)), y1 + float(ann.get("dy", 0.0)) if all(np.isfinite(v) for v in (x0, y0, x1, y1)): shapes = _arrow_shapes(x0, y0, x1, y1, style) @@ -3295,7 +4785,7 @@ def _annotation_svg( f'stroke-width="{stroke_width}" stroke-opacity="{_num(opacity)}"/>' ) elif kind == "marker": - mx, my = float(sx(float(ann["x"]))), float(sy(float(ann["y"]))) + mx, my = point(float(ann["x"]), float(ann["y"])) if all(np.isfinite(v) for v in (mx, my)): radius = max(0.5, float(ann.get("size", 8.0)) / 2.0) builder = _SYMBOL_BUILDERS.get(str(ann.get("symbol", "circle"))) @@ -3316,7 +4806,7 @@ def _annotation_svg( marks.append(f'{shape} fill="{fill}" fill-opacity="{_num(opacity)}"{stroke_attr}/>') if ann.get("text"): tx, ty, label_anchor, vertical_align = annotation_label_placement( - ann, style, sx, sy, plot, width, height + ann, style, sx, sy, plot, width, height, polar ) if not (np.isfinite(tx) and np.isfinite(ty)): continue @@ -3560,7 +5050,14 @@ def _estimated_text_width(lines: list[str], font_size: float) -> float: def _segment_marks( - t: dict[str, Any], blob: bytes, cols: list, sx: _Scale, sy: _Scale, style: dict, color: str + t: dict[str, Any], + blob: bytes, + cols: list, + sx: _Scale, + sy: _Scale, + style: dict, + color: str, + polar: "Optional[_PolarProjection]" = None, ) -> str: x0 = _column(blob, cols[t["x0"]]) x1 = _column(blob, cols[t["x1"]]) @@ -3581,14 +5078,48 @@ def read(index: int) -> np.ndarray: # effective_rgba, so repeating it inside stroke= would apply it twice. constant_paint = paint.get("mode") in {None, "constant"} and _paint_rgba8(plain_css)[3] == 255 css_paint = escape(plain_css) + if polar is None: + px0, py0 = sx(x0), sy(y0) + px1, py1 = sx(x1), sy(y1) + keep = np.ones(n, dtype=bool) + else: + # Clip each independent segment jointly in radial *scale coordinates*. + # Clamping endpoints independently bends a diagonal error bar along the + # ring; interpolating theta at the two intersections preserves its + # authored chord and mirrors SEGMENT_VS. + c0 = np.asarray(polar.r_scale.coord(y0), dtype=np.float64) + c1 = np.asarray(polar.r_scale.coord(y1), dtype=np.float64) + lo = min(polar.r_lo_coord, polar.r_hi_coord) + hi = max(polar.r_lo_coord, polar.r_hi_coord) + finite = np.isfinite(x0) & np.isfinite(x1) & np.isfinite(c0) & np.isfinite(c1) + keep = finite & (np.maximum(c0, c1) >= lo) & (np.minimum(c0, c1) <= hi) + dr = c1 - c0 + ta = np.zeros(n, dtype=np.float64) + tb = np.ones(n, dtype=np.float64) + moving = np.abs(dr) > 1e-30 + ta[moving] = (lo - c0[moving]) / dr[moving] + tb[moving] = (hi - c0[moving]) / dr[moving] + t0 = np.maximum(0.0, np.minimum(ta, tb)) + t1 = np.minimum(1.0, np.maximum(ta, tb)) + clipped_x0 = x0 + (x1 - x0) * t0 + clipped_x1 = x0 + (x1 - x0) * t1 + clipped_c0 = np.clip(c0 + dr * t0, lo, hi) + clipped_c1 = np.clip(c0 + dr * t1, lo, hi) + clipped_y0 = polar.r_scale.value(clipped_c0) + clipped_y1 = polar.r_scale.value(clipped_c1) + keep &= polar.theta_visible_mask(clipped_x0) + keep &= polar.theta_visible_mask(clipped_x1) + px0, py0 = polar(clipped_x0, clipped_y0) + px1, py1 = polar(clipped_x1, clipped_y1) return "".join( - f'" for i in range(len(x0)) + if keep[i] ) @@ -3618,11 +5149,23 @@ def _authored_marker_path_d( def _scatter_marks( - t: dict, blob: bytes, cols: list, sx: _Scale, sy: _Scale, style: dict, fallback: str + t: dict, + blob: bytes, + cols: list, + sx: _Scale, + sy: _Scale, + style: dict, + fallback: str, + polar: "Optional[_PolarProjection]" = None, ) -> list[str]: xv = _column(blob, cols[t["x"]]) yv = _column(blob, cols[t["y"]]) - px, py = sx(xv), sy(yv) + # Only the centres move under polar; the marker glyphs are pixel-space + # around each centre and stay round. Out-of-range radii are culled like + # the client shader culls them — below r_lo a point mirrors through the + # centre INSIDE the disc, where no clip can save it. + px, py = polar(xv, yv) if polar is not None else (sx(xv), sy(yv)) + visible = polar.position_mask(xv, yv) if polar is not None else None n = len(xv) def read(index: int) -> np.ndarray: @@ -3694,6 +5237,8 @@ def read(index: int) -> np.ndarray: blocks = [""] out: list[str] = [] for i in range(n): + if visible is not None and not visible[i]: + continue fill = face_rgba[i] fill_value = ( escape(face_css) @@ -4060,6 +5605,7 @@ def _bar_marks( color: str, svg: _Svg, plot: dict, + polar: "Optional[_PolarProjection]" = None, ) -> str: b = t["bar"] pos = _column(blob, cols[b["pos"]]) @@ -4077,6 +5623,22 @@ def read(index: int) -> np.ndarray: fills, extras, radii = _rect_svg_styles(t, len(pos), color, read, style, svg, plot) out = [] + if polar is not None: + # Annular sectors. SVG has real arcs, so these are exact `A` commands + # rather than the flattened polygons the raster path needs. + for i in range(len(pos)): + d = _polar_wedge_path( + polar, + float(pos[i]) - half, + float(pos[i]) + half, + float(min(v0[i], v1[i])), + float(max(v0[i], v1[i])), + float(np.max(radii[i])) if radii is not None and len(radii) else 0.0, + float(style.get("wedge_gap", 0.0) or 0.0), + ) + if d: + out.append(f'') + return "".join(out) for i in range(len(pos)): if horizontal: x0, x1 = float(sx(min(v0[i], v1[i]))), float(sx(max(v0[i], v1[i]))) @@ -4109,6 +5671,7 @@ def _rect_marks( color: str, svg: _Svg, plot: dict, + polar: "Optional[_PolarProjection]" = None, ) -> str: x0v = _column(blob, cols[t["x0"]]) x1v = _column(blob, cols[t["x1"]]) @@ -4120,6 +5683,24 @@ def read(index: int) -> np.ndarray: fills, extras, radii = _rect_svg_styles(t, len(x0v), color, read, style, svg, plot) out = [] + if polar is not None: + # Four edge columns are an annular sector: (x0, x1) is the angular span + # and (y0, y1) the radial one. This is the path unequal-width slices (a + # pie or donut) take, since the compact bar path ships one scalar width. + out = [] + for i in range(len(x0v)): + d = _polar_wedge_path( + polar, + float(x0v[i]), + float(x1v[i]), + float(min(y0v[i], y1v[i])), + float(max(y0v[i], y1v[i])), + float(np.max(radii[i])) if radii is not None and len(radii) else 0.0, + float(style.get("wedge_gap", 0.0) or 0.0), + ) + if d: + out.append(f'') + return "".join(out) for i in range(len(x0v)): xa_, xb = float(sx(x0v[i])), float(sx(x1v[i])) ya_, yb = float(sy(y0v[i])), float(sy(y1v[i])) @@ -4208,6 +5789,194 @@ def _heatmap_rgba_grid( return np.dstack([rgb, alpha]) +_POLAR_HEATMAP_MAX_DIMENSION = 4096 +# Keep inverse-projection scratch well below the returned RGBA image. At the +# maximum output width this is 64 rows, so the one dense float tile is 2 MiB +# instead of the old implementation's many 128 MiB full-frame arrays. +_POLAR_HEATMAP_TILE_PIXELS = 256 * 1024 + + +def _heatmap_sample_column( + meta: dict[str, Any], + indices: np.ndarray, + blob: bytes, + borrowed: tuple[np.ndarray, ...], +) -> np.ndarray: + """Decode only selected rows from one heatmap source column. + + Polar inverse-raster output is screen-bounded. Expanding a source grid + before sampling defeats that contract (and the raster payload's borrowed + canonical-f64 path), so this helper indexes the wire/canonical storage + first and widens only the selected values. + """ + dtype_name = str(meta.get("dtype", "f32")) + dtype = {"u8": np.uint8, "f32": np.dtype(" np.ndarray: + """Color selected flat heatmap cells without expanding the source grid.""" + count = len(indices) + if "rgba_bufs" in hm: + rgba = np.empty((count, 4), dtype=np.uint8) + for channel, column_index in enumerate(hm["rgba_bufs"]): + values = _heatmap_sample_column(cols[column_index], indices, blob, borrowed) + rgba[:, channel] = np.clip(values * 255.0, 0.0, 255.0).astype(np.uint8) + rgba[:, 3] = (rgba[:, 3].astype(np.float64) * _fill_opacity(style)).astype(np.uint8) + return rgba + + values = _heatmap_sample_column(cols[hm["buf"]], indices, blob, borrowed) + finite = np.isfinite(values) + if hm.get("enc") == "canonical-f64": + d0, d1 = (float(value) for value in hm["domain"]) + # Browser payload normalization and the native Cartesian heatmap opcode + # both round each normalized canonical value to f32 before LUT lookup. + # Preserve that exact seam while touching only sampled source cells. + t = np.zeros(count, dtype=np.float64) + normalized = np.clip((values[finite] - d0) / ((d1 - d0) or 1.0), 0.0, 1.0) + t[finite] = normalized.astype(np.float32).astype(np.float64) + else: + t = np.clip(np.where(finite, values, 0.0), 0.0, 1.0) + rgb = _lut(hm.get("colormap", "viridis"), t) + alpha = np.full(count, int(255 * _fill_opacity(style, 0.95)), dtype=np.uint8) + alpha[~finite] = 0 + return np.column_stack((rgb, alpha)) + + +def polar_heatmap_rgba( + hm: dict[str, Any], + blob: bytes, + cols: list[dict[str, Any]], + style: dict[str, Any], + polar: _PolarProjection, + borrowed: tuple[np.ndarray, ...] = (), + *, + output_scale: float = 1.0, +) -> np.ndarray: + """Inverse-raster a regular heatmap into the visible annular sector. + + The returned image is top-first RGBA and covers ``polar.plot``. Each output + pixel is inverted through the joint polar transform, then nearest-samples + the source cell grid (whose row 0 is the radial-range bottom). This is the + CPU twin of ``HEATMAP_FS`` and is shared by SVG and native raster export. + + Work is bounded by output pixels, not source cells: source values are + gathered only after inverse mapping, and projection scratch is tiled. + ``output_scale`` lets native raster export sample once per device pixel; + SVG uses the default one sample per logical pixel. + """ + source_w, source_h = int(hm["w"]), int(hm["h"]) + if source_w <= 0 or source_h <= 0: + raise ValueError("polar heatmap dimensions must be positive") + plot = polar.plot + output_scale = float(output_scale) + if not math.isfinite(output_scale) or output_scale <= 0.0: + raise ValueError("polar heatmap output_scale must be positive and finite") + out_w = max( + 1, + min( + _POLAR_HEATMAP_MAX_DIMENSION, + int(math.ceil(float(plot["w"]) * output_scale)), + ), + ) + out_h = max( + 1, + min( + _POLAR_HEATMAP_MAX_DIMENSION, + int(math.ceil(float(plot["h"]) * output_scale)), + ), + ) + xs = float(plot["x"]) + (np.arange(out_w, dtype=np.float64) + 0.5) * (float(plot["w"]) / out_w) + dx = xs - polar.cx + xr = hm["x_range"] + yr = hm["y_range"] + out = np.zeros((out_h, out_w, 4), dtype=np.uint8) + tile_rows = max(1, min(out_h, _POLAR_HEATMAP_TILE_PIXELS // out_w)) + near = float(polar.theta_value(float(xr[0]))) + inner = polar.inner_fraction + radius = max(polar.radius, 1e-30) + x_span = (float(xr[1]) - float(xr[0])) or 1.0 + y_span = (float(yr[1]) - float(yr[0])) or 1.0 + + for row_start in range(0, out_h, tile_rows): + row_stop = min(out_h, row_start + tile_rows) + rows = np.arange(row_start, row_stop, dtype=np.float64) + ys = float(plot["y"]) + (rows + 0.5) * (float(plot["h"]) / out_h) + dy = polar.cy - ys + normalized = np.hypot(dy[:, None], dx[None, :]) / radius + candidate_rows, candidate_cols = np.nonzero( + (normalized >= inner - 1e-9) & (normalized <= 1.0 + 1e-9) + ) + if not len(candidate_rows): + continue + + candidate_norm = normalized[candidate_rows, candidate_cols] + angles = np.arctan2(dy[candidate_rows], dx[candidate_cols]) + theta = np.asarray(polar.theta_from_angle(angles, near=near), dtype=np.float64) + radial = np.asarray(polar.radius_value(candidate_norm), dtype=np.float64) + fx = (theta - float(xr[0])) / x_span + fy = (radial - float(yr[0])) / y_span + raw_theta = np.asarray(polar.theta_value(theta), dtype=np.float64) + visible = ( + np.isfinite(fx) + & np.isfinite(fy) + & polar._angular_value_visible_mask(raw_theta) + & (fx >= 0.0) + & (fx <= 1.0) + & (fy >= 0.0) + & (fy <= 1.0) + ) + if not bool(visible.any()): + continue + target_rows = candidate_rows[visible] + target_cols = candidate_cols[visible] + source_x = np.clip( + np.floor(fx[visible] * source_w).astype(np.int64), + 0, + source_w - 1, + ) + source_y = np.clip( + np.floor(fy[visible] * source_h).astype(np.int64), + 0, + source_h - 1, + ) + source_indices = source_y * source_w + source_x + out[row_start + target_rows, target_cols] = _heatmap_rgba_samples( + hm, + source_indices, + blob, + cols, + style, + borrowed, + ) + return out + + def _grid_image( w: int, h: int, rgba: bytes, x_range: list, y_range: list, sx: _Scale, sy: _Scale ) -> str: @@ -4284,7 +6053,26 @@ def _density_image( return _grid_image(w, h, rgba, d["x_range"], d["y_range"], sx, sy) -def _heatmap_image(hm: dict, blob: bytes, cols: list, sx: _Scale, sy: _Scale, style: dict) -> str: +def _heatmap_image( + hm: dict, + blob: bytes, + cols: list, + sx: _Scale, + sy: _Scale, + style: dict, + polar: "Optional[_PolarProjection]" = None, +) -> str: + if polar is not None: + grid_rgba = polar_heatmap_rgba(hm, blob, cols, style, polar) + out_h, out_w = grid_rgba.shape[:2] + b64 = base64.b64encode(_png_rgba(out_w, out_h, grid_rgba.tobytes())).decode("ascii") + plot = polar.plot + return ( + f'' + ) grid_rgba = _heatmap_rgba_grid(hm, blob, cols, style) # Heatmap cells are uniform in *data* space; on a nonlinear axis the image # must be resampled so internal cell edges land at their transformed @@ -4419,7 +6207,19 @@ def _legend_layout(named: list[dict], plot: dict, options: dict) -> dict[str, An legend is kept inside the plot and its labels are visibly ellipsized. A Columns follow Matplotlib's handle/text/column spacing and size to their own labels rather than inheriting the width of the longest label. + + A polar chart hands over a `legend_box_*` gutter beside the disc + (`_recut_polar_plot`); everything below then bounds and places the legend in + that box instead of over the marks, and `loc` chooses where within it. """ + if "legend_box_w" in plot: + plot = { + **plot, + "x": plot["legend_box_x"], + "y": plot["legend_box_y"], + "w": plot["legend_box_w"], + "h": plot["legend_box_h"], + } style_opts = options.get("style") or {} font_size = _legend_font_size(style_opts) char_width = font_size * (_LEGEND_CHAR_WIDTH / 11.0) diff --git a/python/xy/_textblock.py b/python/xy/_textblock.py index 33eff96b..22220361 100644 --- a/python/xy/_textblock.py +++ b/python/xy/_textblock.py @@ -9,7 +9,7 @@ from __future__ import annotations import math -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterator, Sequence from contextlib import contextmanager from contextvars import ContextVar from dataclasses import dataclass @@ -19,7 +19,7 @@ from . import _fontmetrics LINE_HEIGHT = 1.2 -_MeasurementKey = tuple[str, float, float] +_MeasurementKey = tuple[str, float, float, float | None] _MEASUREMENTS: ContextVar[dict[_MeasurementKey, "TextBlock"] | None] = ContextVar( "xy_textblock_measurements", default=None, @@ -73,16 +73,63 @@ def wrapped(*args: Any, **kwargs: Any) -> _Return: return cast(Callable[..., _Return], wrapped) -def measure(text: object, font_size: float, line_height: float = LINE_HEIGHT) -> TextBlock: - """Measure a newline-delimited block in the core DejaVu metrics.""" +def wrap_lines(lines: Sequence[str], font_size: float, max_width: float) -> tuple[str, ...]: + """Greedy word wrap of already newline-split lines, at `max_width` px. + + Mirrors `xyWrapLines` in js/src/50_chartview.ts, and matches how CSS + `white-space: pre-line` treats the same string: authored newlines are hard + breaks (the caller has already split on them), runs of other whitespace + collapse to one space, and a break is only ever taken at a space. A single + word wider than `max_width` keeps its own line and overflows, because that + is what a browser does without an explicit `overflow-wrap`. + """ + size = max(0.0, float(font_size)) + limit = float(max_width) + wrapped: list[str] = [] + for line in lines: + words = str(line).split() + if not words: + wrapped.append("") + continue + current = words[0] + for word in words[1:]: + candidate = f"{current} {word}" + if _fontmetrics.advance(candidate, size) <= limit: + current = candidate + else: + wrapped.append(current) + current = word + wrapped.append(current) + return tuple(wrapped) + + +def measure( + text: object, + font_size: float, + line_height: float = LINE_HEIGHT, + max_width: float | None = None, +) -> TextBlock: + """Measure a newline-delimited block in the core DejaVu metrics. + + A finite positive `max_width` word-wraps the block first, so the measured + height is the height the wrapped text actually occupies. Callers that wrap + must draw `block.lines`, not the original string, or the reservation and the + drawing disagree — which is exactly how a wrapped chart title came to be + clipped in the browser while layout reserved one line for it. + """ size = max(0.0, float(font_size)) normalized = str(text).replace("\r\n", "\n").replace("\r", "\n") resolved_line_height = float(line_height) - key = (normalized, size, resolved_line_height) + limit: float | None = None if max_width is None else float(max_width) + if limit is not None and not (math.isfinite(limit) and limit > 0.0): + limit = None + key = (normalized, size, resolved_line_height, limit) cache = _MEASUREMENTS.get() if cache is not None and key in cache: return cache[key] lines = tuple(normalized.split("\n")) or ("",) + if limit is not None: + lines = wrap_lines(lines, size, limit) line_step = size * resolved_line_height ascent = size * _fontmetrics.ASCENT / _fontmetrics.BASE_PX descent = size * _fontmetrics.DESCENT / _fontmetrics.BASE_PX diff --git a/python/xy/_validate.py b/python/xy/_validate.py index bc47df56..8163d842 100644 --- a/python/xy/_validate.py +++ b/python/xy/_validate.py @@ -35,6 +35,13 @@ {"start", "center", "end", "inside_start", "inside_center", "inside_end"} ) _CURVES = frozenset({"linear", "smooth"}) +_COORDS = frozenset({"cartesian", "polar"}) +_THETA_UNITS = frozenset({"radians", "degrees"}) +_THETA_DIRECTIONS = frozenset({"counterclockwise", "clockwise"}) +_POLAR_GRID_SHAPES = frozenset({"circular", "linear"}) +# Compass letters for theta=0; resolved to radians by THETA_ZERO in _svg.py and +# by the THETA_ZERO table in js/src/50_chartview.ts. Kept as letters on the wire so one table serves all renderers. +_THETA_ZEROS = frozenset({"E", "N", "W", "S"}) _FILL_SPACES = frozenset({"mark", "plot"}) # CSS `` keywords -> wire direction. In mark space the gradient # line runs along each mark's value axis ("bottom" = the base, "top" = the @@ -386,6 +393,68 @@ def curve(value: Any, label: str) -> str: return value +def coords(value: Any, label: str) -> str: + """The chart's coordinate system: 'cartesian' or 'polar'.""" + if not isinstance(value, str) or value not in _COORDS: + raise ValueError(f"{label} must be one of {sorted(_COORDS)}") + return value + + +def theta_unit(value: Any, label: str) -> str: + """Angular unit for a polar theta axis: 'radians' or 'degrees'.""" + if not isinstance(value, str) or value not in _THETA_UNITS: + raise ValueError(f"{label} must be one of {sorted(_THETA_UNITS)}") + return value + + +def theta_direction(value: Any, label: str) -> str: + """Sweep direction of increasing theta.""" + if not isinstance(value, str) or value not in _THETA_DIRECTIONS: + raise ValueError(f"{label} must be one of {sorted(_THETA_DIRECTIONS)}") + return value + + +def theta_zero(value: Any, label: str) -> Any: + """Where theta=0 points: a compass letter, or an angle in radians. + + The letters are the readable spelling of the four cardinal directions and + are kept verbatim on the wire so the client and both exporters resolve them + through one shared table rather than three float literals. + """ + if isinstance(value, str): + if value not in _THETA_ZEROS: + raise ValueError( + f"{label} must be one of {sorted(_THETA_ZEROS)} or an angle in radians" + ) + return value + return finite_scalar(value, label) + + +def theta_sector(value: Any, label: str) -> tuple[float, float]: + """Increasing angular sector endpoints. + + The maximum sweep depends on the theta axis's resolved unit, so the shared + Figure validation performs that final check once the axis is attached to a + polar chart. + """ + return finite_increasing_pair(value, label) + + +def polar_grid_shape(value: Any, label: str) -> str: + """Polar radial-grid geometry: circular arcs or straight polygons.""" + if not isinstance(value, str) or value not in _POLAR_GRID_SHAPES: + raise ValueError(f"{label} must be one of {sorted(_POLAR_GRID_SHAPES)}") + return value + + +def polar_hole(value: Any, label: str) -> float: + """Display-space inner-radius fraction, in the half-open interval [0, 1).""" + out = finite_scalar(value, label) + if out < 0 or out >= 1: + raise ValueError(f"{label} must be at least 0 and less than 1") + return out + + _POINT_SYMBOLS = frozenset( { "circle", diff --git a/python/xy/components.py b/python/xy/components.py index bd562137..9cc0b8aa 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -37,7 +37,7 @@ import uuid import warnings from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from functools import lru_cache from os import PathLike from typing import Any, Literal, Optional, TypeAlias, Union @@ -117,6 +117,11 @@ "mark", "marker", "modebar", + "pie_chart", + "polar_bar_chart", + "polar_chart", + "r_axis", + "radar_chart", "scatter", "scatter_chart", "segments", @@ -130,6 +135,7 @@ "step_chart", "text", "theme", + "theta_axis", "threshold", "threshold_zone", "tooltip", @@ -138,6 +144,7 @@ "violin", "violin_chart", "vline", + "wind_rose", "x_axis", "x_band", "y_axis", @@ -236,6 +243,17 @@ class Axis(Component): minor_tick_values: Optional[list[float]] = None minor_style: dict[str, StyleValue] = field(default_factory=dict) nonpositive: Optional[Literal["clip", "mask"]] = None + # Polar angular configuration, set by `theta_axis`. Ignored unless the + # chart is `coords="polar"`; see spec/design/polar-axes.md. + theta_unit: Optional[str] = None + theta_zero: Union[str, float, None] = None + theta_direction: Optional[str] = None + # Phase-7 polar geometry. Appended to preserve the released positional + # dataclass surface; ignored unless the owning chart is polar. + sector: Optional[tuple[float, float]] = None + grid_shape: Optional[str] = None + hole: Optional[float] = None + r_origin: Optional[float] = None @dataclass @@ -1661,6 +1679,7 @@ def bar( series: Optional[list[str]] = None, opacity: Any = 0.85, corner_radius: Any = 0.0, + wedge_gap: float = 0.0, stroke: Any = None, stroke_width: Any = 0.0, _artist_alpha: Any = None, @@ -1688,6 +1707,10 @@ def bar( series: Optional names for matrix-valued series. opacity: Bar opacity from zero to one. corner_radius: Bar corner radius in pixels. + wedge_gap: Gap between neighbouring polar wedges, in pixels — constant + from the hole to the rim. Deliberately a length, not an angle: an + angular pad's gap is ``r · dtheta`` wide and so tapers to nothing + toward the centre. Ignored outside ``coords="polar"``. stroke: Optional bar outline color. stroke_width: Bar outline width in pixels. _artist_alpha: Internal Matplotlib alpha override, scalar or per bar. @@ -1719,6 +1742,7 @@ def bar( "series": series, "opacity": opacity, "corner_radius": corner_radius, + "wedge_gap": wedge_gap, "stroke": stroke, "stroke_width": stroke_width, "_artist_alpha": _artist_alpha, @@ -1744,6 +1768,7 @@ def column( series: Optional[list[str]] = None, opacity: float = 0.85, corner_radius: Union[float, tuple[float, float]] = 0.0, + wedge_gap: float = 0.0, stroke: Optional[str] = None, stroke_width: float = 0.0, fill: Union[str, dict[str, str], None] = None, @@ -1770,6 +1795,10 @@ def column( series: Optional names for matrix-valued series. opacity: Column opacity from zero to one. corner_radius: Column corner radius in pixels. + wedge_gap: Gap between neighbouring polar wedges, in pixels — constant + from the hole to the rim. Deliberately a length, not an angle: an + angular pad's gap is ``r · dtheta`` wide and so tapers to nothing + toward the centre. Ignored outside ``coords="polar"``. stroke: Optional column outline color. stroke_width: Column outline width in pixels. fill: CSS fill value or linear gradient. @@ -1800,6 +1829,7 @@ def column( "series": series, "opacity": opacity, "corner_radius": corner_radius, + "wedge_gap": wedge_gap, "stroke": stroke, "stroke_width": stroke_width, "fill": fill, @@ -2631,6 +2661,219 @@ def y_axis( ) +#: Cartesian axis keywords no polar renderer implements, mapped to what a polar +#: chart does instead. Each of these rode the wire and was then dropped on the +#: floor by the client *and* both exporters — the same accepted-but-inert trap as +#: a polar secondary axis or an angular `reverse`, and the reason the polar axis +#: documentation was advertising controls that did nothing. +#: +#: Refused HERE, on the documented polar surface, rather than at payload build: +#: `xy.pyplot`'s polar projection forwards rcParam-derived axis props (a +#: `minor_style` for every Axes, `minorticks_on()` values, a `ha=` anchor) into +#: the same figure, and refusing there would turn `projection="polar"` into an +#: error. `_polar_axis_kwargs` strips them for that adapter instead, which is +#: recorded in spec/matplotlib/compat.md. +_POLAR_INERT_AXIS_KEYWORDS: dict[str, str] = { + "minor_tick_values": ( + "no minor rings or spokes are drawn on a disc, so the values were accepted and " + "dropped. Pass the values you want drawn as tick_values" + ), + "minor_style": ( + "no minor ticks or minor grid are drawn on a disc, so the style had nothing to " + "paint. Style the major rings and spokes with style=" + ), + "tick_label_min_gap": ( + "tick labels ring the disc rather than running along an edge, so there is no " + "collision pass for a minimum gap to feed. Radial labels are stride-thinned to " + "what the label spoke holds; use tick_count or tick_values to thin deliberately" + ), + "tick_label_anchor": ( + "each tick label anchors radially — outward around the rim, and outward along " + "the radial label spoke — so an edge-relative anchor has nothing to act on. Use " + "tick_label_angle to rotate the label text" + ), +} + +#: Tick-label strategies that only mean something to the edge-relative collision +#: pass. `off` (hide the label text) and `none` (hide the whole axis) are honoured +#: under polar and stay out of this set. +_POLAR_INERT_TICK_LABEL_STRATEGIES = frozenset({"auto", "hide", "rotate", "stagger", "preserve"}) + + +def _refuse_inert_polar_axis_kwargs(kwargs: dict[str, Any], vocabulary: str) -> None: + """Reject polar axis keywords no renderer implements, naming the alternative.""" + for key, explanation in _POLAR_INERT_AXIS_KEYWORDS.items(): + value = kwargs.get(key) + if value is None or value == {}: + continue + raise ValueError( + f"{vocabulary} does not support {key}={value!r}: {explanation}. " + "See spec/design/polar-axes.md." + ) + strategy = kwargs.get("tick_label_strategy") + if isinstance(strategy, str) and strategy.replace("-", "_") in ( + _POLAR_INERT_TICK_LABEL_STRATEGIES + ): + raise ValueError( + f"{vocabulary} does not support tick_label_strategy={strategy!r}; rim labels " + "have no edge-relative collision pass. 'off' (hide the tick labels) and " + "'none' (hide the axis) are supported. See spec/design/polar-axes.md." + ) + + +def _polar_axis_kwargs(props: Mapping[str, Any]) -> dict[str, Any]: + """`props` with the keywords `theta_axis`/`r_axis` refuse removed. + + For adapters that build a polar axis out of a general axis-property bag they + do not fully control — `xy.pyplot`, whose polar Axes carries an rcParam + `minor_style` and whatever `minorticks_on()`/`ha=` left behind. Dropping is + what all three renderers already do with these values; the point of the + refusal is that a *hand-authored* polar axis hears about it. + """ + dropped = set(_POLAR_INERT_AXIS_KEYWORDS) + out = {key: value for key, value in props.items() if key not in dropped} + strategy = out.get("tick_label_strategy") + if isinstance(strategy, str) and strategy.replace("-", "_") in ( + _POLAR_INERT_TICK_LABEL_STRATEGIES + ): + out.pop("tick_label_strategy") + return out + + +def theta_axis( + *, + unit: Optional[str] = None, + zero: Union[str, float, None] = None, + direction: Optional[str] = None, + sector: Optional[tuple[float, float]] = None, + grid_shape: Optional[str] = None, + **kwargs: Any, +) -> Axis: + """Configure the angular axis of an `xy.polar_chart`. + + Delegates to `x_axis` — the angular axis *is* the x axis under + ``coords="polar"`` — so the `x_axis` keywords listed below apply here too and + are validated by one shared path. + + **Not every `x_axis` keyword survives a disc**, and the ones that do not are + refused here rather than accepted and dropped: + + * ``minor_tick_values`` / ``minor_style`` — no minor rings or spokes are + drawn. Pass the values you want drawn as ``tick_values``. + * ``tick_label_min_gap`` and ``tick_label_strategy`` in its collision + spellings (``"auto"``, ``"hide"``, ``"rotate"``, ``"stagger"``, + ``"preserve"``) — rim labels have no edge-relative collision pass. + ``"off"`` (hide the labels) and ``"none"`` (hide the axis) work. + * ``tick_label_anchor`` — labels anchor radially. Use ``tick_label_angle``. + + Three more are refused when the figure is built, because they depend on the + resolved data: ``type_="log"``/``"symlog"``, ``reverse=True``, and a + time-valued angular column. An instant and a non-linear angle have no + coherent projection; use ``direction=`` to reverse the direction of travel. + + ``format`` **is** honoured and wins over the built-in degree/radian text, so + ``theta_axis(unit="degrees", format=".0f°")`` relabels the spokes. + + Args: + unit: Angular unit of the data, ``"radians"`` (default) or ``"degrees"``. + zero: Direction that angle 0 points — ``"E"`` (default), ``"N"``, + ``"W"``, ``"S"``, or an angle in radians counterclockwise from east. + direction: ``"counterclockwise"`` (default) or ``"clockwise"``. + Compass work usually wants ``zero="N"`` with ``"clockwise"``, which + puts 90° at east and 180° at south. + sector: Visible angular interval in the declared ``unit``. The sweep + must be increasing and no wider than one full turn. + grid_shape: ``"circular"`` (default) for arc rings or ``"linear"`` for + polygonal rings joining the angular spokes. + **kwargs: Any `x_axis` keyword. + + Returns: + An `Axis` for the angular dimension. + """ + _refuse_inert_polar_axis_kwargs(kwargs, "theta_axis") + axis = x_axis(**kwargs) + if sector is not None and axis.domain is not None: + raise ValueError("theta_axis sector and domain describe the same limit; pass only one") + resolved_sector = axis.domain if sector is None else sector + return replace( + axis, + # On a polar angular axis, the familiar axis `domain=` spelling is an + # alias for the visible sector. The data/tick range remains independent + # (notably, categorical theta stays in category-index coordinates). + domain=None, + theta_unit=None if unit is None else _validate.theta_unit(unit, "theta_axis unit"), + theta_zero=None if zero is None else _validate.theta_zero(zero, "theta_axis zero"), + theta_direction=( + None + if direction is None + else _validate.theta_direction(direction, "theta_axis direction") + ), + sector=( + None + if resolved_sector is None + else _validate.theta_sector(resolved_sector, "theta_axis sector") + ), + grid_shape=( + None + if grid_shape is None + else _validate.polar_grid_shape(grid_shape, "theta_axis grid_shape") + ), + ) + + +def r_axis( + *, + hole: Optional[float] = None, + origin: Optional[float] = None, + **kwargs: Any, +) -> Axis: + """Configure the radial axis of an `xy.polar_chart`. + + Delegates to `y_axis` — the radial axis *is* the y axis under + ``coords="polar"`` — so the `y_axis` keywords apply. Provided so polar + compositions read in polar vocabulary rather than mixing x/y with theta/r. + + The same four keywords `xy.theta_axis` refuses are refused here, and for the + same reason — no minor rings, no rim collision pass, no edge-relative label + anchor. ``reverse=True``, ``type_="log"``, ``type_="symlog"`` and a + time-valued radial column are all supported on the radius. + + **Autorange.** A linear or symlog radius starts at the centre (matplotlib's + ``rmin=0``) and ends at the data maximum with no outer pad, so the outermost + ring *is* the largest datum; log autorange stays strictly positive. Two + exceptions: data that goes below zero keeps its ordinary padded extent + (a centre origin is vacuous below zero), and so does a **time** radius, + whose zero is 1970 — pinning it would squeeze every modern instant into a + hairline ring at the rim. An explicit ``margin=`` restores the outer pad; + an explicit ``domain=``/``bounds=`` overrides autorange entirely. + + **Signed radii are positions, not directions.** A negative radius is not + mirrored through the centre the way matplotlib mirrors it: it is a value on + a range that includes it, so ``-5`` draws nearer the centre than ``0``. A + radius outside the visible interval is culled for points and line vertices + and clamped for fills and sectors (spec/design/polar-axes.md §3, §8). + + Args: + hole: Display-space inner-radius fraction, from 0 (no hole) up to but + excluding 1. + origin: Data-space radial origin. An origin below the visible radial + minimum creates an annulus. Mutually exclusive with ``hole``. + **kwargs: Any `y_axis` keyword. + + Returns: + An `Axis` for the radial dimension. + """ + if hole is not None and origin is not None: + raise ValueError("r_axis hole and origin are mutually exclusive") + _refuse_inert_polar_axis_kwargs(kwargs, "r_axis") + axis = y_axis(**kwargs) + return replace( + axis, + hole=None if hole is None else _validate.polar_hole(hole, "r_axis hole"), + r_origin=None if origin is None else _validate.finite_scalar(origin, "r_axis origin"), + ) + + def legend( *children: Any, show: bool = True, @@ -3184,6 +3427,7 @@ def __init__( reset_axes: Optional[tuple[str, ...]] = None, link_group: Optional[str] = None, link_axes: Optional[tuple[str, ...]] = None, + coords: str = "cartesian", ) -> None: """Initialize a chart composition. @@ -3224,6 +3468,10 @@ def __init__( reset_axes: Declared axis IDs restored by reset. link_group: Identifier used to synchronize charts in the browser. link_axes: Axes synchronized within the link group. + coords: Coordinate system, ``"cartesian"`` (default) or ``"polar"``. + Under ``"polar"`` each mark's first channel is the angle and its + second is the radius. Prefer ``xy.polar_chart(...)``, which sets + this for you. """ self.kind = kind self.children = children @@ -3263,6 +3511,7 @@ def __init__( self.reset_axes = reset_axes self.link_group = link_group self.link_axes = link_axes + self.coords = _validate.coords(coords, "coords") self._figure: Optional[Figure] = None self._widget: Any = None # Facet builds pre-seed a union category order here (per axis dim) so @@ -3320,6 +3569,7 @@ def figure(self) -> Figure: title=self.title, x_label=xa.label if xa else None, y_label=ya.label if ya else None, + coords=self.coords, ) for axis in axis_children: axis_id = axis.id or axis.which @@ -3350,6 +3600,13 @@ def figure(self) -> Figure: style=axis.style, minor_style=axis.minor_style, nonpositive=axis.nonpositive, + theta_unit=axis.theta_unit, + theta_zero=axis.theta_zero, + theta_direction=axis.theta_direction, + sector=axis.sector, + grid_shape=axis.grid_shape, + hole=axis.hole, + r_origin=axis.r_origin, ) # Facet builds pre-seed the union category order (set as a private # attribute by FacetChart) so shared categorical domains align the @@ -5596,6 +5853,7 @@ def _apply_bar(fig: Figure, m: Mark, data: Any) -> None: series=m.props["series"], opacity=m.props["opacity"], corner_radius=m.props["corner_radius"], + wedge_gap=m.props.get("wedge_gap", 0.0), stroke=m.props["stroke"], stroke_width=m.props["stroke_width"], _artist_alpha=m.props.get("_artist_alpha"), @@ -5619,6 +5877,7 @@ def _apply_column(fig: Figure, m: Mark, data: Any) -> None: series=m.props["series"], opacity=m.props["opacity"], corner_radius=m.props["corner_radius"], + wedge_gap=m.props.get("wedge_gap", 0.0), stroke=m.props["stroke"], stroke_width=m.props["stroke_width"], fill=m.props["fill"], @@ -5933,6 +6192,468 @@ def line_chart(*children: Component, **props: Any) -> Chart: return Chart("line_chart", children, **props) +def _require_polar_coords(props: dict) -> None: + """Pin `coords` to polar, refusing an explicit override. + + `coords` is the ONLY thing that makes one of these helpers polar — `Chart.kind` + is inert — so `setdefault` let `pie_chart(..., coords="cartesian")` return + unlabelled rounded rects with no axes, silently dropping any authored + `theta_axis`/`r_axis`. Worse, `Figure._validate_coords` returns early for a + non-polar figure, so the keyword also re-opened every refusal this module + relies on it for. + """ + coords = props.get("coords", "polar") + if coords != "polar": + raise ValueError( + f"this chart is polar; coords={coords!r} is not supported. " + "Use xy.chart(...) or xy.bar_chart(...) for a cartesian figure." + ) + props["coords"] = "polar" + + +def polar_chart(*children: Component, **props: Any) -> Chart: + """A polar chart: the same marks, rendered through polar coordinates. + + Each mark's first channel is the angle and its second is the radius, so + `xy.line`, `xy.scatter`, `xy.area`, `xy.bar`, and `xy.column` are reused + rather than replaced by polar-specific marks. Configure the angular axis + with `xy.theta_axis` and the radial axis with `xy.r_axis`. + + xy.polar_chart( + xy.line(angle, gain, name="measured"), + xy.theta_axis(unit="degrees", zero="N", direction="clockwise"), + xy.r_axis(label="gain (dBi)"), + ) + + Supported mark kinds are listed in `xy.config.POLAR_MARK_KINDS`; anything + outside that set is refused at build time rather than approximated. Prefer + `xy.radar_chart` for categorical spider plots, `xy.polar_bar_chart` + for radial bars, and `xy.wind_rose` for directional distributions. Other + details and deferred geometry are tracked in spec/design/polar-axes.md. + """ + _require_polar_coords(props) + return Chart("polar_chart", children, **props) + + +def radar_chart( + categories: Sequence[str], + *children: Component, + fill: bool = True, + **props: Any, +) -> Chart: + """A radar (spider) chart: one closed polygon per series. + + Radar is a composition, not a renderer — matplotlib's own gallery builds it + from a `PolarAxes` subclass plus line/fill calls, and Plotly from a filled + `Scatterpolar`. This wraps that composition: evenly spaced spokes labelled + with `categories`, and each series closed back to its first value. + + xy.radar_chart( + ["speed", "power", "range", "agility"], + xy.area([0.9, 0.7, 0.55, 0.85], name="model A"), + xy.area([0.6, 0.8, 0.7, 0.5], name="model B"), + ) + + Each mark child supplies **values only**, one per category, in the same + order; the angles are derived. Closing is done here rather than by the + caller because the seam is easy to get wrong: appending the first *angle* + makes the final segment sweep backwards through the whole circle, so the + closing sample is placed at a full turn instead. + + Args: + categories: Spoke labels, one per value. + *children: `area` (filled) or `line` (outline) marks carrying values, + plus any axis/legend children. + fill: When False, filled `area` children are rebuilt as `line` + outlines, so one call switches a whole chart between filled and + outline radar without editing every mark. + **props: Any `polar_chart` keyword. + + Returns: + A polar `Chart` with categorical spokes. + """ + names = [str(c) for c in categories] + if len(names) < 3: + raise ValueError("radar_chart needs at least 3 categories") + count = len(names) + # Spokes are derived, so they must be generated in the unit the ANGULAR + # AXIS declares. Hard-coding radians against an authored + # `theta_axis(unit="degrees")` left the samples spanning 0..2pi inside a + # 0..360 frame, squeezing the whole radar into the first 6.28 degrees. + unit = "radians" + for child in children: + if isinstance(child, Axis) and child.which == "x" and child.theta_unit: + unit = str(child.theta_unit) + turn = 360.0 if unit == "degrees" else 2.0 * math.pi + step = turn / count + angles = [i * step for i in range(count)] + closed_angles = [*angles, turn] + + rebuilt: list[Component] = [] + for child in children: + if isinstance(child, Mark) and child.kind in {"area", "line"}: + values = _radar_values(child, count) + closed = [*values, values[0]] + if child.kind == "area" and not fill: + rebuilt.append(_radar_outline(child, closed_angles, closed)) + else: + rebuilt.append(replace(child, x=closed_angles, y=closed)) + elif isinstance(child, Mark): + raise ValueError(f"radar_chart supports area and line marks; got {child.kind!r}") + else: + rebuilt.append(child) + # An authored theta axis customises the spokes, it does not opt out of + # them: category labels merge into it unless it authored its own ticks. + # Dropping the injection outright made `xy.theta_axis(label=...)` silently + # replace the category spokes with numeric angles. + merged = False + for index, child in enumerate(rebuilt): + if isinstance(child, Axis) and child.which == "x": + if child.tick_values is None: + rebuilt[index] = replace(child, tick_values=list(angles), tick_labels=list(names)) + merged = True + break + if not merged: + rebuilt.append(theta_axis(tick_values=angles, tick_labels=names)) + _require_polar_coords(props) + return Chart("radar_chart", tuple(rebuilt), **props) + + +def _radar_outline(mark: "Mark", angles: list[float], values: list[float]) -> "Mark": + """Rebuild a filled radar `area` as its outline, for `fill=False`. + + The two marks do not share a prop vocabulary — an area carries + `line_color`/`line_width`/`line_opacity` where a line carries + `color`/`width`/`opacity` — so swapping only `kind` handed `_apply_line` an + area's dict and it died on `m.props["width"]`. The outline inherits the + area's stroke settings, falling back to its fill color when the stroke was + never given one. + """ + props = mark.props + return replace( + mark, + kind="line", + x=angles, + y=values, + props={ + "color": props.get("line_color") or props.get("color"), + # `or` treated a legal 0.0 as "unset" and silently substituted the + # 2.0 default, so an author asking for no outline got the thickest + # one. Fall back only on a genuinely absent value; an explicit 0 + # then meets the library-wide "line width must be positive" rule + # instead of being quietly overridden. + "width": 2.0 if props.get("line_width") is None else props["line_width"], + "opacity": props.get("line_opacity", 1.0), + "curve": props.get("curve", "linear"), + "dash": props.get("dash"), + "x_axis": props.get("x_axis", "x"), + "y_axis": props.get("y_axis", "y"), + }, + ) + + +def _radar_values(mark: "Mark", count: int) -> list[float]: + """The one value-per-category column a radar mark carries. + + A radar mark is written `xy.area(values)`, so the single positional + argument lands in `x`; `y` is accepted too for callers who spell it out. + """ + raw = mark.y if mark.y is not None else mark.x + if isinstance(raw, str): + raise ValueError( + f"radar_chart {mark.kind} mark must carry values directly, not the " + f"column name {raw!r}: the angles come from the categories, so there is " + "no frame to resolve against. Pass data[column] instead." + ) + if raw is None: + raise ValueError(f"radar_chart {mark.kind} mark needs one value per category") + values = [float(v) for v in np.asarray(raw, dtype=float).reshape(-1)] + if len(values) != count: + raise ValueError( + f"radar_chart {mark.kind} mark has {len(values)} values " + f"but there are {count} categories" + ) + return values + + +def polar_bar_chart(*children: Component, **props: Any) -> Chart: + """Radial bars: each bar is an annular sector rather than a rectangle. + + Bars carry the angle as their first channel and the radius as their second, + with `width` in the angular axis's own unit (radians by default, degrees + when the theta axis says so). A scalar width creates equal sectors; a + per-bar width sequence creates unequal sectors for pie/donut composition. + `base` sets the inner radius, so a positive base opens an annular hole. + + xy.polar_bar_chart( + xy.bar(directions, counts, width=30.0), + xy.theta_axis(unit="degrees", zero="N", direction="clockwise"), + ) + + Args: + *children: `bar` marks plus axis/legend children. + **props: Any `polar_chart` keyword. + + Returns: + A polar `Chart`. + """ + _require_polar_coords(props) + return Chart("polar_bar_chart", children, **props) + + +def pie_chart( + labels: Sequence[Any], + values: ArrayLike, + *children: Component, + hole: float = 0.55, + pad: float = 4.0, + colors: Optional[Sequence[str]] = None, + corner_radius: float = 6.0, + show_values: bool = True, + show_percent: bool = True, + **props: Any, +) -> Chart: + """A pie or donut: one slice per label, sized by value. + + xy.pie_chart( + ["Skyline", "Datawell", "Cloudpeak"], + [27, 21, 13], + ) + + A pie is a composition over the polar coordinate system, not a chart + type: each slice is a wedge bar whose ANGULAR WIDTH carries the value. + That is exactly why the generic hover readout has nothing meaningful to + print for a slice — theta is layout and the radius is the constant rim — + so this composition owns its tooltip: hovering a slice shows its + category and value (and share), nothing else. A user-supplied + `xy.tooltip(...)` child still wins. + + Args: + labels: One category name per slice. + values: One non-negative value per slice. + *children: Extra components (legend placement, a user tooltip, …). + hole: Inner radius fraction; 0 is a full pie, the default is a donut. + pad: Gap between neighbouring slices, in PIXELS — constant from the + hole to the rim. An angular pad would be `r · dtheta` wide and so + taper to nothing toward the centre, which reads as the spacing + being applied unevenly across the slice. + colors: One CSS colour per slice. Defaults to the palette cycle. + corner_radius: Rounded slice corners, in px. + show_values: Include the value in the slice's name (legend + tooltip). + Dropped for a slice whose value renders identically to its share — + percentage-shaped input would otherwise print the same number twice. + show_percent: Include the share in the slice's name (legend + tooltip). + **props: Any `polar_chart` keyword (`width`, `height`, `title`, …). + """ + names = [str(label) for label in labels] + amounts = [float(v) for v in np.asarray(values, dtype=float).reshape(-1)] + if len(names) != len(amounts): + raise ValueError( + f"pie_chart needs one value per label; got {len(names)} labels " + f"and {len(amounts)} values" + ) + if not names: + raise ValueError("pie_chart needs at least one slice") + if any(not math.isfinite(v) or v < 0.0 for v in amounts): + raise ValueError("pie_chart values must be finite and non-negative") + total = sum(amounts) + if total <= 0.0: + raise ValueError("pie_chart values must sum to a positive total") + if not 0.0 <= float(hole) < 1.0: + raise ValueError("pie_chart hole must be in [0, 1)") + if colors is not None and len(colors) != len(names): + raise ValueError( + f"pie_chart colors must have one entry per slice ({len(names)}); got {len(colors)}" + ) + + # Never print the same number twice. Percentage-shaped input — values that + # already sum to 100, which is how most pie data arrives — made the two + # defaults collide: `[40, 30, 20, 10]` rendered "Direct 40 (40%)", so the + # legend read as repeated text and the doubled label was what overflowed the + # box. The share keeps the unit, so it is the one that survives. + # + # Decided once for the whole pie rather than per slice: a mixed legend, where + # one row carries a bare value and the next does not, is harder to read than + # either consistent choice. Zero slices are excluded because they draw no + # wedge and get no row. + values_are_shares = show_percent and all( + f"{value:g}" == f"{value / total * 100:.0f}" for value in amounts if value > 0.0 + ) + + slices: list[Component] = [] + cursor = 0.0 + for index, (label, value) in enumerate(zip(names, amounts, strict=True)): + span = value / total * 360.0 + # A zero-valued category is ordinary in aggregated data and this factory + # accepts it. A zero-width wedge is legal at the mark layer now (it draws + # nothing, like `line_width=0`), so this skip is no longer about avoiding + # an error from a layer below — it is about the LEGEND: a row whose swatch + # highlights nothing on hover and toggles nothing on click is worse than + # no row. Dropping the wedge drops the row with it. + if span <= 0.0: + continue + display = label + if show_values and not values_are_shares: + display += f" {value:g}" + if show_percent: + display += f" ({value / total * 100:.0f}%)" + slices.append( + bar( + [cursor + span / 2.0], + [1.0 - float(hole)], + base=float(hole), + # The FULL span: the gap is carved out by the renderer at a + # constant pixel width, not by shrinking the angle, so the + # wire keeps each slice's true share. + width=span, + wedge_gap=float(pad), + color=None if colors is None else colors[index], + name=display, + corner_radius=corner_radius, + animation=False, + ) + ) + cursor += span + + # The slice's name IS the readout (category, value, share); theta and the + # rim radius are layout, not data, and would only add noise. + has_tooltip = any(isinstance(child, Tooltip) for child in children) + defaults: tuple[Component, ...] = () if has_tooltip else (tooltip(title="{name}"),) + children = ( + *slices, + theta_axis(unit="degrees", zero="N", direction="clockwise", show=False), + r_axis(domain=(0.0, 1.0), show=False), + *defaults, + *children, + ) + _require_polar_coords(props) + return Chart("pie_chart", children, **props) + + +def wind_rose( + directions: ArrayLike, + speeds: ArrayLike, + *children_in: Component, + sectors: int = 16, + speed_bins: Optional[Sequence[float]] = None, + **props: Any, +) -> Chart: + """A wind rose: directional frequency, stacked by speed band. + + Binning is done here in Python rather than by a renderer — the same + arrangement `hist` uses — so the chart is polar bars over counts and nothing + about the render path is wind-specific. + + Directions are compass bearings in degrees (0 = north, increasing + clockwise), which is why the theta axis defaults to `zero="N"` with + `direction="clockwise"`. + + Args: + directions: Bearings in degrees, one per observation. + children_in: Extra components — an `xy.legend`, or an `xy.tooltip` to + replace the default direction/count readout. + speeds: Speeds, one per observation. + sectors: Number of angular bins around the circle. + speed_bins: Upper edges of the speed bands. Defaults to four quartile + bands derived from the data. Each band takes the next colour from + the chart's palette cycle, as stacked series do everywhere else. + **props: Any `polar_chart` keyword. + + Returns: + A polar `Chart` of stacked bars. + """ + bearings = np.asarray(directions, dtype=float).reshape(-1) + magnitudes = np.asarray(speeds, dtype=float).reshape(-1) + if bearings.size != magnitudes.size: + raise ValueError("wind_rose directions and speeds must be the same length") + # A fractional count reached np.bincount's `minlength` and surfaced as a + # raw NumPy TypeError about "a sequence of integers", which names neither + # the parameter nor the caller's mistake. + if isinstance(sectors, bool) or not isinstance(sectors, (int, np.integer)): + raise ValueError(f"wind_rose sectors must be a whole number; got {sectors!r}") + if sectors < 3: + raise ValueError("wind_rose sectors must be at least 3") + finite = np.isfinite(bearings) & np.isfinite(magnitudes) + bearings, magnitudes = bearings[finite], magnitudes[finite] + if bearings.size == 0: + raise ValueError("wind_rose needs at least one finite observation") + + if speed_bins is None: + # Quartile bands, rounded to three significant figures: the raw + # quantiles are readable as a legend only by accident ("<= 2.76651"). + quartiles = np.quantile(magnitudes, [0.25, 0.5, 0.75, 1.0]) + edges = np.unique([float(f"{value:.3g}") for value in quartiles]) + # The top edge rounds *up*, never down: it has to cover the fastest + # observation, and restoring the raw maximum would put "27.2197" in the + # legend next to "2.77". + top = float(magnitudes.max()) + if top > 0: + unit = 10.0 ** (math.floor(math.log10(top)) - 2) + edges[-1] = math.ceil(top / unit) * unit + else: + edges = np.unique(np.asarray(speed_bins, dtype=float).reshape(-1)) + if edges.size == 0: + raise ValueError("wind_rose speed_bins must contain at least one edge") + + width = 360.0 / sectors + # Bin centred on each sector: a bearing of 0 belongs to the sector centred + # on north, not to the one starting there. + index = np.floor(((bearings % 360.0) + width / 2.0) / width).astype(int) % sectors + centres = np.arange(sectors, dtype=float) * width + + marks: list[Component] = [] + base = np.zeros(sectors, dtype=float) + lower = -np.inf + for upper in edges: + in_band = (magnitudes > lower) & (magnitudes <= upper) + counts = np.bincount(index[in_band], minlength=sectors).astype(float) + marks.append( + bar( + centres, + # `bar` measures its value as a HEIGHT above `base`, not as an + # absolute top: passing `base + counts` stacked each band on + # top of its own cumulative offset a second time, so a rose of + # three observations reached radius 5 and every band above the + # first was too thick. The height IS the band's count, which is + # also what makes the hover readout the band's own count. + counts, + base=base.copy(), + width=width, + name=f"\u2264 {upper:g}", + ) + ) + base = base + counts + lower = upper + + _require_polar_coords(props) + # A wind rose is the one polar composition where the ANGLE is data — it is + # the compass bearing — so it opts the direction row back in by naming it, + # and pairs it with the band's own count. `y` is the band height (see the + # comment above), so this reads "<= 10 / direction 45deg / count 7" rather + # than the cumulative stack radius the generic readout would show. + has_tooltip = any(isinstance(child, Tooltip) for child in children_in) + defaults: tuple[Component, ...] = ( + () + if has_tooltip + else ( + tooltip( + title="{name}", + fields=["x", "y"], + labels={"x": "direction (\u00b0)", "y": "count"}, + format={"x": ".0f"}, + ), + ) + ) + children = ( + *marks, + theta_axis(unit="degrees", zero="N", direction="clockwise"), + r_axis(label="count"), + *defaults, + *children_in, + ) + return Chart("wind_rose", children, **props) + + def area_chart(*children: Component, **props: Any) -> Chart: """An area chart composing `area` marks and axis/legend children.""" return Chart("area_chart", children, **props) diff --git a/python/xy/config.py b/python/xy/config.py index 5bbf3502..e06b1f66 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -7,6 +7,7 @@ from __future__ import annotations +import math import warnings # Wire protocol version: the client refuses a mismatched spec loudly (§33). @@ -28,7 +29,66 @@ # including axes-fraction y and pixel padding. A v9 client would ignore that # field and silently omit non-center slots and their placement, so it must # reject the payload. -PROTOCOL_VERSION = 10 +# v11 adds the chart-level `coords` key ("polar"), plus `theta_unit`, +# `theta_zero` and `theta_direction` on the angular (x) axis spec. A v10 client +# would ignore `coords` entirely and draw the (theta, r) columns as cartesian +# x/y — a plausible, completely wrong picture — so it must reject the payload. +# v12 adds polar sector/grid-shape metadata on the angular axis and hole/origin +# metadata on the radial axis. A v11 client would silently draw a full circular +# grid with a centre-origin radius, so the new geometry must fail the handshake. +PROTOCOL_VERSION = 12 + +# Mark kinds the polar transform renders correctly today. Everything else is +# refused by Figure._validate_coords rather than approximated: the rect, area, +# segment and mesh shaders expand geometry in pixel space after the coordinate +# map, so under polar they would draw chord-edged shapes where arcs belong. +# spec/design/polar-axes.md §7 tracks the order the rest land in. +POLAR_MARK_KINDS = frozenset( + {"line", "scatter", "area", "bar", "column", "heatmap", "contour", "errorbar"} +) + +# Polar traces ship tier="direct" (§7): M4 decimation buckets on a monotonic +# screen-x column, which a spiral is not, and density binning in (theta, r) +# distorts by area near the origin. Cap the direct path explicitly rather than +# letting an unbounded polar scatter allocate its way to a cliff. +POLAR_DIRECT_CEILING = 200_000 + +# Subdivisions across one FULL TURN, for the renderers that flatten arcs (the +# raster display list has no arc opcode, and the GPU sweeps a triangle strip). +# Mirrored by POLAR_BAR_SEGMENTS in js/src/50_chartview.ts. Sized so a wedge's +# chord sagitta stays inside the client's XY_POLAR_AA expansion up to a +# ~1400-device-px disc, letting the fragment SDF trim the strip to an exactly +# round arc; the raster's coverage-scanline fill smooths the same polygon. +POLAR_BAR_SEGMENTS = 96 + +# Floor on the subdivision of any single wedge. Two segments keep a strip that +# still brackets the true arc after the AA expansion, even for a hairline slice. +POLAR_BAR_SEGMENTS_MIN = 2 + + +def polar_bar_segments(span: float, turn: float) -> int: + """Subdivisions for one wedge of angular width `span` out of `turn`. + + The count used to be a flat `POLAR_BAR_SEGMENTS` per wedge, sized for the + worst case of a wedge sweeping the whole circle. Almost no wedge does: a + 16-sector wind rose sweeps 22.5 degrees, so every bar paid 2*(96+1) = 194 + vertices for an arc that needs six segments, and 50k polar bars fell off a + performance cliff building ~9.7M vertices per frame instead of ~700k. + + Sagitta is what the constant is sized against, and it is quadratic in the + per-segment angle: holding `span / n` fixed holds the flattening error fixed. + So the honest count is exactly proportional — `POLAR_BAR_SEGMENTS * span / + turn` — which reproduces 96 for a full turn and preserves the error bound for + everything narrower. §28: the decision is a recorded formula over the + AUTHORED angular width, not a view-dependent choice, so all three renderers + reach the same count for the same figure at any zoom or export size. + """ + if not (turn > 0.0): + return POLAR_BAR_SEGMENTS + fraction = abs(float(span)) / float(turn) + scaled = math.ceil(POLAR_BAR_SEGMENTS * fraction) + return max(POLAR_BAR_SEGMENTS_MIN, min(POLAR_BAR_SEGMENTS, scaled)) + # Line traces longer than this ship M4-decimated (Tier 1, §5); the canonical # column stays kernel-side for re-decimation on zoom (§28: recompute for the diff --git a/python/xy/marks.py b/python/xy/marks.py index a2687739..2fba4604 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -677,6 +677,7 @@ def _bar_like( series: Optional[list[str]], opacity: Any, corner_radius: Any = 0.0, + wedge_gap: float = 0.0, stroke: Any = None, stroke_width: Any = 0.0, artist_alpha: Any = None, @@ -690,12 +691,20 @@ def _bar_like( raise ValueError(f"{kind} orientation must be 'vertical' or 'horizontal'") category_axis = "x" if orientation == "vertical" else "y" pos, category_labels = self._axis_positions_with_labels(x, category_axis) + # Zero is legal and draws nothing, like the library-wide `line_width=0` rule. + # A bar of no size is an ordinary DATA state, not an author error: a 0% + # progress ring, an empty category in aggregated output, and the first frame + # of a grow animation all produce one. Refusing it made every hand-rolled + # wedge recipe — the pie/gauge compositions the docs show — die at exactly + # 0% with "bar width must be positive", a message about the author's code + # from a value that came out of their data. Negative and non-finite widths + # are still refused: they are not degenerate, they are meaningless. if np.isscalar(width): # Scalar widths are overwhelmingly the common path. Preserve the # established scalar validator (including bool rejection) without # allocating two temporary NumPy arrays for every bar chart. try: - width_values: float | np.ndarray = self._positive_scalar(width, f"{kind} width") + width_values: float | np.ndarray = self._nonnegative_scalar(width, f"{kind} width") except ValueError as exc: if isinstance(width, (str, bytes)): raise ValueError(f"{kind} width must be scalar or contain numeric values") from exc @@ -703,7 +712,7 @@ def _bar_like( elif isinstance(width, np.ndarray) and width.ndim == 0: if np.issubdtype(width.dtype, np.bool_): raise ValueError(f"{kind} width must be scalar or contain numeric values") - width_values = self._positive_scalar(width.item(), f"{kind} width") + width_values = self._nonnegative_scalar(width.item(), f"{kind} width") else: try: raw_width_array = np.asarray(width) @@ -721,8 +730,8 @@ def _bar_like( raise ValueError( f"{kind} width must be scalar or broadcast to the {len(pos)} bars" ) from None - if not np.isfinite(width_values).all() or np.any(width_values <= 0.0): - raise ValueError(f"{kind} width values must be finite and positive") + if not np.isfinite(width_values).all() or np.any(width_values < 0.0): + raise ValueError(f"{kind} width values must be finite and non-negative") vals = self._bar_value_matrix(y, len(pos), kind) n_series, n_items = vals.shape if mode == "normalized": @@ -792,6 +801,7 @@ def _bar_like( scalar_stroke, stroke_width_values[index], fill, + wedge_gap, ) mark_style.update(style_extra or {}) merged_channels = { @@ -2722,6 +2732,7 @@ def bar( series: Optional[list[str]] = None, opacity: Any = 0.85, corner_radius: Any = 0.0, + wedge_gap: float = 0.0, stroke: Any = None, stroke_width: Any = 0.0, _artist_alpha: Any = None, @@ -2738,6 +2749,7 @@ def bar( color = css.get("color", color) opacity = css.get("opacity", opacity) corner_radius = css.get("corner_radius", corner_radius) + wedge_gap = css.get("wedge_gap", wedge_gap) stroke = css.get("stroke", stroke) stroke_width = css.get("stroke_width", stroke_width) fill = css.get("fill", fill) @@ -2756,6 +2768,7 @@ def bar( series=series, opacity=opacity, corner_radius=corner_radius, + wedge_gap=wedge_gap, stroke=stroke, stroke_width=stroke_width, artist_alpha=_artist_alpha, @@ -2779,6 +2792,7 @@ def column( series: Optional[list[str]] = None, opacity: float = 0.85, corner_radius: Union[float, tuple[float, float]] = 0.0, + wedge_gap: float = 0.0, stroke: Optional[str] = None, stroke_width: float = 0.0, fill: Union[str, dict[str, str], None] = None, @@ -2789,6 +2803,7 @@ def column( color = css.get("color", color) opacity = css.get("opacity", opacity) corner_radius = css.get("corner_radius", corner_radius) + wedge_gap = css.get("wedge_gap", wedge_gap) stroke = css.get("stroke", stroke) stroke_width = css.get("stroke_width", stroke_width) fill = css.get("fill", fill) @@ -2807,6 +2822,7 @@ def column( series=series, opacity=opacity, corner_radius=corner_radius, + wedge_gap=wedge_gap, stroke=stroke, stroke_width=stroke_width, fill=fill, diff --git a/python/xy/pyplot/__init__.py b/python/xy/pyplot/__init__.py index 8948b1c3..b6473fb5 100644 --- a/python/xy/pyplot/__init__.py +++ b/python/xy/pyplot/__init__.py @@ -304,7 +304,9 @@ def subplots( Relative column widths / row heights (also accepted inside ``gridspec_kw``). subplot_kw : dict, optional - Properties applied to every created axes via ``Axes.set``. + Properties applied to every created axes. ``projection="polar"`` and + ``polar=True`` select the polar coordinate system; the remaining + properties are applied via ``Axes.set``. layout : {"none", "tight", "constrained", "compressed"}, optional Layout mode applied after axes are created. ``"tight"``, ``"constrained"``, and ``"compressed"`` use the shim's deterministic @@ -337,10 +339,8 @@ def subplots( width_ratios=width_ratios, height_ratios=height_ratios, gridspec_kw=gridspec_kw, + subplot_kw=subplot_kw, ) - if subplot_kw: - for ax in np.atleast_1d(np.asarray(axes, dtype=object)).ravel(): - ax.set(**subplot_kw) _apply_factory_layout(fig, layout) return fig, axes @@ -383,10 +383,7 @@ def axes(arg: Sequence[float] | None = None, **kwargs: Any) -> Axes: Keywords are applied via ``Axes.set``. """ if arg is None: - ax = gcf().add_subplot(111) - if kwargs: - ax.set(**kwargs) - return ax + return gcf().add_subplot(111, **kwargs) return gcf().add_axes(arg, **kwargs) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index f1e930a5..3ee6d6ec 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -11,6 +11,7 @@ from __future__ import annotations import copy +import math import warnings # Runtime imports, not TYPE_CHECKING: `typing.get_type_hints()` on the public @@ -27,6 +28,7 @@ from .. import _textblock from .._typing import ArrayLike, ColorLike, ColorsLike, LimitsLike, Scalar +from ..components import _polar_axis_kwargs from ._artists import ( Artist, AxesImage, @@ -1092,6 +1094,35 @@ def _cached_modebar(show: bool) -> Any: def _cached_axis(which: str, props: dict) -> Any: if props: + if which == "x" and any( + key in props + for key in ("theta_unit", "theta_zero", "theta_direction", "sector", "grid_shape") + ): + # `_polar_axis_kwargs` drops the keywords `theta_axis` refuses. This + # bag is not hand-authored: every Axes carries an rcParam-derived + # `minor_style`, and `minorticks_on()`/`tick_params(ha=)` add more. + # Dropping them is what all three renderers already do with those + # values; refusing would turn `projection="polar"` into an error over + # a default nobody asked for. Recorded in spec/matplotlib/compat.md. + angular = _polar_axis_kwargs(props) + unit = angular.pop("theta_unit", None) + zero = angular.pop("theta_zero", None) + direction = angular.pop("theta_direction", None) + sector = angular.pop("sector", None) + grid_shape = angular.pop("grid_shape", None) + return xy.theta_axis( + unit=unit, + zero=zero, + direction=direction, + sector=sector, + grid_shape=grid_shape, + **angular, + ) + if which == "y" and any(key in props for key in ("hole", "r_origin")): + radial = _polar_axis_kwargs(props) + hole = radial.pop("hole", None) + origin = radial.pop("r_origin", None) + return xy.r_axis(hole=hole, origin=origin, **radial) factory = xy.x_axis if which == "x" else xy.y_axis return factory(**props) key = ("axis", which) @@ -1146,6 +1177,13 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: # chart *is* the figure and the rectangle comes from get_position(). self._plot_box_px: Optional[tuple[float, float, float, float]] = None self._padding: Optional[list[float]] = None + # "cartesian" or "polar" — matplotlib's `projection=` argument. Polar + # reinterprets the same two axes (x carries theta, y carries r), which + # is exactly how PolarAxes works: ordinary plot/scatter/bar/fill calls + # render into the projection rather than into polar-specific artists. + self._projection: str = "cartesian" + self._polar_options: dict[str, Any] = {} + self._polar_r_options: dict[str, Any] = {} # Natural ``table(loc="bottom")`` height in Matplotlib points. It is # converted at render time so savefig DPI changes preserve cell size. self._table_bottom_points = 0.0 @@ -1531,6 +1569,10 @@ def clear(self) -> None: self._cycle = 0 self._patch_cycle = 0 self._load_rc_chrome() + self._polar_options = {} + self._polar_r_options = {} + if self._projection == "polar": + self._set_projection("polar") self._chart = None self._twin = None self.xaxis = _AxisProxy(self, "x") @@ -3809,8 +3851,13 @@ def set(self, **kwargs: Any) -> "Axes": xticklabels = kwargs.pop("xticklabels", None) yticklabels = kwargs.pop("yticklabels", None) projection = kwargs.pop("projection", None) - if projection not in (None, "rectilinear"): - raise not_implemented(f"projection={projection!r} axes", "2-D rectilinear charts") + if projection is not None: + # Pre-polar this raised NotImplementedError for every non-default + # value, which left `plt.subplot(111, projection="polar")` on an + # already-claimed slot rejecting a now-supported idiom. + # _set_projection accepts 'polar'/'rectilinear' and stays loud + # (ValueError) for anything else. + self._set_projection(projection) unknown: list[str] = [] for name, value in kwargs.items(): setter = aliases.get(name) @@ -3885,6 +3932,12 @@ def set_xlim(self, left: float | LimitsLike | None = None, right: float | None = def get_xlim(self) -> tuple[float, float]: """The current x view limits, in data space and display order.""" host = (self._y2_of or self)._shared_ticker_source("x") + if self._projection == "polar" and not self._has_explicit_shared_domain("x"): + # Polar theta defaults to one complete turn, independently of the + # data's angular extent. This is also the single source read by the + # theta-limit setters/getters below; automatic Cartesian x padding + # must never become an authored sector. + return (0.0, 2.0 * math.pi) lo, hi = host._axis["x"].get("domain", self._auto_domain("x")) lo, hi = map( float, @@ -4464,6 +4517,15 @@ def _auto_domain(self, axis: str) -> tuple[float, float]: spec = host._scale_specs[key] if self._axis_is_dataless(axis): return (1.0, 10.0) if spec["name"] == "log" else (0.0, 1.0) + if axis == "y" and self._projection == "polar" and spec["name"] != "log": + # The radial preview must match the engine's polar autorange + # (centre origin, no outer pad — _figure._range), or every + # rlim call snapshots cartesian-padded values: set_rmax(2.0) + # froze [0.85, 2.0] where matplotlib gives [0, 2]. + lo, hi = self._entry_extent(axis) + lo = min(0.0, float(lo)) + hi = float(hi) if hi > lo else lo + 1.0 + return lo, hi if spec["name"] == "log": # The core consumes log domains in the original positive data # space, while Matplotlib applies margins after transforming to @@ -5666,6 +5728,196 @@ def add_image(self, image: Any) -> AxesImage: interpolation="nearest", ) + # -- polar projection (matplotlib PolarAxes surface) -------------------- + + def _set_projection(self, projection: Any) -> None: + """Back `subplot(projection=...)`; only 'polar' changes anything.""" + name = "cartesian" if projection in (None, "rectilinear") else str(projection) + if name not in ("cartesian", "polar"): + raise ValueError( + f"projection {projection!r} is not supported; use 'polar' or 'rectilinear'" + ) + self._projection = name + if name == "polar": + # Matplotlib's PolarAxes defaults: theta=0 due east, increasing + # counterclockwise, angles in radians. + self._polar_options.setdefault("theta_unit", "radians") + self._polar_options.setdefault("theta_zero", "E") + self._polar_options.setdefault("theta_direction", "counterclockwise") + + def _require_polar(self, method: str) -> None: + if self._projection != "polar": + raise AttributeError( + f"{method} is only available on a polar axes; " + "create one with subplot(projection='polar')" + ) + + def set_theta_zero_location(self, loc: str, offset: float = 0.0) -> None: + """Direction that theta=0 points: 'N', 'NW', 'W', 'SW', 'S', 'SE', 'E' + or 'NE', plus an optional offset in degrees.""" + self._require_polar("set_theta_zero_location") + compass = { + "E": 0.0, + "NE": 45.0, + "N": 90.0, + "NW": 135.0, + "W": 180.0, + "SW": 225.0, + "S": 270.0, + "SE": 315.0, + } + key = str(loc).upper() + if key not in compass: + raise ValueError(f"theta zero location must be one of {sorted(compass)}") + degrees = compass[key] + float(offset) + # The four cardinals keep their letter so the wire stays readable and + # one shared table resolves them; anything else ships as radians. + letters = {0.0: "E", 90.0: "N", 180.0: "W", 270.0: "S"} + normalized = degrees % 360.0 + self._polar_options["theta_zero"] = letters.get(normalized, math.radians(degrees)) + + def set_theta_direction(self, direction: Any) -> None: + """1/'counterclockwise'/'anticlockwise' or -1/'clockwise'.""" + self._require_polar("set_theta_direction") + clockwise = {-1, "clockwise", "cw"} + counter = {1, "counterclockwise", "anticlockwise", "ccw"} + key = direction if isinstance(direction, int) else str(direction).lower() + if key in clockwise: + self._polar_options["theta_direction"] = "clockwise" + elif key in counter: + self._polar_options["theta_direction"] = "counterclockwise" + else: + raise ValueError("theta direction must be 1/-1 or 'clockwise'/'counterclockwise'") + + def set_theta_offset(self, offset: float) -> None: + """Rotation of the theta=0 direction, in radians.""" + self._require_polar("set_theta_offset") + self._polar_options["theta_zero"] = float(offset) + + def get_theta_offset(self) -> float: + self._require_polar("get_theta_offset") + zero = self._polar_options.get("theta_zero", "E") + # Matplotlib's mapping is 0..2pi ccw from east, so "S" reads 3*pi/2 — + # not the -pi/2 the render tables use. Same angle, but a compat getter + # has to return matplotlib's number: `get_theta_offset() > 0` and + # round-trips through `set_theta_offset` both break on the negative. + table = {"E": 0.0, "N": math.pi / 2, "W": math.pi, "S": 3 * math.pi / 2} + return table[zero] if isinstance(zero, str) else float(zero) % (2 * math.pi) + + def get_theta_direction(self) -> int: + self._require_polar("get_theta_direction") + return -1 if self._polar_options.get("theta_direction") == "clockwise" else 1 + + def set_rlim(self, bottom: Any = None, top: Any = None, **kwargs: Any) -> Any: + """Radial limits — the polar spelling of `set_ylim`. + + Accepts matplotlib's documented ``rmin``/``rmax`` keywords; anything + else is refused by name rather than forwarded to `set_ylim`, whose + signature does not know them. + """ + self._require_polar("set_rlim") + if "rmin" in kwargs: + if bottom is not None: + raise ValueError("set_rlim: pass either bottom or rmin, not both") + bottom = kwargs.pop("rmin") + if "rmax" in kwargs: + if top is not None: + raise ValueError("set_rlim: pass either top or rmax, not both") + top = kwargs.pop("rmax") + if kwargs: + raise TypeError(f"set_rlim got unexpected keyword(s) {sorted(kwargs)}") + return self.set_ylim(bottom, top) + + def set_rmin(self, rmin: float) -> None: + self._require_polar("set_rmin") + self.set_ylim(float(rmin), self.get_ylim()[1]) + + def set_rmax(self, rmax: float) -> None: + self._require_polar("set_rmax") + self.set_ylim(self.get_ylim()[0], float(rmax)) + + def get_rmin(self) -> float: + self._require_polar("get_rmin") + return float(self.get_ylim()[0]) + + def get_rmax(self) -> float: + self._require_polar("get_rmax") + return float(self.get_ylim()[1]) + + def set_rticks(self, ticks: Any, labels: Any = None, **kwargs: Any) -> Any: + self._require_polar("set_rticks") + return self.set_yticks(ticks, labels, **kwargs) + + def set_rgrids(self, radii: Any, labels: Any = None, **kwargs: Any) -> Any: + """Radial gridline positions — matplotlib's `set_rgrids`.""" + self._require_polar("set_rgrids") + return self.set_yticks(list(radii), labels, **kwargs) + + def set_thetagrids(self, angles: Any, labels: Any = None, **kwargs: Any) -> Any: + """Angular gridline positions, in DEGREES. + + Matplotlib takes degrees here regardless of the data's unit, which is + the one place its polar API is not unit-consistent; matching it matters + more than being tidy. + """ + values = [float(a) for a in angles] + self._require_polar("set_thetagrids") + if self._polar_options.get("theta_unit", "radians") == "radians": + values = [math.radians(a) for a in values] + return self.set_xticks(values, labels, **kwargs) + + def set_thetamin(self, thetamin: float) -> None: + self._require_polar("set_thetamin") + value = float(thetamin) + if not math.isfinite(value): + raise ValueError("thetamin must be finite") + _lo, hi = sorted(self.get_xlim()) + lo = math.radians(value) + if lo >= hi: + raise ValueError("thetamin must be less than thetamax") + self.set_xlim(lo, hi) + + def set_thetamax(self, thetamax: float) -> None: + self._require_polar("set_thetamax") + value = float(thetamax) + if not math.isfinite(value): + raise ValueError("thetamax must be finite") + lo, _hi = sorted(self.get_xlim()) + hi = math.radians(value) + if hi <= lo: + raise ValueError("thetamax must be greater than thetamin") + self.set_xlim(lo, hi) + + def get_thetamin(self) -> float: + """Minimum visible theta in degrees, matching Matplotlib PolarAxes.""" + self._require_polar("get_thetamin") + lo, _hi = sorted(self.get_xlim()) + return math.degrees(float(lo)) + + def get_thetamax(self) -> float: + """Maximum visible theta in degrees, matching Matplotlib PolarAxes.""" + self._require_polar("get_thetamax") + _lo, hi = sorted(self.get_xlim()) + return math.degrees(float(hi)) + + def set_rorigin(self, origin: Optional[float]) -> None: + """Set the data-space radial origin; ``None`` restores rmin.""" + self._require_polar("set_rorigin") + if origin is None: + self._polar_r_options.pop("r_origin", None) + else: + value = float(origin) + if not math.isfinite(value): + raise ValueError("rorigin must be finite") + self._polar_r_options["r_origin"] = value + self._invalidate() + + def get_rorigin(self) -> float: + """Return the authored radial origin, or the current radial minimum.""" + self._require_polar("get_rorigin") + value = self._polar_r_options.get("r_origin") + return self.get_rmin() if value is None else float(value) + def set_xscale(self, scale: str, **kwargs: Any) -> None: """Set the x-axis scale. @@ -8063,6 +8315,23 @@ def _build_chart_uncached(self, width: int, height: int) -> Any: self._apply_tickers("x", x_props, auto_tick_counts["x"]) self._apply_tickers("y", y_props, auto_tick_counts["y"]) self._apply_auto_tick_density(x_props, y_props, auto_tick_counts) + if self._projection == "polar": + # A polar angular view defaults to one complete turn. Cartesian + # autoscaling above can materialize a padded x domain (bars are a + # common trigger), but that internal domain is not an authored + # theta limit and must not become a sector. set_xlim() and + # set_thetamin/max share the explicit-domain state, so preserve it + # only when either public spelling has actually authored it. + if not self._has_explicit_shared_domain("x"): + x_props.pop("domain", None) + x_props.pop("margin", None) + # The angular descriptors are axis properties, so route them + # through the authored x-axis component with its ticks, labels and + # explicit limits intact. Calling Figure.set_axis() after chart + # construction replaces unspecified fields with defaults and used + # to erase set_thetagrids()/set_xticks() from polar axes. + x_props.update(self._polar_options) + y_props.update(self._polar_r_options) compact = width < 520 if chart_padding is None: top, right, bottom, left = ( @@ -8182,6 +8451,7 @@ def _build_chart_uncached(self, width: int, height: int) -> Any: height=height, padding=chart_padding, styles=chrome_styles, + coords=self._projection, ) core_figure = self._chart.figure() core_figure.title_options = [ diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 9e1c2a4b..44db5fc9 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -391,6 +391,11 @@ def add_subplot(self, *args: Any, **kwargs: Any) -> Axes: self._ensure_grid(nrows, ncols) ax = self._claim_or_create_subplot(subplot_key, index - 1) self._current_ax = ax # matplotlib: add_subplot activates the axes + projection = kwargs.pop("projection", None) + if kwargs.pop("polar", False): + projection = "polar" + if projection is not None: + ax._set_projection(projection) sharex = kwargs.pop("sharex", None) sharey = kwargs.pop("sharey", None) self._share_subplot_axes(ax, sharex=sharex, sharey=sharey) @@ -441,6 +446,14 @@ def activate_subplot(self, *args: Any, **kwargs: Any) -> Axes: if existing is None: return self.add_subplot(*args, **kwargs) self._current_ax = existing + projection = kwargs.pop("projection", None) + if kwargs.pop("polar", False): + projection = "polar" + if projection is not None: + # Same handling as add_subplot: re-requesting a claimed slot with + # a projection reconfigures it rather than bouncing off the + # generic Axes.set path. + existing._set_projection(projection) sharex = kwargs.pop("sharex", None) sharey = kwargs.pop("sharey", None) self._share_subplot_axes(existing, sharex=sharex, sharey=sharey) @@ -456,6 +469,11 @@ def add_axes(self, rect: Any, **kwargs: Any) -> Axes: self._axes.append(ax) ax._figure_rect = parsed self._current_ax = ax + projection = kwargs.pop("projection", None) + if kwargs.pop("polar", False): + projection = "polar" + if projection is not None: + ax._set_projection(projection) if kwargs: ax.set(**kwargs) return ax @@ -511,6 +529,7 @@ def subplots( This mirrors the axes-returning half of ``matplotlib.figure.Figure.subplots``. Figure creation and pyplot registration belong to the state module. """ + subplot_kw = dict(kwargs.pop("subplot_kw", None) or {}) del kwargs grid_options = dict(gridspec_kw or {}) width_ratios = grid_options.pop("width_ratios", width_ratios) @@ -532,6 +551,15 @@ def subplots( spec = _SubplotSpec(grid, (row, row + 1), (col, col + 1)) ax._subplot_spec = spec ax._figure_rect = grid.cell_rect(spec.rows, spec.cols) + if subplot_kw: + projection = subplot_kw.pop("projection", None) + if subplot_kw.pop("polar", False): + projection = "polar" + for ax in np.asarray(axes, dtype=object).reshape(-1): + if projection is not None: + ax._set_projection(projection) + if subplot_kw: + ax.set(**subplot_kw) apply_sharing(self, _share_mode(sharex, "sharex"), _share_mode(sharey, "sharey")) self._hide_inner_tick_labels(int(nrows), int(ncols)) self._invalidate() diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 46cd975d..3b9be46b 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -1653,19 +1653,48 @@ def fill(self, *args: Any, data: TableLike = None, **kwargs: Any) -> list[PolyCo xv, yv = xv[finite], yv[finite] if len(xv) > 2 and np.allclose((xv[0], yv[0]), (xv[-1], yv[-1])): xv, yv = xv[:-1], yv[:-1] - topology = kernels.polygon_triangles(xv, yv) - x0, y0, x1, y1, x2, y2, _ = kernels.indexed_triangles(xv, yv, topology) chosen = facecolor if chosen is None and positional_color is not None: chosen = positional_color + resolved_color = ( + resolve_color(chosen) if chosen is not None else self._next_patch_color() + ) mark_kwargs: dict[str, Any] = { - "color": ( - resolve_color(chosen) if chosen is not None else self._next_patch_color() - ), + "color": resolved_color, "name": None if label is None else str(label), "opacity": 1.0 if alpha is None else float(alpha), "_joined_fill": True, } + if getattr(self, "_projection", "cartesian") == "polar": + if len(xv) < 3: + raise ValueError("fill() polygons require at least three finite points") + entry = self._add( + "area", + { + "x": xv, + "y": yv, + "kwargs": { + "base": 0.0, + "color": resolved_color, + "opacity": 1.0 if alpha is None else float(alpha), + "line_color": ( + resolve_color(edgecolor) if edgecolor is not None else None + ), + "line_width": 1.0 if linewidth is None else float(linewidth), + "line_opacity": ( + (1.0 if alpha is None else float(alpha)) + if edgecolor is not None + else 0.0 + ), + "stroke_perimeter": edgecolor is not None, + "name": None if label is None else str(label), + }, + }, + ) + result.append(PolyCollection(self, entry)) + continue + topology = kernels.polygon_triangles(xv, yv) + x0, y0, x1, y1, x2, y2, _ = kernels.indexed_triangles(xv, yv, topology) entry = self._add( "@mark", { diff --git a/python/xy/styles.py b/python/xy/styles.py index f4e29dee..dd153cca 100644 --- a/python/xy/styles.py +++ b/python/xy/styles.py @@ -276,6 +276,7 @@ def _supported_mark_style_properties(kind: str) -> tuple[str, ...]: "stroke-width", "stroke-opacity", "border-radius", + "wedge-gap", } elif kind in _FILL_KINDS: props |= {"fill", "fill-opacity"} @@ -365,6 +366,12 @@ def _compile_mark_style(kind: str, value: StyleMapping | None, label: str) -> di _set(out, "symbol", _validate.point_symbol(raw, f"{label}['marker-shape']"), prop, seen) elif prop == "border-radius": _set(out, "corner_radius", _px(raw, f"{label}['border-radius']"), prop, seen) + elif prop == "wedge-gap": + # Gap between neighbouring polar wedges, in px. Deliberately a + # LENGTH and not an angle: an angular pad's gap is `r · dtheta` + # wide, so it tapers to nothing at the hole (see + # `_wedge_edge_inset` in _svg.py). Ignored outside coords="polar". + _set(out, "wedge_gap", _px(raw, f"{label}['wedge-gap']"), prop, seen) return out diff --git a/python/xy/styling/capabilities.py b/python/xy/styling/capabilities.py index 252bf12c..205c04e2 100644 --- a/python/xy/styling/capabilities.py +++ b/python/xy/styling/capabilities.py @@ -196,6 +196,22 @@ class ExtensionPoint: status="shipped", notes="Rect kinds only. `corner_radius=(tip, base)` rounds the two ends separately.", ), + MarkStyleProperty( + id="wedge-gap", + vocabulary="xy", + compiles_to="wedge_gap", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes=( + "Gap between neighbouring polar wedges, in px. Rect kinds under " + '`coords="polar"` only; ignored elsewhere. Deliberately a LENGTH ' + "rather than an angle: an angular pad's seam is `r * dtheta` wide, " + "so it tapers to nothing at the hole and reads as uneven spacing. " + "The angular inset therefore grows as the radius shrinks, which is " + "the same construction as d3's padAngle/padRadius pair. An XY " + "vocabulary name: CSS has no gap between two arcs." + ), + ), MarkStyleProperty( id="marker-shape", vocabulary="xy", diff --git a/scripts/check_public_api.py b/scripts/check_public_api.py index d359aea4..4684973c 100644 --- a/scripts/check_public_api.py +++ b/scripts/check_public_api.py @@ -71,7 +71,7 @@ "y_band", "text", ) -DECLARATIVE_AXIS_EXPORTS = ("x_axis", "y_axis") +DECLARATIVE_AXIS_EXPORTS = ("x_axis", "y_axis", "theta_axis", "r_axis") DECLARATIVE_CHROME_EXPORTS = ( "legend", "tooltip", @@ -83,6 +83,11 @@ DECLARATIVE_CHART_EXPORTS = ( "chart", "scatter_chart", + "polar_chart", + "radar_chart", + "polar_bar_chart", + "pie_chart", + "wind_rose", "segments_chart", "triangle_mesh_chart", "line_chart", diff --git a/scripts/polar_parity_smoke.py b/scripts/polar_parity_smoke.py new file mode 100644 index 00000000..27da4de6 --- /dev/null +++ b/scripts/polar_parity_smoke.py @@ -0,0 +1,250 @@ +"""Bind the GLSL polar transform to the shared fixtures — the client half of +the contract in spec/design/polar-axes.md §4. + +`tests/test_polar_transform.py` binds `_svg._PolarProjection` (and through it +both static exporters) to `tests/fixtures/polar_transform.json`. This probe +binds the OTHER implementation — the real `xyPolarPos` in the shipped bundle, +not a JS mirror of it — by rendering one scatter point per fixture sample in a +unique solid colour, reading the pixels back, and comparing each colour's +centroid against the fixture value. + +The fixture stores pixel positions for its own authored plot rect, while the +client computes a rect of its own; the two are reconciled without a third copy +of the transform because a fixture point's offset from the centre, in units of +the disc radius, is rect-independent: `(px - cx, py - cy) / R` depends only on +the axis config and (theta, r). The probe rescales that unit-disc offset to the +runtime canvas. + +Points are placed at 60% of each fixture radius so no sprite clips at the +canvas edge (a half-clipped disc's centroid shifts inward, which would read as +a transform error). Under a linear radial scale the unit-disc offset scales +exactly with the normalized radius, so the expected position stays exact. + +Stdlib-only, like the other smoke probes: runs in the no-PyPI CI lane. +""" + +from __future__ import annotations + +import base64 +import json +import math +import re +import subprocess +import sys +import tempfile +from array import array +from pathlib import Path + +from _protocol import PROTOCOL_VERSION + +ROOT = Path(__file__).resolve().parent.parent +STATIC = ROOT / "python" / "xy" / "static" +FIXTURES = ROOT / "tests" / "fixtures" / "polar_transform.json" +CHROMIUM_CANDIDATES = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/opt/pw-browsers/chromium", + "chromium", + "chromium-browser", + "google-chrome", +] + +# One saturated, unambiguous colour per fixture point (max 6 points per case). +COLORS = ["#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff"] + +# Test radius fraction (see module docstring) and centroid tolerance in px. +# The sprite is an antialiased disc, so its lit-pixel centroid sits within a +# fraction of a pixel of its centre; 1.5 px absorbs that plus f32 encoding. +RADIUS_FRACTION = 0.6 +TOLERANCE_PX = 1.5 + + +def find_chromium() -> str: + import shutil + + if len(sys.argv) > 1: + return sys.argv[1] + for c in CHROMIUM_CANDIDATES: + if Path(c).is_file() or shutil.which(c): + return c + raise SystemExit("no chromium found") + + +def build_case_payload(case: dict) -> tuple[dict, bytes, list[dict]]: + """One spec per fixture case: one single-point scatter trace per sample.""" + cfg = case["config"] + r_lo, r_hi = cfg["r_range"] + span = (r_hi - r_lo) or 1.0 + turn = 360.0 if cfg["unit"] == "degrees" else 2.0 * math.pi + + cols: list[dict] = [] + blob = bytearray() + + def ship(value: float) -> int: + cols.append( + {"byte_offset": len(blob), "len": 1, "offset": 0.0, "scale": 1.0, "kind": "float"} + ) + blob.extend(array("f", [float(value)]).tobytes()) + return len(cols) - 1 + + traces = [] + expected = [] + fx_plot = case["plot"] + fx_cx = fx_plot["x"] + fx_plot["w"] / 2.0 + fx_cy = fx_plot["y"] + fx_plot["h"] / 2.0 + fx_radius = min(fx_plot["w"], fx_plot["h"]) / 2.0 + for i, point in enumerate(case["points"]): + rn = (point["r"] - r_lo) / span + test_r = r_lo + span * rn * RADIUS_FRACTION + # Unit-disc offset of the fixture point, scaled to the test radius. + ux = (point["px"] - fx_cx) / fx_radius * RADIUS_FRACTION + uy = (point["py"] - fx_cy) / fx_radius * RADIUS_FRACTION + expected.append({"ux": ux, "uy": uy, "color": COLORS[i]}) + traces.append( + { + "id": i, + "kind": "scatter", + "name": f"p{i}", + "tier": "direct", + "n_points": 1, + # Scatter colour rides the channel dict — the client reads + # `t.color.color`, and `style.color` is the legend swatch. + "color": {"color": COLORS[i]}, + "size": {"size": 11.0}, + "style": {"color": COLORS[i], "size": 11.0, "opacity": 1.0}, + "x": ship(point["theta"]), + "y": ship(test_r), + } + ) + + spec = { + "protocol": PROTOCOL_VERSION, + "width": 420, + "height": 380, + "coords": "polar", + "x_axis": { + "kind": "linear", + "range": [0.0, turn], + "theta_unit": cfg["unit"], + "theta_zero": cfg["zero"], + "theta_direction": cfg["direction"], + }, + "y_axis": {"kind": "linear", "range": [r_lo, r_hi]}, + "traces": traces, + "columns": cols, + "backend": "none", + } + spec["axes"] = {"x": spec["x_axis"], "y": spec["y_axis"]} + return spec, bytes(blob), expected + + +PROBE_JS = """ +setTimeout(()=>{try{ + v._drawNow(); + const gl=v.gl,w=gl.drawingBufferWidth,h=gl.drawingBufferHeight; + const px=new Uint8Array(w*h*4); + gl.readPixels(0,0,w,h,gl.RGBA,gl.UNSIGNED_BYTE,px); + const targets=TARGETS; + const sums=targets.map(()=>({x:0,y:0,n:0})); + for(let y=0;ys.n?[s.x/s.n/dpr,(h-1-s.y/s.n)/dpr,s.n]:[NaN,NaN,0]); + document.title="XY_OK plot="+v.plot.w+"x"+v.plot.h+" pts="+JSON.stringify(out); +}catch(e){document.title="XY_ERROR "+(e.stack||e.message)}},200); +""" + + +def run_case(chromium: str, standalone: str, case: dict) -> list[str]: + spec, blob, expected = build_case_payload(case) + targets = [ + [int(e["color"][1:3], 16), int(e["color"][3:5], 16), int(e["color"][5:7], 16)] + for e in expected + ] + page = f"""pending +
+ +""" + + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "polar_parity.html" + p.write_text(page, encoding="utf-8") + out = subprocess.run( + [ + chromium, + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + "--virtual-time-budget=4000", + "--dump-dom", + p.as_uri(), + ], + capture_output=True, + text=True, + timeout=120, + ) + m = re.search(r"([^<]*)", out.stdout) + title = m.group(1) if m else "(none)" + if not title.startswith("XY_OK"): + print(out.stderr[-2000:], file=sys.stderr) + raise SystemExit(f"{case['name']}: render failed: {title[:400]}") + + plot_w, plot_h = map(float, re.search(r"plot=([\d.]+)x([\d.]+)", title).groups()) + observed = json.loads(re.search(r"pts=(\[.*\])", title).group(1)) + radius = min(plot_w, plot_h) / 2.0 + cx, cy = plot_w / 2.0, plot_h / 2.0 + + failures = [] + for e, (ox, oy, n) in zip(expected, observed, strict=True): + if not n: + failures.append(f"{case['name']}: no pixels found for {e['color']}") + continue + want_x = cx + e["ux"] * radius + want_y = cy + e["uy"] * radius + dx, dy = ox - want_x, oy - want_y + if math.hypot(dx, dy) > TOLERANCE_PX: + failures.append( + f"{case['name']}: {e['color']} at ({ox:.2f},{oy:.2f}) " + f"expected ({want_x:.2f},{want_y:.2f}) — off by {math.hypot(dx, dy):.2f}px" + ) + return failures + + +def main() -> None: + cases = json.loads(FIXTURES.read_text(encoding="utf-8"))["cases"] + standalone = (STATIC / "standalone.js").read_text(encoding="utf-8") + chromium = find_chromium() + failures: list[str] = [] + for case in cases: + case_failures = run_case(chromium, standalone, case) + status = "ok" if not case_failures else f"{len(case_failures)} FAILED" + print(f" {case['name']:<24} {len(case['points'])} points {status}") + failures.extend(case_failures) + if failures: + print() + for f in failures: + print("FAIL:", f) + raise SystemExit(f"{len(failures)} polar parity failures") + print("polar GLSL parity: all fixture cases match") + + +if __name__ == "__main__": + main() diff --git a/scripts/polar_phase7_smoke.py b/scripts/polar_phase7_smoke.py new file mode 100644 index 00000000..6bb3023b --- /dev/null +++ b/scripts/polar_phase7_smoke.py @@ -0,0 +1,662 @@ +"""Real-Figure smoke coverage for the phase 6/7 polar feature set. + +Six user-facing compositions travel through the normal Python payload +builder, the shipped standalone client in headless Chromium, and both native +static exporters: + +* polar heatmap with contour isolines; +* a partial sector with a hole and angular/radial error bars; +* categorical theta with a logarithmic radius and polygonal grid; +* a symlog radius with a data-space origin below the visible range; +* bars, line, and scatter composed over one logarithmic annulus; +* a descending radial bar over a log-r data-space-origin annulus. + +Pass ``--artifacts DIR`` to retain each live HTML page plus browser, SVG, and +PNG renders for visual inspection. +""" + +from __future__ import annotations + +import argparse +import base64 +import html +import io +import json +import math +import re +import shutil +import subprocess +import tempfile +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np +from PIL import Image + +import xy +from xy.export import _bundled_js, _javascript_for_inline_script + +CHROMIUM_CANDIDATES = ( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/opt/pw-browsers/chromium", + "chromium", + "chromium-browser", + "google-chrome", +) + + +@dataclass(frozen=True) +class Case: + name: str + chart: xy.Chart + marks: frozenset[str] + min_live_pixels: int + grid_shape: str + sector: tuple[float, float] | None = None + hole: float = 0.0 + origin: float | None = None + log_radius: bool = False + symlog_radius: bool = False + inner_fraction: float | None = None + labels: tuple[str, ...] = () + probe: tuple[float, float] = (0.0, 1.0) + probe_kind: str = "line" + gl_colors: tuple[tuple[str, int], ...] = () + chrome_colors: tuple[tuple[str, int], ...] = () + static_colors: tuple[tuple[str, int], ...] = () + + +def _cases() -> list[Case]: + theta = np.linspace(0.0, 360.0, 24, endpoint=False) + radius = np.geomspace(1.0, 100.0, 8) + theta_rad = np.deg2rad(theta) + surface = np.array( + [ + [math.sin(3.0 * angle) + math.cos(1.7 * math.log(r)) for angle in theta_rad] + for r in radius + ] + ) + heatmap_contour = xy.polar_chart( + xy.heatmap(surface, x=theta, y=radius, name="surface"), + xy.contour( + surface, + x=theta, + y=radius, + levels=6, + color="#ff00ff", + width=2.0, + name="isolines", + ), + xy.theta_axis(unit="degrees"), + xy.r_axis(type_="log", domain=(1.0, 100.0)), + width=520, + height=520, + title="Polar heatmap + contour", + ) + + error_theta = [-90.0, -45.0, 0.0, 45.0, 90.0] + error_radius = [2.0, 3.0, 2.5, 4.0, 3.2] + sector_hole_errors = xy.polar_chart( + xy.errorbar( + error_theta, + error_radius, + yerr=[0.3, 0.5, 0.4, 0.6, 0.3], + xerr=[8.0, 6.0, 10.0, 7.0, 8.0], + color="#dc2626", + width=3.0, + cap_size=10.0, + name="uncertainty", + ), + xy.scatter(error_theta, error_radius, color="#111827", size=8.0), + xy.theta_axis( + unit="degrees", + sector=(-110.0, 110.0), + zero="N", + direction="clockwise", + ), + xy.r_axis(domain=(0.0, 5.0), hole=0.35), + width=640, + height=420, + title="Sector + hole + polar error bars", + ) + + categories = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] + values = [1.0, 5.0, 25.0, 120.0, 600.0, 180.0, 40.0, 7.0] + categorical_log_polygon = xy.polar_chart( + xy.line( + categories + [categories[0]], + values + [values[0]], + color="#2563eb", + width=3.0, + name="range", + ), + xy.scatter(categories, values, color="#f59e0b", size=9.0), + xy.theta_axis( + grid_shape="linear", + zero="N", + direction="clockwise", + style={"grid_color": "#10b981"}, + ), + xy.r_axis( + type_="log", + domain=(1.0, 1000.0), + style={"grid_color": "#10b981"}, + ), + width=520, + height=520, + title="Categorical theta + log r + polygon grid", + ) + + symlog_origin = xy.polar_chart( + # The opposite outer points deliberately create a diameter chord. The + # annular clip must retain only its two visible end segments instead of + # painting through the data-space-origin hole. + xy.line( + [0.0, 180.0], + [100.0, 100.0], + color="#7c3aed", + width=5.0, + name="clipped diameter", + ), + xy.scatter([45.0], [0.0], color="#0f766e", size=12.0, name="symlog zero"), + xy.theta_axis(unit="degrees"), + xy.r_axis( + type_="symlog", + constant=1.0, + domain=(-10.0, 100.0), + origin=-100.0, + tick_values=[-10.0, 0.0, 10.0, 100.0], + ), + width=520, + height=520, + title="Symlog r + data-space radial origin", + ) + + composed_wedges = xy.polar_chart( + xy.bar( + [0.0, 90.0, 180.0, 270.0], + [100.0, 35.0, 70.0, 20.0], + base=1.0, + width=70.0, + color="#0284c7", + opacity=1.0, + animation=False, + name="range", + ), + xy.line( + [0.0, 90.0, 180.0, 270.0, 360.0], + [100.0, 35.0, 70.0, 20.0, 100.0], + color="#f97316", + width=2.0, + name="outline", + ), + xy.scatter( + [0.0, 90.0, 180.0, 270.0], + [100.0, 35.0, 70.0, 20.0], + color="#111827", + size=7.0, + ), + xy.theta_axis(unit="degrees", zero="N", direction="clockwise"), + xy.r_axis(type_="log", domain=(1.0, 100.0), hole=0.28), + width=520, + height=520, + title="Composed polar bars + line + scatter", + ) + + origin_descending_wedge = xy.polar_chart( + # Descending endpoint order exercises the other annular-strip + # orientation. On this log axis the visible minimum sits at exactly + # one third of the disc because r_origin=1 is one decade below it. + xy.bar( + [0.0], + [-990.0], + base=1000.0, + width=90.0, + color="#a855f7", + opacity=1.0, + animation=False, + name="descending annular bar", + ), + xy.theta_axis(unit="degrees"), + xy.r_axis(type_="log", domain=(10.0, 1000.0), origin=1.0), + width=520, + height=520, + title="Descending bar + log-r origin", + ) + + return [ + Case( + "heatmap_contour", + heatmap_contour, + frozenset({"heatmap", "contour"}), + 10_000, + "circular", + log_radius=True, + probe=(90.0, 10.0), + probe_kind="heatmap", + gl_colors=(("#ff00ff", 30),), + static_colors=(("#ff00ff", 30),), + ), + Case( + "sector_hole_errorbars", + sector_hole_errors, + frozenset({"errorbar", "scatter"}), + 100, + "circular", + sector=(-110.0, 110.0), + hole=0.35, + probe=(0.0, 2.5), + probe_kind="errorbar", + gl_colors=(("#dc2626", 30),), + static_colors=(("#dc2626", 30),), + ), + Case( + "categorical_log_polygon", + categorical_log_polygon, + frozenset({"line", "scatter"}), + 100, + "linear", + log_radius=True, + labels=("N", "NE", "E", "SE", "S", "SW", "W", "NW"), + probe=(2.0, 25.0), + probe_kind="line", + chrome_colors=(("#10b981", 100),), + static_colors=(("#10b981", 100),), + ), + Case( + "symlog_origin", + symlog_origin, + frozenset({"line", "scatter"}), + 20, + "circular", + origin=-100.0, + symlog_radius=True, + probe=(45.0, 0.0), + probe_kind="scatter", + gl_colors=(("#7c3aed", 10), ("#0f766e", 10)), + static_colors=(("#7c3aed", 10), ("#0f766e", 10)), + ), + Case( + "composed_wedges", + composed_wedges, + frozenset({"bar", "line", "scatter"}), + 1_000, + "circular", + hole=0.28, + log_radius=True, + probe=(0.0, 10.0), + probe_kind="bar", + gl_colors=(("#0284c7", 500), ("#f97316", 20)), + static_colors=(("#0284c7", 500), ("#f97316", 20)), + ), + Case( + "origin_descending_wedge", + origin_descending_wedge, + frozenset({"bar"}), + 1_000, + "circular", + origin=1.0, + log_radius=True, + inner_fraction=1.0 / 3.0, + probe=(0.0, 100.0), + probe_kind="bar", + gl_colors=(("#a855f7", 500),), + static_colors=(("#a855f7", 500),), + ), + ] + + +def _find_chromium(authored: str | None) -> str: + if authored: + return authored + for candidate in CHROMIUM_CANDIDATES: + if Path(candidate).is_file() or shutil.which(candidate): + return candidate + raise SystemExit("no Chromium binary found; pass one as the first argument") + + +def _rgb_targets(targets: tuple[tuple[str, int], ...]) -> list[dict[str, Any]]: + encoded: list[dict[str, Any]] = [] + for color, minimum in targets: + value = color.removeprefix("#") + if len(value) != 6: + raise AssertionError(f"smoke target must be a six-digit hex color, got {color!r}") + encoded.append( + { + "name": color.lower(), + "rgb": [int(value[offset : offset + 2], 16) for offset in (0, 2, 4)], + "min": minimum, + } + ) + return encoded + + +def _page( + spec: dict[str, Any], + blob: bytes, + probe: tuple[float, float], + gl_colors: tuple[tuple[str, int], ...], + chrome_colors: tuple[tuple[str, int], ...], +) -> str: + client = _javascript_for_inline_script(_bundled_js("standalone")) + blob64 = base64.b64encode(blob).decode("ascii") + return f""" +pending + +
+ +""" + + +def _validate_spec(case: Case, spec: dict[str, Any]) -> None: + kinds = {trace["kind"] for trace in spec["traces"]} + missing = case.marks - kinds + if missing: + raise AssertionError(f"{case.name}: payload lost trace kinds {sorted(missing)}") + x_axis = spec["x_axis"] + y_axis = spec["y_axis"] + if x_axis["grid_shape"] != case.grid_shape: + raise AssertionError(f"{case.name}: grid shape did not reach the wire") + if case.sector is not None and tuple(x_axis["sector"]) != case.sector: + raise AssertionError(f"{case.name}: sector did not reach the wire") + if not math.isclose(float(y_axis["hole"]), case.hole): + raise AssertionError(f"{case.name}: hole did not reach the wire") + if case.log_radius and y_axis.get("scale") != "log": + raise AssertionError(f"{case.name}: logarithmic radial scale did not reach the wire") + if case.symlog_radius and y_axis.get("scale") != "symlog": + raise AssertionError(f"{case.name}: symlog radial scale did not reach the wire") + if case.origin is None: + if "r_origin" in y_axis: + raise AssertionError(f"{case.name}: payload invented a radial origin") + elif not math.isclose(float(y_axis.get("r_origin", math.nan)), case.origin): + raise AssertionError(f"{case.name}: radial origin did not reach the wire") + if case.labels and x_axis.get("kind") != "category": + raise AssertionError(f"{case.name}: categorical theta was not encoded as categories") + + +def _validate_live(case: Case, metrics: dict[str, Any]) -> None: + if metrics["lit"] < case.min_live_pixels: + raise AssertionError( + f"{case.name}: only {metrics['lit']} live WebGL pixels " + f"(expected at least {case.min_live_pixels}); " + f"gpu={metrics.get('gpuTraces')}, glError={metrics.get('glError')}" + ) + if metrics["bounds"][2] < metrics["bounds"][0] or metrics["bounds"][3] < metrics["bounds"][1]: + raise AssertionError(f"{case.name}: live WebGL output has no finite pixel bounds") + if metrics["gridShape"] != case.grid_shape: + raise AssertionError(f"{case.name}: client geometry lost grid_shape") + if case.origin is None: + if not math.isclose(float(metrics["hole"]), case.hole, abs_tol=1e-9): + raise AssertionError(f"{case.name}: client geometry lost the authored hole") + if not math.isclose(float(metrics["innerFraction"]), case.hole, abs_tol=1e-9): + raise AssertionError(f"{case.name}: client visible inner radius lost the authored hole") + elif not 0.0 < float(metrics["innerFraction"]) < 1.0: + raise AssertionError(f"{case.name}: radial origin did not create a live annulus") + if case.inner_fraction is not None and not math.isclose( + float(metrics["innerFraction"]), case.inner_fraction, rel_tol=1e-9, abs_tol=1e-9 + ): + raise AssertionError( + f"{case.name}: live inner radius is {metrics['innerFraction']}, " + f"expected {case.inner_fraction}" + ) + if case.sector is not None: + if metrics["fullSector"]: + raise AssertionError(f"{case.name}: partial sector was treated as a full turn") + if not np.allclose(metrics["sector"], case.sector): + raise AssertionError(f"{case.name}: client geometry lost the authored sector") + if (case.hole or case.origin is not None) and ( + metrics["centerAlpha"] != 0 or metrics["inverseCenter"] != [None, None] + ): + raise AssertionError(f"{case.name}: the live hole is painted or hit-testable") + if not np.all(np.isfinite(metrics["projectedProbe"])): + raise AssertionError(f"{case.name}: live forward projection returned a non-finite point") + if not np.allclose(metrics["inverseProbe"], case.probe, rtol=1e-6, atol=1e-6): + raise AssertionError( + f"{case.name}: live projection inverse returned {metrics['inverseProbe']}, " + f"expected {case.probe}" + ) + if metrics["probeHitKind"] != case.probe_kind: + raise AssertionError( + f"{case.name}: live hover hit {metrics['probeHitKind']!r}, expected {case.probe_kind!r}" + ) + live_labels = set(metrics["labels"]) + for label in case.labels: + if label not in live_labels: + raise AssertionError(f"{case.name}: live categorical label {label!r} is missing") + for color, minimum in case.gl_colors: + count = int(metrics["glColorCounts"].get(color, 0)) + if count < minimum: + raise AssertionError( + f"{case.name}: live WebGL has only {count} {color} pixels " + f"(expected at least {minimum})" + ) + for color, minimum in case.chrome_colors: + count = int(metrics["chromeColorCounts"].get(color, 0)) + if count < minimum: + raise AssertionError( + f"{case.name}: live chrome has only {count} {color} pixels " + f"(expected at least {minimum})" + ) + + +def _run_live( + case: Case, + chromium: str, + page: Path, + screenshot: Path | None, +) -> dict[str, Any]: + command = [ + chromium, + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + "--virtual-time-budget=5000", + "--dump-dom", + ] + if screenshot is not None: + command.extend( + [ + f"--window-size={int(case.chart.width)},{int(case.chart.height)}", + f"--screenshot={screenshot}", + ] + ) + command.append(page.as_uri()) + result = subprocess.run(command, capture_output=True, text=True, timeout=120) + match = re.search(r"([^<]*)", result.stdout) + title = html.unescape(match.group(1)) if match else "(no title in DOM dump)" + if not title.startswith("XY_OK "): + print(result.stderr[-2000:]) + raise AssertionError(f"{case.name}: live render failed: {title[:500]}") + metrics = json.loads(title.removeprefix("XY_OK ")) + _validate_live(case, metrics) + return metrics + + +def _validate_static(case: Case, figure: Any, output_dir: Path) -> tuple[int, int, float]: + svg = figure.to_image(format="svg") + ET.fromstring(svg) + if re.search(rb"(? None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("chromium", nargs="?", help="Chromium/Chrome executable") + parser.add_argument( + "--artifacts", + type=Path, + help="retain HTML plus browser, SVG, and PNG renders in this directory", + ) + parser.add_argument( + "--case", + action="append", + dest="case_names", + help="run only this named case (repeatable; defaults to all cases)", + ) + args = parser.parse_args() + chromium = _find_chromium(args.chromium) + cases = _cases() + if args.case_names: + selected = set(args.case_names) + available = {case.name for case in cases} + unknown = selected - available + if unknown: + parser.error( + f"unknown --case value(s): {', '.join(sorted(unknown))}; " + f"choose from {', '.join(sorted(available))}" + ) + cases = [case for case in cases if case.name in selected] + + temporary = None + if args.artifacts is None: + temporary = tempfile.TemporaryDirectory() + output_dir = Path(temporary.name) + else: + output_dir = args.artifacts.resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + try: + for case in cases: + figure = case.chart.figure() + spec, blob = figure.build_payload() + _validate_spec(case, spec) + page = output_dir / f"{case.name}.html" + page.write_text( + _page(spec, blob, case.probe, case.gl_colors, case.chrome_colors), + encoding="utf-8", + ) + screenshot = ( + output_dir / f"{case.name}.browser.png" if args.artifacts is not None else None + ) + metrics = _run_live(case, chromium, page, screenshot) + svg_bytes, png_bytes, ink_fraction = _validate_static(case, figure, output_dir) + print( + f"{case.name}: live={metrics['lit']} px, " + f"SVG={svg_bytes} B, PNG={png_bytes} B, " + f"native ink={ink_fraction:.2%}" + ) + finally: + if temporary is not None: + temporary.cleanup() + print("polar phase 6/7 live examples: browser, SVG, and PNG all OK") + + +if __name__ == "__main__": + main() diff --git a/scripts/render_smoke_nonumpy.py b/scripts/render_smoke_nonumpy.py index 24aedf26..1e5291f0 100644 --- a/scripts/render_smoke_nonumpy.py +++ b/scripts/render_smoke_nonumpy.py @@ -1329,7 +1329,16 @@ def main() -> None: ], capture_output=True, text=True, - timeout=120, + # This probe is the widest one in the tree — a single page that + # exercises every mark family, LOD drill-in, picking, selection, + # the modebar, context-loss recovery and the DPR watch — and it + # SOFTWARE-rasterizes all of it through SwiftShader. On a GitHub + # runner it measured 98 s against the 120 s it used to allow, so a + # slightly slower runner timed the whole job out with nothing + # broken. Sized for headroom instead: a real hang still fails, + # 22 s of jitter no longer does. `append_stream_smoke.py` already + # allows 180 s for a far smaller page. + timeout=300, ) m = re.search(r"([^<]*)", out.stdout) title = m.group(1) if m else "(none)" diff --git a/scripts/verify_ci_workflow.py b/scripts/verify_ci_workflow.py index 34111197..a89fafdc 100644 --- a/scripts/verify_ci_workflow.py +++ b/scripts/verify_ci_workflow.py @@ -331,6 +331,8 @@ def validate_ci_workflow(path: Path = DEFAULT_CI_WORKFLOW) -> list[str]: "scripts/check_public_api.py", "ruff check .", "scripts/smoke_render.py", + "Polar phase 6/7 live examples", + "scripts/polar_phase7_smoke.py", "Browser lifecycle smoke", "Browser visual regression smoke", "Browser interaction stress smoke", diff --git a/spec/api/capability-matrix.md b/spec/api/capability-matrix.md index ebce32d8..80670753 100644 --- a/spec/api/capability-matrix.md +++ b/spec/api/capability-matrix.md @@ -13,7 +13,7 @@ which is sometimes deliberate, and the notes say which. ## In one line -- **10** mark style properties across **20** mark kinds, drawn by all three renderers. +- **11** mark style properties across **20** mark kinds, drawn by all three renderers. - **29** stable chrome slots, CSS- and Tailwind-addressable in the browser; **10** of them reach the native writers — nine through `styles={slot: ...}` itself, and `root` through the chart-level `style=` token bag. - **1** shipped extension point. - **1** known default divergence between renderers, listed below rather than left to be discovered. @@ -35,6 +35,7 @@ one honors. | `stroke-dasharray` | svg | `area`, `ecdf`, `line`, `stairs`, `step` | full | full | full | shipped | | `stroke-linecap` | svg | `ecdf`, `line`, `stairs`, `step` | full | full | full | shipped | | `border-radius` | css | `bar`, `column`, `hist`, `histogram` | full | full | full | shipped | +| `wedge-gap` | xy | `bar`, `column`, `hist`, `histogram` | full | full | full | shipped | | `marker-shape` | xy | `scatter` | full | full | full | shipped | ### Notes @@ -47,6 +48,7 @@ one honors. - **`stroke-dasharray`** — 2-8 positive px lengths, or `none`. The WebGL client tracks arc length on the CPU so dashes stay continuous across segments and constant on screen through zoom. - **`stroke-linecap`** — Line family only — a cap is open-path geometry. XY's default is `round`, not CSS's `butt`, because the native rasterizer has always drawn round and is the reference for static export. Verified per renderer: a Rust coverage test, a rasterized-ink test, and three Chromium screenshots that hash differently per cap. - **`border-radius`** — Rect kinds only. `corner_radius=(tip, base)` rounds the two ends separately. +- **`wedge-gap`** — Gap between neighbouring polar wedges, in px. Rect kinds under `coords="polar"` only; ignored elsewhere. Deliberately a LENGTH rather than an angle: an angular pad's seam is `r * dtheta` wide, so it tapers to nothing at the hole and reads as uneven spacing. The angular inset therefore grows as the radius shrinks, which is the same construction as d3's padAngle/padRadius pair. An XY vocabulary name: CSS has no gap between two arcs. - **`marker-shape`** — 17 shapes, drawn as analytic signed-distance fields in all three renderers. An XY vocabulary name: CSS has no shape keyword for a non-DOM point mark, and the CSS spelling and `symbol=` compile to the same value. ## Chrome slots diff --git a/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index b9452ebe..607a084c 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -156,13 +156,15 @@ style, per-cell data on the wire, one ring definition the other renderers cite. The rectangle family deliberately has two wire shapes: -- **Full rectangles** (`histogram` today, and later irregular cells/candles): +- **Full rectangles** (`histogram`, plus bars with unequal per-item widths): four edge columns, `x0/x1/y0/y1`. Use this when widths are irregular or both - axes need independent per-mark edges. + axes need independent per-mark edges. Under polar, a bar's four edges are an + annular sector rather than a Cartesian rectangle. - **Compact bars** (`bar`/`column`): one position column, one endpoint value column, an optional baseline column or scalar `value0_const`, and scalar - `width`. This keeps common bars to two data columns instead of four while - preserving the same rect fragment shader and legend/color path. + `width`. Equal per-item widths collapse to this path. This keeps common bars + to two data columns instead of four while preserving the same rect fragment + shader and legend/color path. Do not regress bars back to full rectangles for convenience; the 10k-category benchmark tracks this as part of the core 2D payload budget. @@ -174,6 +176,9 @@ benchmark tracks this as part of the core 2D payload budget. mark-blind. A new kind inherits them without writing any interaction code, but they are not unconditional: `navigation`/`pan`/`zoom` default to on and can be turned off — or scoped to specific axes — per figure. + Polar is coordinate-system-specific: hover, fixed-minimum radial zoom, and + reset ship, while theta pan/rotation, box zoom, selection, brush, and + crosshair are disabled. [interaction.md](interaction.md) is the authority on the switches, per-axis policy, defaults, gesture map, and event payloads. - **Responsive sizing**: `width/height:"100%"` + ResizeObserver. diff --git a/spec/api/chart-roadmap.md b/spec/api/chart-roadmap.md index 45fd9d5e..1930ba51 100644 --- a/spec/api/chart-roadmap.md +++ b/spec/api/chart-roadmap.md @@ -32,6 +32,14 @@ aggregation. Line and area cover direct and M4-decimated time series. Histogram and bar/column share the instanced rectangle renderer; heatmap ships a compact grid texture. +Polar phases 6/7 now carry the allowlisted heatmap, contour, and error-bar +schemas through the same coordinate system, including the heatmap +fragment-stage inverse. Authored sectors use their own bounding-box layout; +hole/r-origin, categorical θ, log/symlog radius, and +`grid_shape="linear"` are shared by browser and static export. This is not +generic segment or mesh support: polar rule/band annotations, LOD, +facets/animation, and every mark outside `POLAR_MARK_KINDS` remain deferred. + Beyond the mark set, four capability layers now ship on `main`: - **Mark-level styling (§ "Styling & Theming" below):** CSS `linear-gradient` @@ -112,7 +120,7 @@ not fall out of sight. | 3 | Bar / column | vertical bar, horizontal bar, grouped, stacked, normalized stacked, diverging bar | Implemented core | `xy.bar(...)` / `xy.column(...)` ship categorical/numeric vertical and horizontal bars, grouped bars, stacked bars, and normalized stacked bars (`mode="normalized"`) through the shared rectangle renderer. Follow-up: labels. | | 4 | Area | filled line, stacked area, streamgraph, ridgeline-lite area bands | Implemented core | `xy.area(...)` ships a filled area with scalar/array baseline and optional line overlay. Follow-ups: stacked area helpers and streamgraph offsets. | | 5 | Histogram | count, probability, density, cumulative histogram | Implemented core | Python-side binning plus the shared rectangle renderer; `cumulative=True` (count CDF and, with `density=True`, empirical CDF) is implemented. Follow-up: viewport-aware re-binning for huge streamed distributions. | -| 6 | Pie / donut | pie, donut, nested donut, variable-radius pie | Implemented in `xy.pyplot` | Native pie/donut tessellation with Matplotlib-style containers and labels; richer nested/variable-radius composition remains future depth. | +| 6 | Pie / donut | pie, donut, nested donut, variable-radius pie | Implemented | `xy.pie_chart(labels, values)` composes polar wedge bars (hole/pad/rounded corners) and owns its tooltip — a slice reads category, value, share; theta/rim never show. `xy.pyplot` keeps its Matplotlib-style pie. Nested/variable-radius remain future depth. | | 7 | Heatmap / image / matrix | heatmap, image, annotated matrix, correlation matrix, cohort heatmap | Implemented core | `xy.heatmap(...)` renders matrix cells through a compact grid texture with continuous colormaps and categorical/numeric axes. Native static export borrows canonical f64 spans and normalizes only sampled pixels in Rust, verified through 4.29B cells without a derived grid or RGBA expansion. Follow-ups: annotation and tiled huge-image browser transport. | | 8 | Box plot | box, grouped box, notched box, outlier points | Implemented core | Tukey quartiles, whiskers, median, deterministic outliers, numeric or categorical groups. | | 9 | Candlestick / OHLC | candlestick, OHLC bars, volume overlay, range selector | Prototyped (PR closed unmerged) | `xy.candlestick(...)`/`xy.ohlc(...)` + `xy.candlestick_chart(...)` on the closed `codex/finance-charting-surface` exploration branch: OHLC decimation, shared-y f32 frame, time axes, hover, and a volume pane. Critical finance surface; inherits LOD and time-axis work from core primitives. | @@ -124,7 +132,7 @@ not fall out of sight. | 15 | Funnel | funnel, funnel area, conversion funnel | Planned | Product analytics and sales/ops dashboard expectation. | | 16 | Treemap | treemap, squarified treemap | Planned | Common BI hierarchy chart; requires layout and label polish. | | 17 | Sunburst / icicle | sunburst, icicle, radial hierarchy | Planned | Plotly/Highcharts/ECharts compatibility for hierarchical data. | -| 18 | Radar / polar | radar, spider, polar area, radial bar | Planned | Common in Chart.js/Highcharts; needs polar axes and interaction semantics. | +| 18 | Radar / polar | radar, spider, polar area, radial bar, polar heatmap/contour | Implemented core | `xy.polar_chart` renders the allowlisted line/scatter/area/bar/column/heatmap/contour/errorbar schemas through the polar coordinate system (spec/design/polar-axes.md); `xy.radar_chart`, `xy.polar_bar_chart` and `xy.wind_rose` are the compositions. Sector layout, hole/r-origin, categorical θ, log/symlog radius, and polygonal grids ship. Follow-ups are rule/band geometry, polar LOD, facets/animation, and angular navigation/selection. | | 19 | Gauge / indicator | gauge, bullet, KPI indicator | Planned | Dashboard compatibility; mostly DOM/SVG/canvas chrome rather than large-data rendering. | | 20 | Small multiples | facet grid, repeat chart, trellis chart, pair grid | Implemented core | `xy.facet_chart(...)` builds per-panel screen-bounded Figures with optional shared domains. | | 21 | Scatter matrix / pair plots | SPLOM, pairplot, corner plot | Planned | High-value exploratory data analysis; should reuse scatter kernels across many panels. | @@ -138,9 +146,9 @@ not fall out of sight. | 29 | Parallel coordinate/category | parallel coordinates, parallel categories, alluvial-lite | Planned later | Present in Plotly/ECharts; useful for high-dimensional EDA. | | 30 | Sankey / alluvial | Sankey, alluvial, dependency wheel | Planned later | Important flow chart, but requires layout and interaction work. | | 31 | Network/tree/org | network graph, force graph, tree, dendrogram, org chart, arc diagram | Planned later | Valuable but layout-heavy; should follow core 2D marks. | -| 32 | Scientific vector fields | quiver, barbs, streamplot, wind rose | Implemented in `xy.pyplot` | Quiver, barbs, and bounded streamlines feed shared instanced segments; wind rose remains tied to future polar axes. | +| 32 | Scientific vector fields | quiver, barbs, streamplot, wind rose | Implemented across core and `xy.pyplot` | Quiver, barbs, and bounded streamlines feed shared instanced segments through `xy.pyplot`; `xy.wind_rose(...)` bins compass bearings and speed bands into stacked polar sectors in core. | | 33 | Irregular grid science | pcolormesh, tricontour, tripcolor, triangular mesh | Implemented in `xy.pyplot` | Curvilinear quads and explicit/native triangulations route through indexed meshes and marching-triangle kernels. | -| 34 | Specialist coordinate systems | ternary, Smith chart, carpet plot, polar scatter/line/bar | Planned later | Plotly/science compatibility; axis systems are the main work. | +| 34 | Specialist coordinate systems | ternary, Smith chart, carpet plot, polar marks | Partially implemented | Polar shipped as the first non-cartesian system (chart-level `coords`, spec/design/polar-axes.md), including the phase-6/7 grid inverse and axis-depth surface. Ternary/Smith/carpet remain planned and should reuse the same seam. | | 35 | Finance advanced | VWAP, moving averages, Bollinger bands, RSI, MACD, depth chart, order book heatmap, market profile, Renko, Heikin-Ashi, Kagi, point-and-figure | Prototyped (PR closed unmerged) | The closed `codex/finance-charting-surface` exploration branch has a `FinanceChart`/`FinanceLayer` system with volume bars, SMA, VWAP, Bollinger bands, RSI, and MACD as overlay/pane layers plus drawings. Remaining: depth/order-book, market profile, Renko/Heikin-Ashi/Kagi/P&F. | | 36 | Maps and geo | choropleth, tile choropleth, point map, bubble map, density map, route map, filled-area map | Deferred 2D domain | 2D, but requires projection/tile/geography stack; do after core chart breadth. | | 37 | Statistical evaluation | ROC, precision-recall, lift, calibration, Manhattan, volcano | Planned later | Mostly composed line/scatter variants with domain helpers. | @@ -180,7 +188,7 @@ depth: strip/swarm/boxen/rug distributions, regression diagnostics, richer | Rank | Chart | Why it matters | Caveat | |---:|---|---|---| | 13 | Composed / mixed charts | Overlay line, scatter, bars, bands, candlesticks, and secondary axes cleanly. | API and spec work comes before many chart families. | -| 14 | Pie / donut | Very popular in basic chart libraries and user expectations. | Implemented through `xy.pyplot`; future work is composition and styling depth. | +| 14 | Pie / donut | Very popular in basic chart libraries and user expectations. | Implemented: `xy.pie_chart` in the composition API + `xy.pyplot`. | | 15 | Candlestick / OHLC | Important for finance users and appears in Plotly/Highcharts stock tooling. | **Prototyped (PR closed unmerged):** candlestick/OHLC marks with date axes, gaps, and hover format on the closed finance exploration branch. Remaining polish: range selectors. | | 16 | Finance overlays | Volume bars, VWAP, moving averages, Bollinger bands, depth/order-book heatmap, market profile, Renko, Heikin-Ashi, Kagi, point-and-figure. | **Prototyped (PR closed unmerged):** volume pane, SMA, VWAP, Bollinger, RSI, MACD as `FinanceLayer`s reusing composed charts + time axes. Remaining: depth/order-book, market profile, Renko/Heikin-Ashi/Kagi/P&F. | | 17 | Waterfall | Common in business reporting and Plotly/Highcharts. | Mostly categorical bars plus running baseline. | @@ -203,9 +211,9 @@ depth: strip/swarm/boxen/rug distributions, regression diagnostics, richer | Rank | Chart family | Why it matters | Caveat | |---:|---|---|---| -| 27 | Radar / polar / radial bar | Common in Chart.js/Highcharts and dashboards. | Needs polar axes and interaction semantics. | +| 27 | Radar / polar / radial bar | Common in Chart.js/Highcharts and dashboards. | Implemented core: polar coordinate system + radar/polar-bar/wind-rose compositions, heatmap/contour/errorbar, sector layout, hole/r-origin, categorical θ, log/symlog radius, and polygonal grids. Rule/band annotations, polar LOD, facets/animation, and angular navigation/selection remain tracked in polar-axes.md §§8–9. | | 28 | Ternary / Smith / carpet | Plotly/scientific compatibility. | New coordinate systems, not new mark primitives. | -| 29 | Quiver / barbs / streamplot / wind rose | Scientific and engineering vector fields. | Quiver, barbs, and streamplot are implemented through `xy.pyplot`; wind rose awaits polar support. | +| 29 | Quiver / barbs / streamplot / wind rose | Scientific and engineering vector fields. | Quiver, barbs, and streamplot are implemented through `xy.pyplot`; `xy.wind_rose(...)` ships as a core helper over stacked polar bars. | | 30 | Pcolormesh / tricontour / tripcolor | Matplotlib-style irregular grid science. | Implemented through `xy.pyplot` with native quad/triangle geometry. | | 31 | Waffle / mosaic / Mekko / variwide | Business/category compatibility. | Mostly rectangle layout algorithms. | | 32 | Packed bubble / Venn / Euler | Compatibility and presentation charts. | Layout algorithms and label placement dominate. | @@ -406,9 +414,10 @@ not re-implementing shipped primitives. 3. **Statistical compatibility depth.** Add strip/swarm/boxen/rug, regression/diagnostic helpers, richer density hover/readout, and scatter-matrix/joint-plot composition on the shipped primitives. -4. **Pie / donut in the composition API.** Implemented in `xy.pyplot` today; - promote to a core `xy.pie_chart(xy.pie(...))` surface with bounded arc - geometry and label placement. +4. **Pie / donut depth.** `xy.pie_chart(labels, values)` ships in the + composition API over unequal-width polar bars, and `xy.pyplot` supplies the + Matplotlib-shaped pie helpers. Remaining depth is nested donuts and + variable-radius composition. 5. **Re-land the finance surface.** Open a fresh PR from the closed exploration branch, rebased onto current composition/LOD primitives, then extend the `FinanceLayer` system with depth charts, Heikin-Ashi, and Renko. @@ -441,21 +450,19 @@ xy.violin_chart(xy.violin(values, group=None, bandwidth="auto")) xy.chart(xy.errorbar(x, y, yerr=..., xerr=...)) xy.chart(xy.hexbin(x, y, gridsize=50, color_scale="log")) xy.candlestick_chart(xy.candlestick(x, open, high, low, close)) # prototyped (closed finance PR) - -# next -xy.pie_chart(xy.pie(values, labels=..., donut=0.0)) +xy.pie_chart(labels, values, hole=0.55) # wedge bars + an owned slice tooltip ``` New chart kinds land as composition marks plus a family container -(`xy.box_chart(xy.box(...))`, `xy.pie_chart(xy.pie(...))`, …). +(`xy.box_chart(xy.box(...))`, `xy.histogram_chart(xy.hist(...))`, …). ## Decision Summary The rectangle/polygon/grid-texture foundations, statistical breadth block, -full mark styling, native PNG rasterizer, and the **Reflex-first reactive API** -(reflex-xy adapter) are in place, and `v0.0.1` is launched with a live docs -site. The next product track is **post-launch stabilization** (the open -interaction/adapter bug backlog), followed by statistical compatibility depth -and **pie/donut** in the composition API. Finance (candlestick plus -indicators) remains prototyped on a closed exploration branch awaiting a fresh -landing. +full mark styling, native PNG rasterizer, core polar/pie/donut composition, and +the **Reflex-first reactive API** (reflex-xy adapter) are in place, and +`v0.0.1` is launched with a live docs site. The next product track is +**post-launch stabilization** (the open interaction/adapter bug backlog), +followed by statistical compatibility depth and pie/donut depth (nesting and +variable radius). Finance (candlestick plus indicators) remains prototyped on +a closed exploration branch awaiting a fresh landing. diff --git a/spec/api/interaction.md b/spec/api/interaction.md index 1a5114aa..2f0c3ff9 100644 --- a/spec/api/interaction.md +++ b/spec/api/interaction.md @@ -127,6 +127,12 @@ the pan *gesture* would leave its position reachable through zoom: a zoom-in / zoom-out chain at two cursor positions is an exact pan. Exclusion from pan therefore means containment, not gesture removal. +Polar radial zoom is the deliberate exception to cursor anchoring: it always +uses anchor `0`, preserving `r_min` and scaling only `r_max`. An interior radial +anchor would lift the minimum and turn an ordinary disc zoom into an annulus. +Theta pan/zoom, box gestures, selection, brush, and crosshair are disabled for +polar; see `spec/design/polar-axes.md` §8. + An axis is **contained** when zoom navigation can change it but pan cannot: `navigation` and `zoom` are enabled, the axis is in `zoom_axes`, and either `pan` is off or the axis is outside `pan_axes`. A contained axis's window can @@ -278,7 +284,7 @@ renderer reads anywhere in `js/src/`. | **Shift**-drag | Box select, overriding the current drag mode (`53_interaction.ts:117`) | `brush`, `select`, `_pickable` | | Drag in `select` / `select-lasso` / `select-x` / `select-y` mode | That selection shape | `brush`, `select`, `_pickable` | | Drag in `zoom` mode | Box zoom, fitting `zoom_axes` on release | `navigation`, `zoom`, and `box_zoom` | -| Wheel | Cursor-anchored zoom of `zoom_axes`, factor `1.0015 ** deltaY`; `preventDefault`. The active drag tool never disables it — box-zoom and select tools are drag-only — except `none`, which releases the wheel to the page | `navigation`, `zoom`, and `wheel_zoom` | +| Wheel | Cursor-anchored zoom of `zoom_axes`, factor `1.0015 ** deltaY`; `preventDefault`. Polar instead scales only `r_max` about fixed `r_min`. The active drag tool never disables it — box-zoom and select tools are drag-only — except `none`, which releases the wheel to the page | `navigation`, `zoom`, and `wheel_zoom` | | Double click in `pan` or `zoom` mode | Reset `reset_axes` to home (animated); does **not** clear selection | `navigation` and `double_click_reset` | | Double click in `select` / `select-lasso` / `select-x` / `select-y` mode | Clear the active selection and, for lasso, its editable polygon; no-op when no selection exists | active selection | | Click without drag | Pick; a drag past threshold sets `_ignoreNextClick` and swallows the click | `click` | diff --git a/spec/benchmarks/methodology.md b/spec/benchmarks/methodology.md index 63db93bb..22bf9025 100644 --- a/spec/benchmarks/methodology.md +++ b/spec/benchmarks/methodology.md @@ -235,12 +235,23 @@ and tier discipline applies to them rather than a threshold. wall time, so browser, install, and cross-library process benchmarks stay out of it — those live in `benchmark-refresh.yml`, and the workflow says so inline. -The glob collects five modules — `test_codspeed_animation.py`, -`test_codspeed_kernels.py`, `test_codspeed_pyplot.py`, -`test_codspeed_selection.py`, and `test_codspeed_transport.py` — for 102 rows -total. These are trend-tracked in CodSpeed, not gated: none of them feed +The glob collects six modules — `test_codspeed_animation.py`, +`test_codspeed_kernels.py`, `test_codspeed_polar.py`, +`test_codspeed_pyplot.py`, `test_codspeed_selection.py`, and +`test_codspeed_transport.py` — for **109 rows** total, counting parametrized +expansion. These are trend-tracked in CodSpeed, not gated: none of them feed `scripts/check_regressions.py`, whose three inputs are §7's. +That count is a hard gate, not prose: +`tests/test_benchmark_environment.py::test_codspeed_row_count_matches_the_methodology_spec` +collects the modules by AST and fails when the two disagree. It exists because a +benchmark that is renamed or deleted without a note here leaves a **stale row in +CodSpeed's stored baseline** — the dashboard keeps reporting it as "skipped, using +the baseline result", which reads like a flaky measurement rather than a row that +no longer exists. Removing one is allowed; removing one silently is not. Stale +rows already in the dashboard have to be archived there by hand; this gate stops +new ones appearing. + **`benchmarks/test_codspeed_pyplot.py` — 14 rows, seven paired arms.** Each pair expresses one chart twice over the same input arrays: once through the declarative API (`xy.chart` + marks) and once through the identical @@ -300,6 +311,27 @@ assertion and lazy-import warmup fixture. Browser `updatePayload` time, animation-frame pacing, heap delta, and the previous+next scene bound stay in `bench_animation.py`. +**`benchmarks/test_codspeed_polar.py` — 6 rows.** Attribution for the polar +coordinate system, which shipped without any CodSpeed row: the report read "103 +untouched benchmarks" for a change that rewrote wedge geometry in three +renderers, and a performance cliff at ~50k polar bars was consequently found by +hand rather than by CI. Three payload rows cover the shapes with materially +different validation and emit paths — a 100k-point polar line, a 16-sector / +50k-observation wind rose (Python-side binning plus stacked wedges), and a +24-slice pie (unequal widths, so the four-edge column path rather than the +compact scalar-width one). Two export rows bracket the arc-flattening term over +the same rose: SVG draws real `A` arcs and needs no subdivision count, so it is +the control, while native PNG flattens every wedge through `polar_wedge_points` +at `config.polar_bar_segments(span, turn)` vertices — six segments for a +22.5-degree sector rather than the full-turn 96, so a regression back to a flat +count lands here as an arc-flattening step change instead of a bug report. The +sixth row is a polar heatmap's bounded screen-space inverse raster, which has no +Cartesian twin. The payload rows assert bounds, not sizes: the rose's bytes must +stay bounded by sector and band count rather than observation count, so a row +cannot get cheaper by shipping a different chart. Browser wedge vertex counts, +GPU buffer lifetime, and radial-zoom frame pacing are wall-clock/WebGL +measurements and stay in `bench_interaction.py` and the polar smokes. + **`benchmarks/test_codspeed_selection.py` — 4 rows.** The backend interaction/selection handlers the client's gesture messages resolve to (design-dossier §17/§34): hover pick with a categorical channel readout; diff --git a/spec/benchmarks/results.md b/spec/benchmarks/results.md index 93c1812c..5d852b47 100644 --- a/spec/benchmarks/results.md +++ b/spec/benchmarks/results.md @@ -85,6 +85,7 @@ commit so CI artifacts are quick to inspect from logs. | `input_ingestion` | Input ingestion | tracked | Real applications provide converted, strided, datetime, list, pandas, and Arrow inputs rather than only contiguous f64 arrays. | ingest latency, copies, peak Python memory | `benchmarks/bench_workflows.py` ingestion rows | Keep zero-copy inputs cheap and make unavoidable conversions visible. | | `streaming_updates` | Streaming updates | tracked | Monitoring and notebook workflows append repeatedly; stable-domain batches should update indexes incrementally while domain growth may rebuild. | append latency, refresh bytes, incremental pyramid update, domain-growth rebuild | `benchmarks/bench_workflows.py` streaming rows; `benchmarks/bench_transport.py` append diagnostics | Keep stable-domain appends proportional to the batch and expose unavoidable rebuild stalls. | | `log_autorange` | Log autorange | tracked | Large positive/negative and non-finite series are common in monitoring and scientific charts, and log axes must avoid full-data rescans. | range latency, positive-domain correctness, peak Python memory | `benchmarks/bench_workflows.py` log autorange row; `tests/test_figure.py` | Compute correct positive log domains from zone statistics with cost proportional to chunks, not points. | +| `polar_coordinate_system` | Polar coordinate system | tracked | Radar, wind rose, pie/donut and gauge views are a whole chart family, and a polar wedge is the most expensive mark in the engine — one annular sector per bar instead of one quad. | payload-prep time, wedge flattening cost, SVG/PNG export latency, inverse-raster latency | `benchmarks/test_codspeed_polar.py` | Keep polar payload prep bounded by composition size rather than observation count, and keep wedge subdivision proportional to each wedge's own angular span. | | `static_export` | Static export | tracked | HTML, SVG, and PNG have distinct serialization and browser costs. | export latency, output bytes, peak Python memory | `benchmarks/bench_workflows.py` export rows; `benchmarks/bench_pyplot_vs_matplotlib.py` matched PNG rows | Track each target independently without mixing browser and payload work. | The launch scatter baseline has no entry in `BENCHMARK_CATEGORIES`, so it diff --git a/spec/design/polar-axes.md b/spec/design/polar-axes.md new file mode 100644 index 00000000..909343eb --- /dev/null +++ b/spec/design/polar-axes.md @@ -0,0 +1,586 @@ +# Polar axes + +How `xy` renders a chart in polar coordinates. This document is **normative** +for the (θ, r) → pixel transform, the angular conventions, the wire shape, and +which marks are legal under `coords="polar"`. Where an implementation and this +document disagree, this document is right and the implementation is a bug. + +Roadmap context: item 18 (radar/polar), items 29/32 (wind rose), and item 34 +(specialist coordinate systems) in +[`../api/chart-roadmap.md`](../api/chart-roadmap.md). + +## 1. The shape of the problem + +Polar is **one coordinate system**, not a family of chart types. Both incumbents +work this way: matplotlib has a `PolarAxes` projection that ordinary `plot` / +`scatter` / `bar` / `fill` calls render into, and Plotly has three polar trace +types (`scatterpolar`, `scatterpolargl`, `barpolar`) from which radar, spider, +bubble and wind-rose charts are composed. Neither ships a "radar renderer". + +So `xy` adds a coordinate system and lets the existing mark registry render +through it. `MARK_KINDS` (`js/src/55_marks.ts`) gains no entries, and no +`_emit_` in `python/xy/_payload.py` is rewritten. This follows the standing +rule in [`../api/chart-kind-contract.md`](../api/chart-kind-contract.md): +organize by primitive, not by chart name. + +## 2. Conventions + +| Concept | Value | Notes | +|---|---|---| +| θ unit | `"radians"` (default) or `"degrees"` | Affects input data and tick labels together. | +| θ zero location | `"E"` (default), `"N"`, `"W"`, `"S"`, or a float in radians | Direction that θ = 0 points. `"E"` = math convention. | +| θ direction | `"counterclockwise"` (default) or `"clockwise"` | Math convention default; compass work sets `"N"` + `"clockwise"`. | +| θ sector | one full turn by default, or finite increasing `(start, end)` | At most 2π radians / 360 degrees. `theta_axis(domain=...)` is a compatibility alias when `sector=` is omitted. | +| θ grid shape | `"circular"` (default) or `"linear"` | Linear joins the angular spokes into polygonal radial rings. | +| θ scale | linear only | A non-linear angle has no coherent projection; `type_="log"`/`"symlog"` on the angular axis is rejected at payload build. | +| r scale | linear, log, or symlog | Radius normalization happens in scale-coordinate space. | +| r range | `[r_lo, r_hi]` | Linear/symlog autorange retains the centre-origin default; log autorange starts at its positive minimum. Three exemptions in §2.1. | +| inner shape | `hole ∈ [0, 1)` or a data-space `r_origin` | Mutually exclusive authored controls. An omitted origin resolves to visible `r_lo`. | +| signed r | a position, never a direction | §2.2. | + +The compass composition — `zero="N"`, `direction="clockwise"` — makes θ = 90° +point East, 180° South, 270° West. Wind roses depend on exactly this; §4 pins it +with fixtures. + +### 2.1 Radial autorange (normative) + +A linear or symlog radius starts at the **centre** (matplotlib's `rmin = 0`) and +ends at the data maximum with **no outer pad**, so the outermost ring *is* the +largest datum. A radius padded away from zero puts the smallest datum at the +centre and makes a 5%-variation series read as radiating from nothing. + +Three exemptions, each because the centre-origin rule is vacuous there: + +- **Log radius** — has no zero; it resolves its own strictly positive extent. +- **Data below zero** — zero is no longer an end of the range, so the ordinary + padded extent is kept on both sides and stays symmetric about the data. +- **A time radius** — its zero is 1 January 1970. Pinning a modern instant's + origin there squeezes the whole series into a hairline ring at the rim + (twelve consecutive days out of ~1.7e12 ms occupy 0.0006% of the radius), so + a time radius keeps the ordinary padded extent. A time *angular* axis remains + refused outright (§9): an instant has no angle, but it does have a distance. + +An authored `margin=` restores the outer pad — it is a request for exactly the +pad this rule otherwise drops, and honouring it is what keeps `r_axis(margin=)` +from being another accepted-and-ignored keyword. An authored `domain=`/`bounds=` +overrides autorange entirely. + +### 2.2 Signed radii (normative) + +A negative radius is **a position on a range that includes it**, not a direction. +`r = -5` therefore draws *nearer the centre* than `r = 0`, on an axis whose +autoranged floor is below both. It is **not** reflected through the centre the +way matplotlib reflects it — reflection would put two different data values at +the same pixel, and the §3.2 inverse could not name which one a hover found. + +A radius outside the visible interval is culled for points and line vertices, +and clamped for fills and annular sectors (§8). That is the only place a +radius's sign changes what is drawn rather than where. + +## 3. The transform (normative) + +Given θ and r in data space, an authored angular sector `[θ_lo, θ_hi]`, a +radial range `[r_lo, r_hi]`, radial scale-coordinate function `coord`, radial +origin `r_origin` (default `r_lo`), display-space hole fraction `h` (default +0), and a plot rect `(x, y, w, h_px)` in CSS pixels: + +``` +th = θ · (π/180) if unit == "degrees" else θ +a = zero + dir · th # dir = +1 ccw, −1 cw +c0 = coord(r_origin) +rn = h + (1 − h) · (coord(r) − c0) / (coord(r_hi) − c0) +R = min(w, h_px) / 2 # full-turn radius +cx = x + w/2 , cy = y + h_px/2 # full-turn centre + +px = cx + rn · R · cos(a) +py = cy − rn · R · sin(a) # screen space: y grows DOWN +``` + +Only values inside the authored θ sector and visible radial interval are +projected. Point and line positions outside either interval are culled; spans +and bars are clipped to the visible interval. The authored radial origin must +be below `r_hi` and no greater than `r_lo`; on a log scale it must also be +positive. An origin below `r_lo` makes the visible lower ring an annulus, while +`hole` assigns that ring the explicit display-space fraction `h`. + +Five properties this pins down, each of which has matching coverage: + +- **The circle is round in a non-square rect.** `R` uses `min(w, h_px)`, and the + circle is centred in the rect rather than stretched to fill it. +- **Radius is normalized after scaling.** Linear, log, and symlog radii share + the same equation; only `coord` changes. Log autorange never reintroduces + zero. +- **Sector and data range are independent.** Numeric θ remains in angular + units. Categorical θ remains in category-index coordinates and maps those + indices evenly over the sector; it is never rewritten into radians/degrees. +- **`R` carries no fill factor.** Room for angular tick labels is reserved by + *shrinking the plot rect during layout*, not by scaling the radius. The + transform stays pure; layout owns the gutters. Concretely, after the + cartesian gutter passes converge, `_recut_polar_plot` (`_svg.py`, mirrored by + `_recutPolarPlot` in the client) gives back the cartesian tick-label gutters + — polar labels ring the disc instead of hugging two edges — and reserves a + uniform `_POLAR_LABEL_ROOM` all round. Reservations that still mean something + survive: the title band, a colorbar's gutter, and the left gutter when the + radial axis has a title (which is drawn there and would otherwise leave the + canvas). +- **A legend gets a gutter, not a corner.** A Cartesian legend overlays the plot + because data rarely reaches a corner. A disc inscribed in its rect leaves no + corner at all, so an inside legend lands on the marks — a default `upper right` + box covered a wind rose's whole north-east quadrant and the outer radial label + under it. `_polar_legend_reserve` (`_svg.py`, mirrored by `_polarLegendReserve`) + therefore takes a gutter off the canvas edge **before** the disc is fitted, and + records it as `plot["legend_box_*"]` / `view._legendBox`; the legend places and + bounds itself in that box, and `loc` chooses where within it. + `_polar_legend_room` (22% of the canvas width, clamped to 120–200 px) on the + side `loc` names, or `_POLAR_LEGEND_BAND` (64 px) beneath the disc at compact + widths, where a side gutter would leave a disc too small to read. Derived from + the canvas width rather than measured from the label set, for the same reason + the subdivision count is a shared formula: every renderer knows the canvas to + the pixel, while a measured reservation would drift with each renderer's font + metrics. A label wider than the gutter wraps in the browser and ellipsizes in + the static exporters, which have no scroll axis to fall back on; either way the + full text stays in `title`/ARIA. Nothing is reserved when the author supplied an + `anchor` (an explicit plot-relative placement they own, still resolved against + the plot) or a four-tuple `padding` (which already states the box the plot + should occupy, and remains the way to hand-reserve a caption band), and nothing + is reserved for a figure whose angular axis is `tick_label_strategy="none"` — + that early return skips the whole recut, and it is the donut/gauge case whose + chrome the author has already taken over. +- **A title reserves the lines it will wrap into.** `_title_wrap_width` — the + canvas minus the *authored/default* horizontal gutters, mirrored by + `_titleWrapWidth` in the client — is resolved before the title band, and both + the reservation and the drawing wrap at it. Wrapping at the final plot width + would be circular (the measured left gutter depends on the plot height, which + depends on the title band). The client also caps the title element at that + width, so the DOM cannot wrap into more lines than layout reserved; measuring + one line and painting two lifted a compact Wind Rose title ~10 px off the top + of the canvas. +- **A partial sector owns its bounding box.** Full turns retain the centred + circle above. For a partial turn, layout finds the bounds of the visible + outer arc plus inner boundary and scales/translates that shape to the plot + rect. A gauge therefore does not reserve dead space for a missing arc. +- **The y term is a subtraction.** This is the single most likely parity bug in + the codebase. Screen/SVG/raster space has y growing **down**, so upward angles + must *decrease* py. GL clip space has y growing **up**, so the GLSL form is + `+` on the y component. An implementation that copies `cy − r·sin(a)` into a + shader, or `+` into the exporters, renders vertically mirrored and every + fixture in §4 catches it. + +### 3.1 Where it sits in the existing pipeline + +The Cartesian pipeline is two independent 1-D maps. On the client +(`AXIS_GLSL` in `js/src/40_gl.ts`): + +``` +xyDecode(encoded, meta) → undo §16 offset encoding, back to data space +xyAxisCoord(...) → apply the scale (log / symlog / linear) +· map.x + map.y → affine to clip space +``` + +and in Python, `_Scale.coord()` then `_Scale.__call__()` in +`python/xy/_svg.py` do the same two steps. + +**Polar replaces only the last step**, and replaces it with a *joint* map over +both axes. Decode and scale are untouched. After `xyAxisCoord` yields θ and r +in scale-coordinate space, the joint polar map produces a position directly, +and the per-axis `u_xmap`/`u_ymap` affine is bypassed. This is why log and +symlog radius reuse the existing axis-scale implementation. + +Because the WebGL canvas is positioned and sized to exactly the plot rect, +clip space `[-1, 1]²` **is** the plot rect. So +the GLSL form needs no plot-rect uniforms at all — only the centre, the radius +in clip units per axis, and the radial range: + +```glsl +vec2 xyPolar(float thC, float rC, vec4 pol, vec2 rr, vec2 zdir, + vec2 trange, float turn, vec2 rshape) { + if (!thetaVisible(thC, trange, turn) || rC < rr.x || rC > rr.y) + return vec2(NAN); + float rn = rshape.y + + (1.0 - rshape.y) * (rC - rshape.x) / (rr.y - rshape.x); + float a = zdir.x + zdir.y * thC; + return vec2(pol.x + rn * pol.z * cos(a), + pol.y + rn * pol.w * sin(a)); // '+': clip-space y grows UP +} +``` + +`pol.zw` is a **vec2** radius, not a scalar: clip space is square while the plot +rect generally is not, so a round circle needs `2R/w` clip units horizontally +and `2R/h` vertically. `trange`/`turn` own authored-sector clipping, while +`rshape = (coord(r_origin), hole)`. + +Radial zoom is therefore a change to `rr` alone — a uniform update, exactly like +Cartesian pan/zoom. This is the reason the transform lives in the shader rather +than being pre-projected into (x, y) in the kernel: pre-projection would make +every zoom a full re-transform and re-upload, and would break streaming append. + +### 3.2 Inverse (screen → data) + +Hover, tooltips, the heatmap fragment shader, and drag gestures need the +inverse. With `dx = px − cx`, `dy = cy − py` (note the flip again), and +`displayed = hypot(dx, dy) / R`: + +``` +base = (displayed − hole) / (1 − hole) +rc = coord(r_origin) + base · (coord(r_hi) − coord(r_origin)) +r = coord⁻¹(rc) +a = atan2(dy, dx) +th = (a − zero) / dir # then wrapped into the θ domain +``` + +Pixels inside the hole, outside the outer radius, or outside a partial sector +have no data coordinate. θ is wrapped modulo a full turn: **θ = 0 and θ = 2π +are the same location**, and any angular distance metric must wrap across that +seam. A naïve `|θ₁ − θ₂|` reports points at 1° and 359° as maximally distant; +they are 2° apart. + +## 4. Parity fixtures + +The transform above is implemented twice — once in GLSL, once in Python (shared +by both exporters). Prose does not bind them. `tests/fixtures/polar_transform.json` +does, and it is authored from the definition in §3, not generated from either +implementation. + +Fixture cases are chosen so a human can check them by inspection: + +| Config | θ, r | Expected | Pins | +|---|---|---|---| +| default | 0, 1 | due right | zero location | +| default | π/2, 1 | due up (py smaller) | the y flip | +| default | 0, 0 | dead centre | radial origin | +| `zero="N"` | 0, 1 | due up | zero rotation | +| `zero="N"`, cw, degrees | 90, 1 | due right (compass E) | direction sign | +| `zero="N"`, cw, degrees | 180, 1 | due down (compass S) | compass composition | +| non-square rect | 0/π/2/π, 1 | round, centred | `min(w,h)` and centring | + +Three consumers must agree with that file: + +1. **Python** — a unit test over `_PolarProjection`. Fast, always runs. +2. **GLSL** — `scripts/polar_parity_smoke.py` renders one scatter point per + fixture sample in headless Chrome and compares each colour's lit-pixel + centroid to the fixture value. This binds the *actual shader* in the shipped + bundle, not a JS mirror of it, which is the only version that can drift + silently. It runs in the stdlib-only CI lane beside the other smokes. +3. **Exporters** — SVG and raster inherit (1) because they share the Python + projection, so their obligation is a rendered-output check, not a second + transform test. + +This is deliberately stronger than the existing tick-math arrangement, where +`js/src/30_ticks.ts` and its hand port in `python/xy/_svg.py` are bound by +nothing executable. Polar does not repeat that gap. + +## 5. Chord versus arc + +A straight line in (θ, r) space is a curve on screen. Whether to draw the curve +or the chord is a **semantic** choice, not a rendering detail, and the two +incumbents differ: Plotly draws straight chords between polar data points; +matplotlib arc-interpolates paths. + +| Geometry | Rendering | Why | +|---|---|---| +| Data lines, fill boundaries | **chord** | Plotly semantics. Radar/spider edges *must* be straight or the polygon is wrong. | +| Grid rings, outer frame | **true arc** | Axis chrome must be round. | +| Bar edges (annular sectors) | **true arc** | A wide bar with chorded ends reads as a triangle. | +| Heatmap cells | **true arc boundaries** | The fragment-stage inverse samples each screen pixel in (θ, r), so cell edges follow rings/spokes. | +| Contour / error-bar segments | **chord** | They are independent data-space segments projected at their endpoints, with radial clipping before projection. | + +Chords need no subdivision, which is why line, scatter and area are cheap. Arcs +flatten to polylines wherever the medium lacks a real arc: the raster display +list always, and the GPU bar sweep by construction. SVG needs no count: it draws +real `A` arcs (`_polar_wedge_path`), and `polar_wedge_points` is the flattened +twin the raster path consumes. + +The subdivision count is **span-proportional and recorded as a formula** +(`config.polar_bar_segments`, mirrored by `xyPolarBarSegments` in +`js/src/50_chartview.ts`): + +``` +segments(span) = clamp(ceil(POLAR_BAR_SEGMENTS · |span| / turn), + POLAR_BAR_SEGMENTS_MIN, POLAR_BAR_SEGMENTS) +``` + +`POLAR_BAR_SEGMENTS` (96) is the count for a wedge sweeping a **full turn**, +sized so the chord sagitta stays inside the client's `XY_POLAR_AA` expansion up +to a ~1400-device-px disc. Sagitta is quadratic in the per-segment angle, so +holding `span / n` fixed holds the flattening error fixed: proportional +subdivision preserves that bound for every narrower wedge instead of paying the +worst case for all of them. A 16-sector wind rose sector needs six segments, not +96 — 14 vertices per bar instead of 194. + +This is **not** the view-adaptive count §28 would require a recording for. The +input is the *authored* angular width — the scalar `width` on the compact bar +path, and the widest `x1 − x0` in the trace on the four-edge path (measured once +at build, cached on the trace) — so zoom, resize and export cannot change it, and +all three renderers reach the same count for the same figure. A single instanced +draw shares one count, hence "widest in the trace": every narrower wedge in it is +then over-subdivided, never under. + +On the client the flattening never reaches the screen: the GL context runs +with `antialias: false`, so wedge edges are fragment-shader coverage like +every other mark. `POLAR_WEDGE_GLSL` expands the strip `XY_POLAR_AA` px +outward and `RECT_FS` trims it against the true annular-sector SDF — the AA +fringe gets room to ramp on both sides of each edge, and because the expanded +chords stay outside the true outer arc (the segment count is sized for that up +to a ~1400-device-px disc), the trimmed arc is exactly round rather than +faceted. The raster's coverage-scanline polygon fill antialiases the same +flattened wedge; SVG antialiases its real arcs natively. + +### Rounded corners + +`corner_radius` on a slice has no rectangle to hang off, and every donut, +progress ring and gauge design in the wild asks for one. It is defined in the +**unrolled** (arc, radial) frame: at each radius the wedge is a rectangle of +half-height `hr` and half-width `sweep/2 · dist`, so the standard rounded-rect +profile applies there and rolls back out to corners that follow the arc. The +client evaluates that profile per fragment (the annular-sector SDF in +`RECT_FS`); the exporters sample the same profile into a polygon +(`_rounded_wedge_points`), which is why a rounded wedge ships as a polyline +while a plain one keeps its exact `A` arcs — the rounded boundary is not a +circular arc once rolled back, so a polyline is the honest shape rather than an +approximation of one. + +An opt-in arc-interpolated line mode (matplotlib's behaviour) is a possible +later flag. It is not in this increment, and the default does not change. + +## 6. Renderer seams + +Polar must be implemented at every seam below. Missing one does not fail +loudly — it renders something plausible and wrong. + +### Client (`js/src/40_gl.ts`) + +The vertex shaders below each consume the shared data→clip preamble. Three of +them draw points: + +| Shader | Line | Draws | This increment | +|---|---|---|---| +| `POINT_VS` | 107 | scatter (full) | yes | +| `POINT_SIMPLE_VS` | 311 | scatter (fast path) | yes | +| `PICK_VS` | 348 | hover id-pick buffer | yes | +| `LINE_VS` | 486 | line | yes | +| `SEGMENT_VS` | 588 | error bars, stems, contour | **yes for allowlisted `errorbar` and `contour` only** — this does not make generic segment marks polar-legal | +| `MESH_VS` | 653 | hexbin, triangle mesh | no — §7 | +| `AREA_VS` | 738 | area (error bands stay outside the polar allowlist) | yes — interpolates in data space, then projects, so radial edges are true radii and fill boundaries are chords | +| `BAR_VS` | — | compact bars | yes — sweeps `segments(span)`+1 vertex pairs per instance (§5): an annular sector, not a quad | +| `RECT_VS` | 796 | four-edge rects: the unequal-width slice path (§7); histogram/box/violin stay refused | yes — sweeps the same annular sector as `BAR_VS` | + +`POINT_SIMPLE_VS` and `PICK_VS` are the traps. Scatter silently switches to the +simple program whenever `_canDrawSimplePoints` holds, so transforming only +`POINT_VS` leaves a fast path that draws Cartesian. And `PICK_VS` feeds the +GPU hit-test: untransformed, the picture is right while hover reports the wrong +row. + +Vertex projection alone cannot clip the interior of a chord, fill, wide point, +or bar that crosses a hole or a sector boundary. Every legal polar mark +fragment program therefore calls the shared `POLAR_FRAGMENT_CLIP_GLSL` +predicate. It inverts `gl_FragCoord` into normalized polar display space, +rejects the explicit hole and the implicit annulus created by `r_origin`, +rejects the missing angular sector, and applies the same predicate to the pick +framebuffer. Authored Canvas markers and clipped annotation marks use the +matching annular-sector path. In-bounds callout/arrow connectors remain +unclipped, preserving the existing Matplotlib `annotation_clip` semantics. + +`GRID_VS`/the heatmap fragment shader are a different shape entirely: they draw +one fullscreen quad and invert screen→data **in the fragment stage**. In polar +mode the fragment shader discards pixels outside the annular sector, applies +the inverse from §3.2, wraps θ into the grid's own edge range, and nearest +samples the source cell. SVG and native raster export share a bounded CPU +inverse-raster twin. It tiles inverse-projection scratch, gathers canonical-f64 +or RGBA source cells only after resolving visible output pixels, and never +expands the full source grid. SVG samples at logical plot resolution; native +raster samples at device resolution (`ceil(plot × scale)`), capped at 4096 +pixels per output dimension. Work and temporary memory are therefore bounded +by the requested screen/export surface rather than the source matrix. Density +grids remain outside the polar allowlist; landing the heatmap inverse does not +make every grid-backed mark polar-capable. + +### Client chrome (`js/src/50_chartview.ts`) + +Four stacked surfaces. Grid lines are canvas-2D (`ctx.arc` gives rings and +`moveTo`/`lineTo` gives spokes — both cheap), but **axis spines and tick marks +are DOM `
`s with a background colour**, which can express a rectangle and +nothing else. The polar frame circle and its radial ticks therefore move to the +2D chrome canvas. + +### Exporters (`python/xy/_svg.py`, `python/xy/_raster.py`) + +The raster exporter imports ~45 symbols from `_svg` — including `_Scale`, +`_axis_scales`, `layout` and `axis_ticks` — precisely so the two static outputs +share geometry. So the Python projection is written **once** in `_svg.py` and +both exporters inherit it. Two consequences: + +- SVG can express rings as `` and sectors as `A` path commands. +- PDF inherits every polar chart from the SVG: the full-disc clip lowers to + four Bézier quarter-arcs, and the hole/sector clips — emitted as a single + `` clipPath — lower to PDF path ops with SVG's `clip-rule` mapped onto + `W`/`W*`. A clip shape outside that set still fails loudly. +- The raster path still has no arc or wedge paint primitive. Curves are + pre-flattened polylines/polygons, following the `_round_rect_pts` precedent. + It does have one analytic annular-sector **mark clip** in the private display + list: a rectangular outer-radius bbox rejects work cheaply, then the final + pixel blend applies radial/angular containment with supersampled boundary + coverage. The state covers every primitive — chord strokes, fills, symbols, + and heatmap images — so no mark can paint through a hole or missing sector. + A later rectangular clip resets it for unclipped chrome. + +**The affine fast-path trap.** Several emitters bake an affine data→pixel map +into Rust, gated on `sx.affine and sy.affine`. A polar chart on linear axes +satisfies that predicate while being emphatically non-affine. Any polar scale +object must therefore report `affine = False`, or scatter, line smoothing and +grid blits will silently project through a straight-line map. + +## 7. Scope + +**Legal under `coords="polar"`:** `line`, `scatter`, `area`, `bar`, `column`, +`heatmap`, `contour`, and `errorbar` (`POLAR_MARK_KINDS`, +`python/xy/config.py`). + +`area` uses chord-bounded fill geometry, which supports the categorical +composition built by `xy.radar_chart(...)`. Each radar series closes at a full +turn rather than by repeating its first angle, which would sweep the closing +segment backwards through the circle. `bar` and `column` use annular sectors; +`xy.polar_bar_chart(...)` makes that composition explicit, and +`xy.wind_rose(...)` bins directional observations into stacked sector marks +using compass convention `zero="N"` plus clockwise angles. The client subdivides +each sector arc by the span-proportional formula in §5, SVG uses true arc +commands, and raster flattens the same sector to a polygon. + +A bar's angular width may vary per bar. Equal widths ship through the compact +bar path (one scalar width, `BAR_VS`); unequal widths ship four edge columns, +which under polar *are* an annular sector — `(x0, x1)` is the angular span and +`(y0, y1)` the radial one. That is what makes pie and donut charts a +composition rather than a chart type: a slice is one bar carrying its own +width, and `RECT_VS` sweeps it exactly as `BAR_VS` does. + +Everything else is **rejected at payload build** with an error naming the +supported set. In particular, the new polar branch in `SEGMENT_VS` exists for +the allowlisted contour and error-bar trace schemas; it does not authorize +`stem`, `segments`, box-whisker internals, or arbitrary segment/mesh marks. +Likewise the heatmap fragment inverse does not authorize density or triangle +meshes. This is not a limitation to be discovered at render time: a histogram +drawn through the four-edge rect shader would come out chord-edged, and §28 of +the dossier requires that such a decision ship as a recorded refusal rather +than a silent approximation. + +On top of the mark kinds, three compositions are public API rather than +renderers: `xy.radar_chart(categories, ...)` (evenly spaced spokes labelled +with the categories; each series closed at a **full turn**, never by repeating +the first angle, which would sweep the closing segment backwards through the +whole circle), `xy.polar_bar_chart(...)`, and `xy.wind_rose(directions, +speeds)` (Python-side binning like `hist`, stacked bars, compass convention +`zero="N"` + clockwise). + +The pyplot `projection="polar"` surface is landed and corpus-bound: ordinary +`plot`, `scatter`, `fill`, `bar`, heatmap/image, contour, and error-bar calls +route through the polar coordinate system from `subplot`, `add_subplot`, +`axes`, and `subplots(subplot_kw={"projection": "polar"})`. +`set_thetamin`/`set_thetamax` author sector endpoints in degrees, and +`set_rorigin` authors the radial origin. This goes beyond Plotly's native polar +trace set, which has no polar heatmap, contour, or error-bar trace. + +### Tier policy + +Polar traces ship `tier: "direct"`. Line/scatter/area point primitives are +point-capped by validation; heatmap/contour grids are not rejected merely +because their cell count exceeds that point ceiling. The LOD tiers do not +transfer unmodified and must not be silently reused: + +- **M4 decimation** assumes a monotonic x→screen-x column. A spiral is not + monotonic in θ, and multi-turn data revisits the same screen columns. +- **Density binning** in (θ, r) has an area-distortion problem: equal + data-space bins near the origin cover far fewer pixels, so genuinely uniform + density renders as centre-concentrated. + +Both need their own design work in +[`lod-architecture.md`](lod-architecture.md). Any future fallback or ceiling +change must be explicit and reported, per §28. + +## 8. Interaction + +The point-pick buffer uses the same polar transform as the visible scatter +geometry, and the screen→data inverse in §3.2 supplies θ/r readouts. The existing +modebar reset remains available. + +- **Hover** — screen-space nearest-point test, seam-aware per §3.2, with the + readout reporting (θ, r) in the axis's declared unit. +- **Radial zoom** — wheel scales the radial maximum about a **fixed** radial + minimum (Plotly's radial semantics), serialized through the existing + view-state machinery. Anchoring at the cursor's radius was tried first and + rejected from interactive testing: an interior anchor unexpectedly lifts + `r_lo`. A deliberate annulus is instead authored through `hole` or + `r_origin`, so it remains stable across view changes and every renderer uses + the same inverse. + Marks outside the radial range are **culled in the shader** (NaN position, + the same gap semantics NaN data gets): below `r_lo` a mark would reflect + through the centre, and above `r_hi` it would draw past the outer ring into + the rect corners — the GL canvas is the plot rect, so the shader cull is the + client's equivalent of the static exporters' shaped mark clips. Exporter + line and scatter paths still apply the same cull + (`_PolarProjection.position_mask`) rather than relying on their clips: a + below-range point mirrors through the centre to a position *inside* the + visible annulus, and an invalid vertex must produce a data gap rather than a + boundary-clipped chord. A chord with a culled endpoint is therefore dropped + whole in every renderer; at data resolution the gap is under one segment. + Fills and bars + **clamp** their radial span to `[r_lo, r_hi]` instead of culling: their + visible extent at an angle is `[base, top] ∩ [r_lo, r_hi]`, and culling one + endpoint made a radar fill vanish the moment zoom lifted `r_lo` above its + baseline. A span fully outside collapses to zero and draws nothing. +- **Reset** — existing modebar, no change. +- The wheel gesture stays live even though polar's resolved default drag tool + is `none` (pan/box/select are all disabled, so there is nothing to drag). + Only the *user* choosing the `none` tool releases page scroll — the gate + distinguishes that from a chart that simply has no drag tools, because + conflating them made radial zoom dead on arrival. + +Deferred and explicitly disabled rather than half-working: θ pan (rotation), +sector zoom, and box select. Box select's rectangle has no polar meaning; the +right answer is an annulus/sector select, and shipping a rectangle over a disc +in the meantime would be a wrong affordance rather than a partial one. For +reference, Plotly never solved polar wheel zoom at all — it offers radial-axis +drag only — so a small, deliberate model is already ahead of the field. + +## 9. Phase 6/7 status + +The Plotly-parity and axis-depth increments are shipped: + +| Feature | Shipped contract | +|---|---| +| Polar heatmap / contour | Heatmap uses the browser fragment-stage inverse and the shared static inverse raster; contour uses allowlisted projected segments. | +| Sector layout | `theta_axis(sector=...)` (or compatibility `domain=...`) controls clipping, tick trimming, chrome, and a sector-bounding-box layout. Pyplot `set_thetamin`/`set_thetamax` use degrees. Tick trimming is **modular**, matching mark culling: a sector spanning the 0/turn seam (`(300, 420)`, or the compass-natural `(-30, 30)`) keeps the authored ticks on the far side of the seam, because a data point at that same angle plots inside the sector. | +| Hole / r-origin | `r_axis(hole=...)` and `r_axis(origin=...)` implement the §3 scale-coordinate formula and inverse; authored together they fail validation. | +| Categorical θ axis | Category-index coordinates stay on the wire and are mapped evenly across the full turn or authored sector. | +| Log / symlog radial scale | Radius normalization, inverse hit testing, chrome, and static export operate in scale-coordinate space; log autorange remains strictly positive. | +| Polygonal grid | `theta_axis(grid_shape="linear")` joins spoke intersections into polygonal radial rings. | +| Polar error bars | The `errorbar` trace schema uses the polar segment branch with joint radial clipping. Generic segment/mesh support did not ship. | +| pyplot `projection="polar"` | Factories plus theta/r controls and the allowlisted mark families route into the same core polar figure. | +| Angular tick text | `theta_axis(format=...)` wins over the built-in degree/radian text in all three renderers. It used to lose — the angular branch ran first and overwrote the authored spec — so a `format=` on a polar angular axis was accepted and ignored. Authored `tick_labels` still win over both, and a categorical θ axis keeps its category names. | +| Legend beside the disc | A polar figure with a legend reserves a gutter and places the legend in it (§3, layout). Zero-width wedges are legal at the mark layer, so a 0% pie/gauge slice draws nothing instead of raising. | + +The remaining work stays explicitly disabled or direct-only: + +| Deferred feature | Notes | +|---|---| +| Polar `rule` / `band` annotations | Point-anchored annotations (`text`, `label`, `marker`, `arrow`, `callout`) project jointly through the transform in all three renderers (`_dataPxPoint` in the client). A rule/band is genuinely different geometry on a disc — a θ rule is a spoke, an r rule is a ring, and a band is an annulus or sector — so payload build rejects them rather than drawing a Cartesian bar. | +| Secondary θ / r axes | A polar figure carries exactly one angular and one radial axis. A second axis bound and validated like a Cartesian one while the transform read only the primary pair, so an overlapping secondary range drew *pixel-identical* to the primary and a disjoint one was culled away — with a straight Cartesian spine still drawn in the gutter of a disc. Payload build rejects any axis id outside `{"x", "y"}` under `coords="polar"`. | +| Non-linear θ scale | The angle must be linear. `theta_axis(type_="log"/"symlog")` was accepted and honoured by exactly one renderer — the client scaled θ before projecting while the static exporters ignored the scale outright — so one figure pointed the same datum at opposite sides of the disc depending on where it was drawn. A log or symlog **radial** scale is supported (§3) and unaffected. | +| `reverse` on the angular axis | The Cartesian flip switch has no polar meaning; the angular axis spells direction of travel as `theta_axis(direction=...)`. `reverse=True` rode the wire and every renderer ignored it, so payload build rejects it. `r_axis(reverse=True)` is honoured. | +| Time angular axis | An instant has no angle. Datetime theta was pinned to a fixed 0..2pi range regardless of the data, so consecutive days wrapped the disc billions of times under radian spoke labels. Payload build refuses on the *resolved* column kind, not just a declared `type_="time"`. A time **radial** axis is supported, and autoranges per §2.1 rather than from epoch zero. | +| Minor ticks (`minor_tick_values`, `minor_style`) | Neither axis draws minor rings or minor spokes: the client skips the whole minor pass under polar (`!hideX && !polarGeom`) and so do both exporters (`if polar is not None: break`). The values and their style rode the wire and were dropped by all three. `xy.theta_axis`/`xy.r_axis` refuse them and point at `tick_values`. Finer rings are real geometry work, not a formatting toggle. | +| Rim label collision controls (`tick_label_min_gap`, `tick_label_strategy` in `auto`/`hide`/`rotate`/`stagger`/`preserve`) | The collision pass is edge-relative — it thins a ladder of labels along one side — and a rim has no side. Angular labels ring the disc and radial labels are stride-thinned to what the `POLAR_RLABEL_DEG` spoke holds, so a minimum gap and a collision strategy had nothing to feed. Refused; `off` (hide the labels) and `none` (hide the axis) are honoured, and `tick_count`/`tick_values` remain the deliberate way to thin. | +| `tick_label_anchor` | Polar labels anchor radially: outward around the rim, outward along the label spoke. An edge-relative anchor had nothing to act on. Refused; `tick_label_angle` rotates the text and is honoured. | + +The four rows above are refused **on the documented polar surface** +(`xy.theta_axis` / `xy.r_axis`), not at payload build, and that placement is +deliberate. `xy.pyplot`'s polar projection assembles its axis out of a property +bag it does not fully own: every Axes carries an rcParam-derived `minor_style`, +and `minorticks_on()` / `tick_params(ha=)` add more. Refusing at payload build +would turn `projection="polar"` into an error over defaults nobody authored, so +`components._polar_axis_kwargs` **drops** them for that adapter — the same thing +all three renderers already do with the values — and +[`../matplotlib/compat.md`](../matplotlib/compat.md) records the drop. A +hand-authored polar axis is the case that must hear about it, and does. +| Cartesian `coords` on a polar helper | `coords` is the only thing making `polar_chart`, `pie_chart`, `radar_chart`, `polar_bar_chart` and `wind_rose` polar, so an explicit `coords="cartesian"` silently returned an axis-less cartesian figure *and* re-opened every refusal in this table (`_validate_coords` returns early for a non-polar figure). The helpers now refuse it. | +| Polar LOD | §7. Exit criterion for `scatterpolargl`-scale claims. Point traces remain direct and capped. | +| Polar facets / animation | Untouched by this increment; no support claim is made. | +| Angular navigation / sector selection | Authored sector limits ship; interactive θ pan, sector zoom, annulus/sector selection, and rectangular box select remain disabled per §8. | diff --git a/spec/design/renderer-architecture.md b/spec/design/renderer-architecture.md index 4822d4cd..0c67abc1 100644 --- a/spec/design/renderer-architecture.md +++ b/spec/design/renderer-architecture.md @@ -27,10 +27,10 @@ relative mass, not as a budget (see §3 on why a line count failed as a metric). | `10_colormaps.ts` | 51 | The `COLORMAP_STOPS` table (§36 CVD-safe defaults) as compact RGB stop lists, and `buildLutData`, which linearly interpolates a stop list into the 256-texel RGBA LUT uploaded once per colormap as a texture. | | `20_theme.ts` | 163 | Resolves chrome and mark colors: arbitrary CSS color expressions and `--chart-*` custom properties are resolved against a live probe element into f32 RGBA for GL, with a fallback on unparseable input. Also owns `XY_CHROME_CSS` and its one-time stylesheet injection. | | `30_ticks.ts` | 224 | CPU-side tick generation in f64 for linear, log, category and time axes, plus every axis/colorbar label formatter (automatic and `format=`-driven). Specified in §6. | -| `40_gl.ts` | 829 | WebGL2 primitives: shader compile/link, `makeProgram` with its per-program uniform-location memo (R1), the fixed `ATTR_SLOTS` attribute-slot table bound at link time, and the shader inventory itself. The only module that is GPU-API-specific by design (§4). | +| `40_gl.ts` | 829 | WebGL2 primitives: shader compile/link, `makeProgram` with its per-program uniform-location memo (R1), the fixed `ATTR_SLOTS` attribute-slot table bound at link time, and the shader inventory itself. It owns the shared Cartesian/polar axis-coordinate preamble, including sector/hole/origin uniforms, the heatmap fragment-stage polar inverse, the allowlisted contour/error-bar segment projection, and the annular-sector fragment clip shared by every legal polar mark and pick path. The only module that is GPU-API-specific by design (§4). | | `45_lod.ts` | 567 | View-dependent level-of-detail orchestration, deliberately chart-agnostic: tier selection, drill enter/exit hysteresis (`LOD_DRILL_EXIT_FACTOR`), cross-tier fades, and the retained tier caches. Calls back into `view._draw*` rather than drawing itself, which is the seam tests intercept. | | `46_worker.ts` | 103 | The standalone density re-bin worker: a worker source string carried inside the bundle and booted from a Blob URL. Re-bins the retained sample off the main thread — counts plus, for channel-bearing traces, the per-cell mean point color (same linear-light law as the kernel, LOD doc §2) — so kernel-less (`to_html`) density charts refine on zoom instead of stretching the overview texture; absence of workers falls back to stretching. | -| `50_chartview.ts` | 4175 | The `ChartView` class: the four drawing surfaces, scale/view state, chrome (background, grid, axes, legend, colorbar), GL buffer and VAO management (R2), and pick orchestration. Modules 51–54 extend this same class. | +| `50_chartview.ts` | 4175 | The `ChartView` class: the four drawing surfaces, scale/view state, chrome (background, grid, axes, legend, colorbar), GL buffer and VAO management (R2), and pick orchestration. It resolves the joint polar geometry (sector layout, categorical θ, scaled radius, hole/origin, circular/polygonal grid), uploads the corresponding uniforms, and shares its inverse with hover. Modules 51–54 extend this same class. | | `51_annotations.ts` | 591 | The 2D overlay canvas above the marks canvas: annotation markers, arrows, shape fills, and collision-nudged labels. Separates canvas shape style keys from label CSS so annotation styling never leaks into the DOM label. | | `52_tooltip.ts` | 321 | Hit → source row → tooltip DOM. Anchors the tooltip at the picked point's data coordinates and reprojects it every draw ([interaction.md](../api/interaction.md) §7). Renders the local f32-decoded row immediately, then replaces it with the kernel's exact f64 row when that reply arrives (sequence- and `drill_seq`-guarded); composes text nodes, never HTML. | | `53_interaction.ts` | 1820 | The entire user-facing interaction surface: pointer/drag/wheel wiring, crosshair, box select, box zoom, lasso, the modebar and its export menu, and the animated pan/zoom view state machine. The gesture→action mapping, the modebar tool inventory and the `interaction_config` switches are specified in [interaction.md](../api/interaction.md) §2 and §5. | @@ -57,6 +57,14 @@ relative mass, not as a budget (see §3 on why a line count failed as a metric). - **Uniform-only pan/zoom**: geometry is static offset-encoded f32; view changes touch two vec2 uniforms per mark (`_map`). This is why interaction is cheap; nothing below may regress it. +- **Coordinate-system seam, including non-vertex grids:** point/line/area/bar + programs call the shared joint polar projection after ordinary axis decode + and scale. Heatmap remains a fullscreen quad and performs the inverse joint + map in its fragment shader instead; the CPU exporter has the same bounded + inverse-raster contract. `SEGMENT_VS` has a polar path only for the + allowlisted `contour` and `errorbar` trace schemas. This does **not** imply + generic segment, density-grid, rectangle, or mesh support under + `coords="polar"`; Python's `POLAR_MARK_KINDS` remains the recorded gate. - **Bounded inputs in the aggregated tiers**: decimated ships M4 output bounded by the plot's pixel width; density ships a fixed grid. Direct-tier traces still ship O(N) columns — the bound there is the tier threshold, not @@ -147,7 +155,9 @@ Ordered by how much each compounds as kinds multiply. (`GLSL_COMMON`), not a shader framework. Deliberately stop there — a "shader graph" is over-engineering at this scale. The original "fine at 5 kinds" deferral rationale has expired; the trigger is now a third - corner-expanding VS. + corner-expanding VS. The polar branch in `SEGMENT_VS` is deliberately + schema-gated above this layer: only contour/error-bar traces are admitted, + so it must not be cited as generic polar segment or mesh support. - **R7 — DPR/zoom *changes* aren't observed.** ✅ **Done.** `_armDprWatch` re-arms a one-shot `matchMedia('(resolution: Ndppx)')` per dpr value; `_resize` re-reads devicePixelRatio so a pure-DPR change re-derives diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 3b4ceebd..4d9080ba 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -420,9 +420,9 @@ The reassembled bytes are identical to the source blob, which is what keeps Two independent version constants: -- **Renderer/spec protocol.** `PROTOCOL_VERSION = 10` (`python/xy/config.py`) +- **Renderer/spec protocol.** `PROTOCOL_VERSION = 12` (`python/xy/config.py`) rides every first-paint spec as `spec["protocol"]`; the client's - `PROTOCOL = 10` (`js/src/00_header.ts`) is checked in the `ChartView` + `PROTOCOL = 12` (`js/src/00_header.ts`) is checked in the `ChartView` constructor. A mismatch replaces the chart element with "update the xy package and restart the kernel" and throws. Requests and replies carry no version of their own — the handshake happens once, at first paint, before @@ -453,7 +453,17 @@ Two independent version constants: axes-fraction `y` and pixel `pad` occupy a two-f32 raw geometry column referenced by `geometry`, keeping numeric data out of JSON. A cached v9 client would ignore the field and silently omit non-center slots and their - placement, so the v10 mismatch rejects it before rendering. + placement, so the v10 mismatch rejects it before rendering. v11 adds the + top-level `coords: "polar"` mode plus `theta_unit`, `theta_zero`, and + `theta_direction` on the angular axis. A cached v10 client would accept the + same x/y columns and render a plausible Cartesian chart, so it must be + rejected. v12 adds resolved `sector`/`grid_shape` fields on the angular axis + and `hole` plus optional `r_origin` on the radial axis, and makes + heatmap/contour/error-bar trace schemas legal in polar coordinates. A cached + v11 client would silently draw a full circular, centre-origin view and route + those grid/segment traces through their Cartesian paths. The v12 handshake + rejects that stale bundle before any of those compatible-looking wrong + pictures can appear. - **Transport frame.** `FRAME_MAGIC` `"XYBF"` with `FRAME_VERSION = 1` versions the binary envelope separately, so the transport and the renderer can evolve without coupling. diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 94cb1230..96164cc7 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,6 +4,35 @@ This changelog records changes to the upstream compatibility target and to the meaning of xy's compatibility levels. It complements the project changelog, which covers user-visible releases across the whole package. +## Polar projection depth — 2026-07-28 (Matplotlib 3.11.1 reference) + +- Polar heatmap/image and contour now use the core polar grid/segment paths, + and `errorbar` uses the same allowlisted projected-segment contract. The + heatmap samples through a fragment-stage screen→(θ, r) inverse rather than + stretching a Cartesian image into the disc. +- `set_thetamin`/`set_thetamax` and their getters use Matplotlib's degree + surface while the core axis remains in its declared angular unit. + `set_rorigin`/`get_rorigin` route to the radial-origin geometry, including + annular views. +- Categorical θ, positive log-r autorange, symlog radius, partial-sector + layout/clipping, and the core polygonal radial grid are shared across HTML, + PNG, and SVG. +- This does not generalize every segment or mesh artist to polar. Polar + rules/spans, LOD, facets/animation, angular navigation/selection, and the + stateful `plt.polar`/`plt.thetagrids`/`plt.rgrids` wrappers remain deferred + or absent and fail loudly where a Cartesian fallback would be misleading. +- Minor ticks, minor tick styling, tick-label horizontal alignment, and the + tick-label collision strategies are now recorded as **dropped** on a polar + Axes rather than left undocumented. No renderer draws minor rings or spokes, + and rim labels have neither an edge-relative collision pass nor an anchor. + A hand-authored `xy.theta_axis`/`xy.r_axis` refuses them; the shim drops them, + because every Axes carries an rcParam-derived minor style and refusing would + break `projection="polar"` over a default nobody authored. +- A polar radial axis carrying datetimes now autoranges from its data instead of + from epoch zero, which had compressed every modern instant into a hairline + ring at the rim. `set_rlim` and an explicit radial `margin` are unaffected and + still win. + ## Box and violin default geometry — 2026-07-26 (Matplotlib 3.11.1 reference) - `xy.pyplot.boxplot` no longer routes its default call through the native diff --git a/spec/matplotlib/compat-matrix.md b/spec/matplotlib/compat-matrix.md index 5755d472..b0ff74b1 100644 --- a/spec/matplotlib/compat-matrix.md +++ b/spec/matplotlib/compat-matrix.md @@ -8,7 +8,7 @@ Corpus links are executable examples and are checked for every supported method. | Family | Level | Methods | Executable corpus | |---|---|---:|---:| -| Basic | equivalent semantics | 22 | 47 | +| Basic | equivalent semantics | 22 | 48 | | Spans | exact geometry | 5 | 4 | | Spectral | equivalent semantics | 9 | 2 | | Statistics | equivalent semantics | 5 | 2 | @@ -25,16 +25,16 @@ Corpus links are executable examples and are checked for every supported method. Approximation level: **equivalent semantics**. -- `plot` — [`01_basic_line.py`](../../tests/pyplot/corpus/01_basic_line.py), [`02_plot_fmt_red_dashed.py`](../../tests/pyplot/corpus/02_plot_fmt_red_dashed.py), [`03_plot_fmt_green_circles.py`](../../tests/pyplot/corpus/03_plot_fmt_green_circles.py), [`04_plot_fmt_cycle_dashdot_square.py`](../../tests/pyplot/corpus/04_plot_fmt_cycle_dashdot_square.py), [`05_multi_series_one_call.py`](../../tests/pyplot/corpus/05_multi_series_one_call.py), [`06_implicit_x.py`](../../tests/pyplot/corpus/06_implicit_x.py), [`07_labels_title_legend_grid.py`](../../tests/pyplot/corpus/07_labels_title_legend_grid.py), [`08_xlim_ylim.py`](../../tests/pyplot/corpus/08_xlim_ylim.py), [`09_log_scale.py`](../../tests/pyplot/corpus/09_log_scale.py), [`20_fill_between_band.py`](../../tests/pyplot/corpus/20_fill_between_band.py), [`23_axhline_axvline.py`](../../tests/pyplot/corpus/23_axhline_axvline.py), [`24_axvspan_band.py`](../../tests/pyplot/corpus/24_axvspan_band.py), [`25_annotate_text.py`](../../tests/pyplot/corpus/25_annotate_text.py), [`26_twinx_dual_axis.py`](../../tests/pyplot/corpus/26_twinx_dual_axis.py), [`27_subplots_2x2_mixed.py`](../../tests/pyplot/corpus/27_subplots_2x2_mixed.py), [`28_subplots_figsize.py`](../../tests/pyplot/corpus/28_subplots_figsize.py), [`29_implicit_state_savefig.py`](../../tests/pyplot/corpus/29_implicit_state_savefig.py), [`30_savefig_html.py`](../../tests/pyplot/corpus/30_savefig_html.py), [`31_rcparams_figsize.py`](../../tests/pyplot/corpus/31_rcparams_figsize.py), [`33_set_data_mutation.py`](../../tests/pyplot/corpus/33_set_data_mutation.py), [`34_gray_string_color.py`](../../tests/pyplot/corpus/34_gray_string_color.py), [`35_tab_colors.py`](../../tests/pyplot/corpus/35_tab_colors.py), [`36_color_cycle_c0_c9.py`](../../tests/pyplot/corpus/36_color_cycle_c0_c9.py), [`37_markers_only_fmt.py`](../../tests/pyplot/corpus/37_markers_only_fmt.py), [`38_close_all_hygiene.py`](../../tests/pyplot/corpus/38_close_all_hygiene.py), [`39_multiple_figures.py`](../../tests/pyplot/corpus/39_multiple_figures.py), [`40_subplots_row_sharex.py`](../../tests/pyplot/corpus/40_subplots_row_sharex.py), [`41_line_kwargs.py`](../../tests/pyplot/corpus/41_line_kwargs.py), [`43_grid_html_suptitle.py`](../../tests/pyplot/corpus/43_grid_html_suptitle.py), [`44_subplot_classic.py`](../../tests/pyplot/corpus/44_subplot_classic.py), [`45_xticks_positions_labels.py`](../../tests/pyplot/corpus/45_xticks_positions_labels.py) +- `plot` — [`01_basic_line.py`](../../tests/pyplot/corpus/01_basic_line.py), [`02_plot_fmt_red_dashed.py`](../../tests/pyplot/corpus/02_plot_fmt_red_dashed.py), [`03_plot_fmt_green_circles.py`](../../tests/pyplot/corpus/03_plot_fmt_green_circles.py), [`04_plot_fmt_cycle_dashdot_square.py`](../../tests/pyplot/corpus/04_plot_fmt_cycle_dashdot_square.py), [`05_multi_series_one_call.py`](../../tests/pyplot/corpus/05_multi_series_one_call.py), [`06_implicit_x.py`](../../tests/pyplot/corpus/06_implicit_x.py), [`07_labels_title_legend_grid.py`](../../tests/pyplot/corpus/07_labels_title_legend_grid.py), [`08_xlim_ylim.py`](../../tests/pyplot/corpus/08_xlim_ylim.py), [`09_log_scale.py`](../../tests/pyplot/corpus/09_log_scale.py), [`20_fill_between_band.py`](../../tests/pyplot/corpus/20_fill_between_band.py), [`23_axhline_axvline.py`](../../tests/pyplot/corpus/23_axhline_axvline.py), [`24_axvspan_band.py`](../../tests/pyplot/corpus/24_axvspan_band.py), [`25_annotate_text.py`](../../tests/pyplot/corpus/25_annotate_text.py), [`26_twinx_dual_axis.py`](../../tests/pyplot/corpus/26_twinx_dual_axis.py), [`27_subplots_2x2_mixed.py`](../../tests/pyplot/corpus/27_subplots_2x2_mixed.py), [`28_subplots_figsize.py`](../../tests/pyplot/corpus/28_subplots_figsize.py), [`29_implicit_state_savefig.py`](../../tests/pyplot/corpus/29_implicit_state_savefig.py), [`30_savefig_html.py`](../../tests/pyplot/corpus/30_savefig_html.py), [`31_rcparams_figsize.py`](../../tests/pyplot/corpus/31_rcparams_figsize.py), [`33_set_data_mutation.py`](../../tests/pyplot/corpus/33_set_data_mutation.py), [`34_gray_string_color.py`](../../tests/pyplot/corpus/34_gray_string_color.py), [`35_tab_colors.py`](../../tests/pyplot/corpus/35_tab_colors.py), [`36_color_cycle_c0_c9.py`](../../tests/pyplot/corpus/36_color_cycle_c0_c9.py), [`37_markers_only_fmt.py`](../../tests/pyplot/corpus/37_markers_only_fmt.py), [`38_close_all_hygiene.py`](../../tests/pyplot/corpus/38_close_all_hygiene.py), [`39_multiple_figures.py`](../../tests/pyplot/corpus/39_multiple_figures.py), [`40_subplots_row_sharex.py`](../../tests/pyplot/corpus/40_subplots_row_sharex.py), [`41_line_kwargs.py`](../../tests/pyplot/corpus/41_line_kwargs.py), [`43_grid_html_suptitle.py`](../../tests/pyplot/corpus/43_grid_html_suptitle.py), [`44_subplot_classic.py`](../../tests/pyplot/corpus/44_subplot_classic.py), [`45_xticks_positions_labels.py`](../../tests/pyplot/corpus/45_xticks_positions_labels.py), [`55_polar_projection.py`](../../tests/pyplot/corpus/55_polar_projection.py) - `errorbar` — [`47_statistical_families.py`](../../tests/pyplot/corpus/47_statistical_families.py) -- `scatter` — [`10_scatter_basic.py`](../../tests/pyplot/corpus/10_scatter_basic.py), [`11_scatter_color_array_cmap.py`](../../tests/pyplot/corpus/11_scatter_color_array_cmap.py), [`12_scatter_size_array.py`](../../tests/pyplot/corpus/12_scatter_size_array.py), [`13_scatter_edgecolors.py`](../../tests/pyplot/corpus/13_scatter_edgecolors.py), [`27_subplots_2x2_mixed.py`](../../tests/pyplot/corpus/27_subplots_2x2_mixed.py) +- `scatter` — [`10_scatter_basic.py`](../../tests/pyplot/corpus/10_scatter_basic.py), [`11_scatter_color_array_cmap.py`](../../tests/pyplot/corpus/11_scatter_color_array_cmap.py), [`12_scatter_size_array.py`](../../tests/pyplot/corpus/12_scatter_size_array.py), [`13_scatter_edgecolors.py`](../../tests/pyplot/corpus/13_scatter_edgecolors.py), [`27_subplots_2x2_mixed.py`](../../tests/pyplot/corpus/27_subplots_2x2_mixed.py), [`55_polar_projection.py`](../../tests/pyplot/corpus/55_polar_projection.py) - `step` — [`22_step.py`](../../tests/pyplot/corpus/22_step.py) - `loglog` — [`51_basic_2d_aliases.py`](../../tests/pyplot/corpus/51_basic_2d_aliases.py) - `semilogx` — [`54_plotting_method_coverage.py`](../../tests/pyplot/corpus/54_plotting_method_coverage.py) - `semilogy` — [`54_plotting_method_coverage.py`](../../tests/pyplot/corpus/54_plotting_method_coverage.py) - `fill_between` — [`20_fill_between_band.py`](../../tests/pyplot/corpus/20_fill_between_band.py) - `fill_betweenx` — [`51_basic_2d_aliases.py`](../../tests/pyplot/corpus/51_basic_2d_aliases.py) -- `bar` — [`14_bar_categories.py`](../../tests/pyplot/corpus/14_bar_categories.py), [`15_bar_stacked_bottom.py`](../../tests/pyplot/corpus/15_bar_stacked_bottom.py), [`27_subplots_2x2_mixed.py`](../../tests/pyplot/corpus/27_subplots_2x2_mixed.py), [`32_xticks_rotation.py`](../../tests/pyplot/corpus/32_xticks_rotation.py), [`42_tick_params_rotation.py`](../../tests/pyplot/corpus/42_tick_params_rotation.py) +- `bar` — [`14_bar_categories.py`](../../tests/pyplot/corpus/14_bar_categories.py), [`15_bar_stacked_bottom.py`](../../tests/pyplot/corpus/15_bar_stacked_bottom.py), [`27_subplots_2x2_mixed.py`](../../tests/pyplot/corpus/27_subplots_2x2_mixed.py), [`32_xticks_rotation.py`](../../tests/pyplot/corpus/32_xticks_rotation.py), [`42_tick_params_rotation.py`](../../tests/pyplot/corpus/42_tick_params_rotation.py), [`55_polar_projection.py`](../../tests/pyplot/corpus/55_polar_projection.py) - `barh` — [`16_barh.py`](../../tests/pyplot/corpus/16_barh.py) - `bar_label` — [`53_matplotlib_311_plotting.py`](../../tests/pyplot/corpus/53_matplotlib_311_plotting.py) - `grouped_bar` — [`53_matplotlib_311_plotting.py`](../../tests/pyplot/corpus/53_matplotlib_311_plotting.py) @@ -46,7 +46,7 @@ Approximation level: **equivalent semantics**. - `broken_barh` — [`51_basic_2d_aliases.py`](../../tests/pyplot/corpus/51_basic_2d_aliases.py) - `vlines` — [`51_basic_2d_aliases.py`](../../tests/pyplot/corpus/51_basic_2d_aliases.py) - `hlines` — [`51_basic_2d_aliases.py`](../../tests/pyplot/corpus/51_basic_2d_aliases.py) -- `fill` — [`54_plotting_method_coverage.py`](../../tests/pyplot/corpus/54_plotting_method_coverage.py) +- `fill` — [`54_plotting_method_coverage.py`](../../tests/pyplot/corpus/54_plotting_method_coverage.py), [`55_polar_projection.py`](../../tests/pyplot/corpus/55_polar_projection.py) ### Spans diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 77b3170b..59bf0e2e 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -77,6 +77,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `xticks(positions, labels, rotation=)` / `tick_params(labelrotation=)` | Exact positions and strings render in browser, PNG, and SVG | | `twinx()`, `secondary_xaxis()`, `secondary_yaxis()` | second data axes and linked tick-only secondary axes with callable forward/inverse conversions. Secondary-axis ticks are evenly spaced conversions of the primary domain (not Matplotlib's secondary-unit locators) and currently reach the interactive HTML client only — PNG/SVG export does not draw them yet | | `fig, ax = plt.subplots()`; `plt.subplots(n, m, figsize=, dpi=, squeeze=, sharex=, sharey=)` | Grid renders as CSS-grid HTML and stitched PNG/SVG; shared axes use common domains and live linked pan/zoom. `Figure.subplots_adjust(left=, right=, top=, bottom=, wspace=, hspace=)` moves the SubplotParams frame: the grid resolves to explicit figure rectangles and every exporter (HTML, PNG, SVG) positions panels at those rectangles | +| `subplot(projection="polar")`; `add_subplot(..., polar=True)`; `axes(projection="polar")`; `subplots(subplot_kw={"projection": "polar"})` | Ordinary `plot`, `scatter`, `fill`, `bar`, heatmap/image, contour, and error-bar calls render through the core polar coordinate system in HTML, PNG, and SVG. The PolarAxes controls `set_theta_zero_location`, `set_theta_direction`, `set_theta_offset`, `set_thetagrids`, `set_thetamin`/`set_thetamax` (degrees), `set_rlim`, `set_rticks`, `set_rorigin`, and their theta/r limit accessors route into the same angular/radial axes. Categorical θ and log/symlog radial scales use that core transform as well. Polar `axhline`/`axvline` and span geometry, LOD, facets/animation, angular navigation/selection, and the stateful `plt.polar`/`plt.thetagrids`/`plt.rgrids` convenience wrappers remain outside this surface and fail or remain absent rather than drawing a Cartesian approximation. **Silently dropped on a polar Axes:** minor ticks and their style (`minorticks_on`, `minor` `tick_params`, `set_minor_locator`), tick-label horizontal alignment (`tick_params(ha=)`), and the tick-label collision strategies — no renderer draws minor rings or spokes, and rim labels have no edge-relative collision pass or anchor (`spec/design/polar-axes.md` §9). They are dropped rather than refused because every Axes carries an rcParam-derived `minor_style`, so refusing would break the projection over a default nobody authored; a hand-authored `xy.theta_axis`/`xy.r_axis` refuses them instead. | | `Axes.get_position(original=False)` and the rendered axes frame | Supported subplot and free-form axes report their live figure rectangle and render on it. `original=True` returns the allocated rectangle before an adjustable-box aspect correction; the default applies the correction and its anchor, matching Matplotlib. Grid cells resolve under the live SubplotParams (`wspace`/`hspace`, width/height ratios), while explicit `add_axes`/`set_position` rectangles take precedence until a later layout adjustment. Titles, top-side x axes (`matshow`), and secondary-y gutters grow the surrounding allocation instead of moving the frame. **Known exception:** an axes carrying a colorbar keeps label-aware margins because xy and Matplotlib currently reserve the colorbar strip through different layout paths | | `fig.add_subplot(2, 2, 1)` / `add_subplot(221)` | | | `plt.subplot_mosaic([['A','B'],['C','C']])` / `Figure.subplot_mosaic` | Row sequences (a list of equal-length label strings, or nested label lists) resolve to a uniform grid; each distinct label, in first-appearance order, binds to the next cell, returning `(fig, {label: Axes})` with `figsize=`/`dpi=` sizing the figure. Repeated labels do not span and `'.'` does not blank a cell — the grid keeps one axes per cell — and Matplotlib's single-string forms (`'AB;CC'`, newline-separated blocks) are not parsed into rows | @@ -97,11 +98,12 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent ## Outside 2-D chart-method compatibility -Polar/3D projections, `FuncAnimation`, arbitrary third-party Artist graphs, -non-affine transform graphs, and blitting are not part of this 2-D chart-method -target. Bounded shim-owned `Axes` Artist views, children, containers, removal, -affine data transforms, coordinate spaces, and linked secondary axes are -supported. +Three-dimensional, ternary, geographic, and custom projections, +`FuncAnimation`, arbitrary third-party Artist graphs, non-affine transform +graphs, and blitting are not part of this 2-D chart-method target. Polar is the +supported non-Cartesian projection with the boundary above. Bounded shim-owned +`Axes` Artist views, children, containers, removal, affine data transforms, +coordinate spaces, and linked secondary axes are supported. Unknown keyword arguments on supported calls raise `TypeError` naming the offending keyword. Known material options that the native marks cannot honor diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index 32608962..746d6398 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -476,7 +476,9 @@ treated as ordinary shim bugs. ### Projection and domain systems - 3-D plotting and `mplot3d`. -- Polar, radar, ternary, geographic and custom projection registration. +- Ternary, geographic and custom projection registration. Polar axes are + supported through `projection="polar"`; the remaining polar feature gaps are + tracked in `spec/design/polar-axes.md` §9. - Cartopy/Basemap integration. - Full TeX/MathText/PGF layout parity and Matplotlib font-manager behavior. diff --git a/src/lib.rs b/src/lib.rs index 75bee755..53f288bd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -83,7 +83,7 @@ unsafe fn borrowed_byte_spans<'a>( /// ABI version — bumped on any signature change. The Python wrapper checks this /// at load time and refuses a mismatched library loudly (§33 comm-versioning /// rule, applied to the in-process boundary). -pub const ABI_VERSION: u32 = 46; +pub const ABI_VERSION: u32 = 47; const FACTORIZE_CAPACITY_EXCEEDED: usize = usize::MAX - 1; #[no_mangle] diff --git a/src/raster.rs b/src/raster.rs index 34964553..5995295f 100644 --- a/src/raster.rs +++ b/src/raster.rs @@ -33,9 +33,149 @@ const OP_AFFINE_POINTS: u8 = 14; const OP_AFFINE_CHANNEL_POINTS: u8 = 15; const OP_STROKED_TRIANGLES: u8 = 16; const OP_STYLED_TEXT: u8 = 17; +const OP_POLAR_CLIP: u8 = 18; const SS: usize = 4; // vertical supersamples per scanline for polygon AA +/// Analytic annular-sector clip carried by the display-list state. +/// +/// The ordinary clip remains a rectangular bbox for cheap work rejection. +/// This second predicate is applied at the final pixel blend, so every mark +/// primitive shares one clip: fills, chord strokes, symbols, and images cannot +/// paint through a polar hole or missing sector. +#[derive(Clone, Copy)] +struct PolarClip { + cx: f32, + cy: f32, + inner: f32, + outer: f32, + start: [f32; 2], + end: [f32; 2], + direction: f32, + wide: bool, + full: bool, +} + +impl PolarClip { + fn new(cx: f32, cy: f32, inner: f32, outer: f32, start: f32, sweep: f32) -> Option { + if ![cx, cy, inner, outer, start, sweep] + .into_iter() + .all(f32::is_finite) + || inner < 0.0 + || outer < inner + || sweep == 0.0 + { + return None; + } + let span = sweep.abs(); + Some(Self { + cx, + cy, + inner, + outer, + start: [start.cos(), start.sin()], + end: [(start + sweep).cos(), (start + sweep).sin()], + direction: sweep.signum(), + wide: span > std::f32::consts::PI, + full: span >= std::f32::consts::TAU * (1.0 - 1e-6), + }) + } + + #[inline] + fn contains(&self, x: f32, y: f32) -> bool { + let v = [x - self.cx, self.cy - y]; // math coordinates: +y is up + let radius2 = v[0] * v[0] + v[1] * v[1]; + if radius2 < self.inner * self.inner || radius2 > self.outer * self.outer { + return false; + } + if self.full { + return true; + } + let from_start = self.direction * (self.start[0] * v[1] - self.start[1] * v[0]); + let before_end = self.direction * (v[0] * self.end[1] - v[1] * self.end[0]); + if self.wide { + from_start >= 0.0 || before_end >= 0.0 + } else { + from_start >= 0.0 && before_end >= 0.0 + } + } + + #[inline] + fn near_boundary(&self, x: f32, y: f32) -> bool { + let v = [x - self.cx, self.cy - y]; + let radius2 = v[0] * v[0] + v[1] * v[1]; + let radial_band = |radius: f32| { + let low = (radius - 1.0).max(0.0); + radius2 >= low * low && radius2 <= (radius + 1.0) * (radius + 1.0) + }; + if radial_band(self.outer) || (self.inner > 0.0 && radial_band(self.inner)) { + return true; + } + if self.full { + return false; + } + [self.start, self.end].into_iter().any(|edge| { + let along = edge[0] * v[0] + edge[1] * v[1]; + let perpendicular = edge[0] * v[1] - edge[1] * v[0]; + perpendicular.abs() <= 1.0 && along >= self.inner - 1.0 && along <= self.outer + 1.0 + }) + } + + #[inline] + fn pixel_coverage(&self, x: usize, y: usize) -> f32 { + // A valid Python hole may be closer to 1 than f32 can distinguish at + // the requested device radius. Its serialized inner/outer radii then + // coincide. That is an empty visible annulus, not a malformed command + // stream: mask marks completely and let the later rectangular clip + // reset state for chrome. + if self.inner >= self.outer { + return 0.0; + } + let center = (x as f32 + 0.5, y as f32 + 0.5); + if !self.near_boundary(center.0, center.1) { + return if self.contains(center.0, center.1) { + 1.0 + } else { + 0.0 + }; + } + // Only boundary pixels pay for supersampling. Four samples per axis + // match polygon-fill AA and keep the clip edge stable at every scale. + let mut covered = 0usize; + for sy in 0..SS { + let py = y as f32 + (sy as f32 + 0.5) / SS as f32; + for sx in 0..SS { + let px = x as f32 + (sx as f32 + 0.5) / SS as f32; + covered += usize::from(self.contains(px, py)); + } + } + covered as f32 / (SS * SS) as f32 + } +} + +#[inline] +fn apply_polar_clip( + polar_clip: Option, + x: usize, + y: usize, + mut rgba: [u8; 4], +) -> Option<[u8; 4]> { + let Some(clip) = polar_clip else { + return Some(rgba); + }; + let coverage = clip.pixel_coverage(x, y); + if coverage <= 0.0 { + return None; + } + if coverage < 1.0 { + rgba[3] = (rgba[3] as f32 * coverage + 0.5) as u8; + if rgba[3] == 0 { + return None; + } + } + Some(rgba) +} + /// Straight-alpha RGBA8 framebuffer. Static chart export paints an opaque /// background first, so keeping the working canvas in its final byte format /// avoids a 16-byte-per-pixel float canvas plus a full-frame conversion pass. @@ -53,6 +193,7 @@ struct Canvas<'a> { px: &'a mut [u8], opaque: bool, clip: [f32; 4], // x0, y0, x1, y1 + polar_clip: Option, } impl<'a> Canvas<'a> { @@ -67,6 +208,7 @@ impl<'a> Canvas<'a> { px, opaque: opaque_white, clip: [0.0, 0.0, w as f32, h as f32], + polar_clip: None, } } @@ -93,8 +235,29 @@ impl<'a> Canvas<'a> { ); } - #[inline] + #[inline(always)] fn blend_u8(&mut self, x: usize, y: usize, rgba: [u8; 4]) { + // The polar-clip test must stay a one-byte discriminant check: this is + // the per-pixel hot path for every mark on every chart, and routing it + // through `apply_polar_clip(self.polar_clip, ..)` copied the ~40-byte + // `Option` per pixel — a deterministic +17% on cartesian + // PNG export (CodSpeed test_png_export_line_pyplot, 34 -> 40.9 ms). + if self.polar_clip.is_some() { + self.blend_u8_polar_clipped(x, y, rgba); + return; + } + let o = (y * self.w + x) * self.channels(); + blend_px(self.px, o, self.opaque, rgba); + } + + /// Outlined polar-clip blend: only pixels painted while an annular-sector + /// clip is active (polar marks) ever take this call. + #[cold] + #[inline(never)] + fn blend_u8_polar_clipped(&mut self, x: usize, y: usize, rgba: [u8; 4]) { + let Some(rgba) = apply_polar_clip(self.polar_clip, x, y, rgba) else { + return; + }; let o = (y * self.w + x) * self.channels(); blend_px(self.px, o, self.opaque, rgba); } @@ -109,6 +272,7 @@ impl<'a> Canvas<'a> { channels: self.channels(), opaque: self.opaque, clip: self.clip, + polar_clip: self.polar_clip, px: self.px, } } @@ -191,6 +355,7 @@ struct Surface<'a> { channels: usize, opaque: bool, clip: [f32; 4], + polar_clip: Option, } impl Surface<'_> { @@ -199,8 +364,24 @@ impl Surface<'_> { self.blend_u8(x, y, [rgb[0], rgb[1], rgb[2], to_u8(alpha * cov)]); } - #[inline] + #[inline(always)] fn blend_u8(&mut self, x: usize, y: usize, rgba: [u8; 4]) { + // Same one-byte discriminant test as `Canvas::blend_u8`; see the + // comment there. The banded parallel painter runs this per pixel. + if self.polar_clip.is_some() { + self.blend_u8_polar_clipped(x, y, rgba); + return; + } + let o = ((y - self.y0) * self.w + x) * self.channels; + blend_px(self.px, o, self.opaque, rgba); + } + + #[cold] + #[inline(never)] + fn blend_u8_polar_clipped(&mut self, x: usize, y: usize, rgba: [u8; 4]) { + let Some(rgba) = apply_polar_clip(self.polar_clip, x, y, rgba) else { + return; + }; let o = ((y - self.y0) * self.w + x) * self.channels; blend_px(self.px, o, self.opaque, rgba); } @@ -299,6 +480,12 @@ fn to_u8(v: f32) -> u8 { /// It is exact at subpixel edges and turns the full-canvas white background /// from millions of polygon-coverage blends into a contiguous byte fill. fn fill_rect(cv: &mut Canvas<'_>, pts: &[(f32, f32)], rgba: [f32; 4]) -> bool { + // The contiguous-row fast path bypasses per-pixel blending, where the + // annular-sector clip lives. Fall back to the general polygon painter while + // that clip is active. + if cv.polar_clip.is_some() { + return false; + } if pts.len() != 4 { return false; } @@ -1235,7 +1422,13 @@ fn paint_image_bands( } let threads = threads.min(y1 - y0).max(1); let band_rows = (y1 - y0).div_ceil(threads); - let (w, ch, opaque, clip) = (cv.w, cv.channels(), cv.opaque, cv.clip); + let (w, ch, opaque, clip, polar_clip) = ( + cv.w, + cv.channels(), + cv.opaque, + cv.clip, + cv.polar_clip, + ); let row_bytes = w * ch; let active = &mut cv.px[y0 * row_bytes..y1 * row_bytes]; std::thread::scope(|scope| { @@ -1252,6 +1445,7 @@ fn paint_image_bands( channels: ch, opaque, clip, + polar_clip, }; paint(&mut surface); }); @@ -1954,7 +2148,14 @@ fn paint_banded( y_extent: impl Fn(usize) -> Option<(f32, f32)>, paint: impl Fn(&mut Surface, &[u32]) + Sync, ) { - let (w, h, ch, opaque, clip) = (cv.w, cv.h, cv.channels(), cv.opaque, cv.clip); + let (w, h, ch, opaque, clip, polar_clip) = ( + cv.w, + cv.h, + cv.channels(), + cv.opaque, + cv.clip, + cv.polar_clip, + ); let n_bands = (threads * 4).min(h.div_ceil(8)).max(1); let band_rows = h.div_ceil(n_bands); let n_bands = h.div_ceil(band_rows); @@ -1999,6 +2200,7 @@ fn paint_banded( channels: ch, opaque, clip, + polar_clip, }; paint(&mut sf, &bucket); }); @@ -2331,6 +2533,22 @@ fn rasterize_with_spans<'a>( (x + cw).min(w as f32), (y + ch).min(h as f32), ]; + cv.polar_clip = None; + } + OP_POLAR_CLIP => { + let (cx, cy, inner, outer) = (r.f32()?, r.f32()?, r.f32()?, r.f32()?); + let (start, sweep) = (r.f32()?, r.f32()?); + let polar = PolarClip::new(cx, cy, inner, outer, start, sweep)?; + // Retain the current rectangular plot clip and tighten it + // by the outer disc bbox. The analytic predicate handles + // the inner radius and angular interval at blend time. + cv.clip = [ + cv.clip[0].max(cx - outer).max(0.0), + cv.clip[1].max(cy - outer).max(0.0), + cv.clip[2].min(cx + outer).min(w as f32), + cv.clip[3].min(cy + outer).min(h as f32), + ]; + cv.polar_clip = Some(polar); } OP_FILL_POLY => { let n = r.u32()? as usize; @@ -2782,7 +3000,10 @@ fn rasterize_with_spans<'a>( continue; } let pts = [(xa, ya), (xb, ya), (xb, yb), (xa, yb)]; - fill_rect(&mut cv, &pts, rgba_at(fills, i)); + let color = rgba_at(fills, i); + if !fill_rect(&mut cv, &pts, color) { + fill_poly(&mut cv, &pts, |_, _| color); + } } } OP_TRIANGLES | OP_STROKED_TRIANGLES => { @@ -3134,6 +3355,84 @@ mod tests { assert_eq!(px(&out, 10, 8, 5), [0, 0, 0, 0]); // clipped away } + #[test] + fn polar_clip_handles_annular_sectors_in_both_directions() { + let ccw = PolarClip::new(10.0, 10.0, 3.0, 9.0, 0.0, std::f32::consts::FRAC_PI_2) + .expect("valid counterclockwise sector"); + assert!(ccw.contains(15.0, 5.0)); + assert!(!ccw.contains(10.0, 10.0), "the hole must stay empty"); + assert!(!ccw.contains(5.0, 5.0), "the missing sector must stay empty"); + + let clockwise = + PolarClip::new(10.0, 10.0, 0.0, 9.0, 0.0, -std::f32::consts::FRAC_PI_2) + .expect("valid clockwise sector"); + assert!(clockwise.contains(15.0, 15.0)); + assert!(!clockwise.contains(15.0, 5.0)); + + let wide = PolarClip::new( + 10.0, + 10.0, + 0.0, + 9.0, + 0.0, + 1.5 * std::f32::consts::PI, + ) + .expect("valid wide sector"); + assert!(wide.contains(5.0, 10.0)); + assert!(!wide.contains(15.0, 15.0)); + + let collapsed = PolarClip::new(10.0, 10.0, 9.0, 9.0, 0.0, std::f32::consts::TAU) + .expect("a float32-collapsed annulus is a valid empty clip"); + assert_eq!(collapsed.pixel_coverage(18, 10), 0.0); + } + + #[test] + fn polar_clip_opcode_masks_fill_and_rect_clip_resets_it() { + let mut cmd = vec![OP_CLIP]; + for value in [0.0f32, 0.0, 20.0, 20.0] { + cmd.extend(f32le(value)); + } + cmd.push(OP_POLAR_CLIP); + for value in [ + 10.0f32, + 10.0, + 4.0, + 9.0, + 0.0, + std::f32::consts::FRAC_PI_2, + ] { + cmd.extend(f32le(value)); + } + cmd.push(OP_FILL_POLY); + cmd.extend(u32le(4)); + for (x, y) in [(0.0f32, 0.0f32), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0)] { + cmd.extend(f32le(x)); + cmd.extend(f32le(y)); + } + cmd.extend([255, 0, 0, 255]); + + // A normal rectangular clip begins a new state and removes the shaped + // clip. Paint one small square in what was the missing sector. + cmd.push(OP_CLIP); + for value in [0.0f32, 0.0, 20.0, 20.0] { + cmd.extend(f32le(value)); + } + cmd.push(OP_FILL_POLY); + cmd.extend(u32le(4)); + for (x, y) in [(4.0f32, 4.0f32), (7.0, 4.0), (7.0, 7.0), (4.0, 7.0)] { + cmd.extend(f32le(x)); + cmd.extend(f32le(y)); + } + cmd.extend([0, 0, 255, 255]); + + let mut out = vec![0u8; 20 * 20 * 4]; + assert!(rasterize_into(&cmd, 20, 20, &mut out)); + assert_eq!(px(&out, 20, 15, 5), [255, 0, 0, 255]); + assert_eq!(px(&out, 20, 10, 10), [0, 0, 0, 0]); + assert_eq!(px(&out, 20, 5, 14), [0, 0, 0, 0]); + assert_eq!(px(&out, 20, 5, 5), [0, 0, 255, 255]); + } + #[test] fn stroke_marks_a_line() { let mut cmd = vec![OP_STROKE]; diff --git a/tests/fixtures/polar_transform.json b/tests/fixtures/polar_transform.json new file mode 100644 index 00000000..3a041683 --- /dev/null +++ b/tests/fixtures/polar_transform.json @@ -0,0 +1,256 @@ +{ + "_contract": "Normative (theta,r)->pixel fixtures for polar coordinates. Authored from spec/design/polar-axes.md section 3, NOT generated from any implementation. px/py are SCREEN space (y grows DOWN). GL clip space flips the y term to '+'.", + "_consumers": [ + "tests/test_polar_transform.py (Python projection)", + "scripts/polar_parity_smoke.py (the real GLSL, via headless Chrome)" + ], + "tolerance_px": 1e-06, + "cases": [ + { + "name": "default-cardinals", + "pins": "zero location at East, ccw direction, the screen-space y flip, and r=0 at centre", + "config": { + "unit": "radians", + "zero": "E", + "direction": "counterclockwise", + "r_range": [ + 0.0, + 1.0 + ] + }, + "plot": { + "x": 0.0, + "y": 0.0, + "w": 400.0, + "h": 400.0 + }, + "points": [ + { + "theta": 0, + "r": 1, + "px": 400.0, + "py": 200.0 + }, + { + "theta": 1.5707963267948966, + "r": 1, + "px": 200.0, + "py": 0.0 + }, + { + "theta": 3.141592653589793, + "r": 1, + "px": 0.0, + "py": 200.0 + }, + { + "theta": -1.5707963267948966, + "r": 1, + "px": 200.0, + "py": 400.0 + }, + { + "theta": 0, + "r": 0, + "px": 200.0, + "py": 200.0 + } + ] + }, + { + "name": "zero-north", + "pins": "theta zero rotation: theta=0 points up", + "config": { + "unit": "radians", + "zero": "N", + "direction": "counterclockwise", + "r_range": [ + 0.0, + 1.0 + ] + }, + "plot": { + "x": 0.0, + "y": 0.0, + "w": 400.0, + "h": 400.0 + }, + "points": [ + { + "theta": 0, + "r": 1, + "px": 200.0, + "py": 0.0 + }, + { + "theta": 1.5707963267948966, + "r": 1, + "px": 0.0, + "py": 200.0 + } + ] + }, + { + "name": "compass-degrees-cw", + "pins": "compass composition used by wind roses: 90deg=East, 180deg=South, 270deg=West", + "config": { + "unit": "degrees", + "zero": "N", + "direction": "clockwise", + "r_range": [ + 0.0, + 1.0 + ] + }, + "plot": { + "x": 0.0, + "y": 0.0, + "w": 400.0, + "h": 400.0 + }, + "points": [ + { + "theta": 0, + "r": 1, + "px": 200.0, + "py": 0.0 + }, + { + "theta": 90, + "r": 1, + "px": 400.0, + "py": 200.0 + }, + { + "theta": 180, + "r": 1, + "px": 200.0, + "py": 400.0 + }, + { + "theta": 270, + "r": 1, + "px": 0.0, + "py": 200.0 + } + ] + }, + { + "name": "non-square-rect", + "pins": "circle stays round via min(w,h) and is centred in the rect, not stretched", + "config": { + "unit": "radians", + "zero": "E", + "direction": "counterclockwise", + "r_range": [ + 0.0, + 1.0 + ] + }, + "plot": { + "x": 50.0, + "y": 20.0, + "w": 600.0, + "h": 300.0 + }, + "points": [ + { + "theta": 0, + "r": 1, + "px": 500.0, + "py": 170.0 + }, + { + "theta": 1.5707963267948966, + "r": 1, + "px": 350.0, + "py": 20.0 + }, + { + "theta": 3.141592653589793, + "r": 1, + "px": 200.0, + "py": 170.0 + }, + { + "theta": -1.5707963267948966, + "r": 1, + "px": 350.0, + "py": 320.0 + } + ] + }, + { + "name": "radial-range-offset", + "pins": "radial normalization against a non-zero r_lo", + "config": { + "unit": "radians", + "zero": "E", + "direction": "counterclockwise", + "r_range": [ + 10.0, + 20.0 + ] + }, + "plot": { + "x": 0.0, + "y": 0.0, + "w": 400.0, + "h": 400.0 + }, + "points": [ + { + "theta": 0, + "r": 10, + "px": 200.0, + "py": 200.0 + }, + { + "theta": 0, + "r": 15, + "px": 300.0, + "py": 200.0 + }, + { + "theta": 0, + "r": 20, + "px": 400.0, + "py": 200.0 + } + ] + }, + { + "name": "half-radius", + "pins": "linear radial interpolation", + "config": { + "unit": "radians", + "zero": "E", + "direction": "counterclockwise", + "r_range": [ + 0.0, + 1.0 + ] + }, + "plot": { + "x": 0.0, + "y": 0.0, + "w": 400.0, + "h": 400.0 + }, + "points": [ + { + "theta": 0, + "r": 0.5, + "px": 300.0, + "py": 200.0 + }, + { + "theta": 1.5707963267948966, + "r": 0.5, + "px": 200.0, + "py": 100.0 + } + ] + } + ] +} diff --git a/tests/pyplot/corpus/55_polar_projection.py b/tests/pyplot/corpus/55_polar_projection.py new file mode 100644 index 00000000..615c1df5 --- /dev/null +++ b/tests/pyplot/corpus/55_polar_projection.py @@ -0,0 +1,34 @@ +import numpy as np + +import xy.pyplot as plt + +theta = np.linspace(0.0, 2.0 * np.pi, 17) +radius = 0.6 + 0.3 * np.cos(4.0 * theta) + +fig, axes = plt.subplots( + 1, + 2, + figsize=(8, 4), + subplot_kw={"projection": "polar"}, +) + +axes[0].plot(theta, radius, color="tab:blue", label="response") +axes[0].fill(theta, radius, color="tab:blue", alpha=0.2) +axes[0].scatter(theta[::2], radius[::2], color="tab:orange", s=24) +axes[0].set_theta_zero_location("N") +axes[0].set_theta_direction(-1) +axes[0].set_thetagrids([0, 90, 180, 270], ["N", "E", "S", "W"]) +axes[0].set_rlim(0.0, 1.0) +axes[0].set_rticks([0.25, 0.5, 0.75, 1.0]) +axes[0].legend() + +directions = np.arange(0.0, 2.0 * np.pi, np.pi / 4.0) +axes[1].bar( + directions, + [3, 5, 4, 7, 6, 4, 2, 3], + width=np.pi / 5.0, + bottom=1.0, + color="tab:green", + alpha=0.75, +) +axes[1].set_title("Polar bars") diff --git a/tests/pyplot/test_boundaries.py b/tests/pyplot/test_boundaries.py index 216c3bc3..02a72ee8 100644 --- a/tests/pyplot/test_boundaries.py +++ b/tests/pyplot/test_boundaries.py @@ -90,13 +90,13 @@ def test_shim_never_imports_real_matplotlib_statically() -> None: def test_unsupported_errors_link_to_support_requests() -> None: - assert str(not_implemented("polar charts")) == ( - "xy.pyplot does not implement polar charts. See the compatibility table: " + assert str(not_implemented("3-D charts")) == ( + "xy.pyplot does not implement 3-D charts. See the compatibility table: " "https://github.com/reflex-dev/xy/blob/main/spec/matplotlib/compat.md. " f"Request support: {SUPPORT_REQUEST_URL}" ) with pytest.raises(TypeError) as exc_info: - check_unsupported({"projection": "polar"}, "subplot()") + check_unsupported({"projection": "3d"}, "subplot()") assert str(exc_info.value) == ( "xy.pyplot subplot() got unsupported keyword(s): projection. " "See the compatibility table: " diff --git a/tests/pyplot/test_polar_projection.py b/tests/pyplot/test_polar_projection.py new file mode 100644 index 00000000..388b006f --- /dev/null +++ b/tests/pyplot/test_polar_projection.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import math + +import numpy as np +import pytest + +import xy.pyplot as plt + + +@pytest.fixture(autouse=True) +def _clean_state(): + yield + plt.close("all") + + +def test_subplots_routes_polar_projection_before_axes_properties() -> None: + fig, axes = plt.subplots( + 1, + 2, + subplot_kw={ + "projection": "polar", + "xticks": [0.0, math.pi / 2.0], + "yticks": [0.5, 1.0], + }, + ) + + for ax in axes: + ax.plot([0.0, math.pi / 2.0], [0.5, 1.0]) + assert ax._projection == "polar" + + figures = [chart.figure() for chart in fig._charts()] + assert [figure.coords for figure in figures] == ["polar", "polar"] + assert figures[0].axis_options["x"]["tick_values"] == pytest.approx([0.0, math.pi / 2.0]) + assert figures[0].axis_options["y"]["tick_values"] == pytest.approx([0.5, 1.0]) + + +@pytest.mark.parametrize( + "factory", + [ + lambda: plt.subplot(111, projection="polar"), + lambda: plt.figure().add_subplot(111, polar=True), + lambda: plt.axes(projection="polar"), + lambda: plt.axes([0.1, 0.1, 0.8, 0.8], projection="polar"), + ], +) +def test_polar_projection_routes_through_axes_factories(factory) -> None: + ax = factory() + ax.plot([0.0, math.pi], [0.5, 1.0]) + assert ax._build_chart(320, 320).figure().coords == "polar" + + +def test_polar_options_and_supported_marks_reach_the_core_figure() -> None: + fig, ax = plt.subplots(subplot_kw={"projection": "polar"}) + theta = np.linspace(0.0, 2.0 * math.pi, 9) + radius = np.linspace(0.2, 1.0, 9) + + ax.plot(theta, radius) + ax.scatter(theta[::2], radius[::2]) + ax.fill(theta, radius, alpha=0.2) + ax.bar(theta[::2], radius[::2], width=0.4, bottom=0.1) + ax.set_theta_zero_location("N") + ax.set_theta_direction(-1) + ax.set_thetagrids([0, 90, 180, 270], ["N", "E", "S", "W"]) + ax.set_rlim(0.0, 1.25) + ax.set_rticks([0.25, 0.5, 1.0]) + + core = fig._charts()[0].figure() + spec, _buffers = core.build_payload_split() + assert spec["coords"] == "polar" + assert spec["x_axis"]["theta_unit"] == "radians" + assert spec["x_axis"]["theta_zero"] == "N" + assert spec["x_axis"]["theta_direction"] == "clockwise" + assert spec["x_axis"]["tick_values"] == pytest.approx( + [0.0, math.pi / 2.0, math.pi, 3.0 * math.pi / 2.0] + ) + assert spec["y_axis"]["range"] == pytest.approx([0.0, 1.25]) + assert spec["y_axis"]["tick_values"] == pytest.approx([0.25, 0.5, 1.0]) + assert [trace.kind for trace in core.traces] == ["line", "scatter", "area", "bar"] + + +# -- review round 3: rlim semantics, claimed-subplot projection -------------- + + +def test_set_rmax_keeps_the_centre_origin() -> None: + """set_rmax froze the cartesian-PADDED preview as rmin: ax.set_rmax(2.0) + shipped [0.85, 2.0] where matplotlib gives [0, 2]. The radial auto-domain + preview now matches the engine's polar autorange.""" + plt.close("all") + fig = plt.figure() + ax = fig.add_subplot(111, projection="polar") + ax.plot([0, 1, 2, 3], [1, 2, 3, 4]) + ax.set_rmax(2.0) + spec, _ = ax._build_chart(400, 360).figure().build_payload_split() + assert spec["y_axis"]["range"] == [0.0, 2.0] + assert ax.get_rmin() == 0.0 + assert ax.get_rmax() == 2.0 + plt.close("all") + + +def test_set_rlim_accepts_matplotlib_rmin_rmax_keywords() -> None: + plt.close("all") + fig = plt.figure() + ax = fig.add_subplot(111, projection="polar") + ax.plot([0, 1, 2, 3], [1, 2, 3, 4]) + ax.set_rlim(rmin=0.5, rmax=2.0) + spec, _ = ax._build_chart(400, 360).figure().build_payload_split() + assert spec["y_axis"]["range"] == [0.5, 2.0] + with pytest.raises(ValueError, match="either bottom or rmin"): + ax.set_rlim(0.1, rmin=0.2) + with pytest.raises(TypeError, match="unexpected keyword"): + ax.set_rlim(rmax=2.0, emit=False) + plt.close("all") + + +def test_subplot_projection_polar_on_a_claimed_slot() -> None: + """`plt.subplot(111); plt.subplot(111, projection="polar")` is the + canonical mpl idiom for re-requesting a slot as polar; it used to bounce + off a stale pre-polar NotImplementedError in Axes.set.""" + plt.close("all") + plt.subplot(111) + ax = plt.subplot(111, projection="polar") + ax.plot([0, 1, 2], [1, 2, 3]) + spec, _ = ax._build_chart(360, 320).figure().build_payload_split() + assert spec.get("coords") == "polar" + with pytest.raises(ValueError, match="is not supported"): + plt.subplot(111, projection="3d") + plt.close("all") diff --git a/tests/pyplot/test_tick_side_rendering.py b/tests/pyplot/test_tick_side_rendering.py index 0e079b69..8df54519 100644 --- a/tests/pyplot/test_tick_side_rendering.py +++ b/tests/pyplot/test_tick_side_rendering.py @@ -120,9 +120,12 @@ def test_tick_sides_bump_wire_protocol_and_client_in_lockstep() -> None: client = (ROOT / "js" / "src" / "50_chartview.ts").read_text(encoding="utf-8") assert spec["x_axis"]["tick_sides"] == ["bottom", "top"] - assert spec["protocol"] == PROTOCOL_VERSION == 10 + assert spec["protocol"] == PROTOCOL_VERSION == 12 assert f"PROTOCOL = {PROTOCOL_VERSION};" in header - assert 'import { PROTOCOL, xyByteSpan } from "./00_header";' in client + # The point is that the client reads PROTOCOL from the header, not the exact + # spelling of the import list — which grows whenever the header gains another + # shared constant (it now also exports TRACE_GPU_BUFFERS). + assert "PROTOCOL" in client.split(' from "./00_header";', 1)[0] assert "spec.protocol !== PROTOCOL" in client diff --git a/tests/test_benchmark_environment.py b/tests/test_benchmark_environment.py index 7dd676d2..45482892 100644 --- a/tests/test_benchmark_environment.py +++ b/tests/test_benchmark_environment.py @@ -1,6 +1,7 @@ from __future__ import annotations import ast +import re from collections.abc import Sequence from datetime import UTC, datetime from pathlib import Path @@ -348,11 +349,71 @@ def test_context_governor_reserves_pending_restores() -> None: def test_triangle_mesh_resource_cleanup_deletes_every_coordinate_buffer() -> None: + # The buffer names moved out of `_destroyTraceResources` into the shared + # `TRACE_GPU_BUFFERS` list (js/src/00_header.ts) that all three teardown paths + # read, so assert against the list and that the teardown really uses it. + # `tests/test_trace_buffer_lifecycle.py` pins the list against every buffer + # any build path creates; this row keeps the triangle-mesh six explicit. + header = (ROOT / "js" / "src" / "00_header.ts").read_text(encoding="utf-8") + listed = header.split("export const TRACE_GPU_BUFFERS = [", 1)[1].split("];", 1)[0] + for name in ("x0Buf", "x1Buf", "x2Buf", "y0Buf", "y1Buf", "y2Buf"): + assert f'"{name}"' in listed + client = (ROOT / "js" / "src" / "50_chartview.ts").read_text(encoding="utf-8") cleanup = client[client.index("_destroyTraceResources(g, texSeen)") :] cleanup = cleanup[: cleanup.index("_destroyGlResources()")] - for name in ("x0Buf", "x1Buf", "x2Buf", "y0Buf", "y1Buf", "y2Buf"): - assert f'"{name}"' in cleanup + assert "this._deleteBuffers(g, TRACE_GPU_BUFFERS);" in cleanup + + +def _codspeed_row_count() -> int: + """CodSpeed rows the workflow's glob collects, counting parametrized expansion. + + Parsed rather than imported: the benchmark modules assert a native backend at + import time, and this only needs their shape. + """ + total = 0 + for path in sorted((ROOT / "benchmarks").glob("test_codspeed_*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if not isinstance(node, ast.FunctionDef) or not node.name.startswith("test_"): + continue + rows = 1 + for decorator in node.decorator_list: + if not isinstance(decorator, ast.Call) or len(decorator.args) != 2: + continue + target = decorator.func + name = ( + target.attr if isinstance(target, ast.Attribute) else getattr(target, "id", "") + ) + if name != "parametrize": + continue + argvalues = decorator.args[1] + assert isinstance(argvalues, (ast.List, ast.Tuple)), ( + f"{path.name}::{node.name} parametrizes with a non-literal argvalues; " + "the row count can no longer be counted statically" + ) + rows *= len(argvalues.elts) + total += rows + return total + + +def test_codspeed_row_count_matches_the_methodology_spec() -> None: + """A benchmark cannot be added or removed without saying so in the spec. + + A row that is renamed or deleted silently stays in CodSpeed's stored + baseline, where the dashboard keeps reporting it as "skipped, using the + baseline result" — indistinguishable from a flaky measurement rather than a + row that no longer exists. Deleting a benchmark is fine; deleting one without + updating §8 (and archiving the stale row in the dashboard) is not. + """ + methodology = (ROOT / "spec/benchmarks/methodology.md").read_text(encoding="utf-8") + declared = re.search(r"for \*\*(\d+) rows\*\* total", methodology) + assert declared is not None, "spec/benchmarks/methodology.md §8 no longer states a row count" + assert _codspeed_row_count() == int(declared.group(1)), ( + "CodSpeed row count drifted from spec/benchmarks/methodology.md §8. Update the " + "count and the module list there, and archive any deleted row in the CodSpeed " + "dashboard so it stops being reported as skipped." + ) def test_benchmark_categories_track_core_hardening_metrics() -> None: diff --git a/tests/test_legend_resize_regression.py b/tests/test_legend_resize_regression.py index 6c404156..5963e14c 100644 --- a/tests/test_legend_resize_regression.py +++ b/tests/test_legend_resize_regression.py @@ -318,12 +318,19 @@ const compactNodes = [...view._colorbar.querySelectorAll( '[data-xy-slot="colorbar_tick"], [data-xy-slot="colorbar_title"]' )]; + const visibleTickText = (nodes) => nodes + .filter((node) => !node.hidden && node.dataset.xySlot === "colorbar_tick") + .map((node) => node.textContent); const compactState = { plotWidth: view.plot.w, colorbarWidth: view._colorbar.getBoundingClientRect().width, compact: view._colorbar.dataset.xyCompact, hiddenChrome: compactNodes.filter((node) => node.hidden).length, chromeCount: compactNodes.length, + visibleTicks: visibleTickText(compactNodes), + titleHidden: compactNodes + .filter((node) => node.dataset.xySlot === "colorbar_title") + .every((node) => node.hidden), }; view._resize(760, 500); @@ -555,9 +562,17 @@ def test_narrow_fluid_resize_stays_painted_and_preserves_plot_space() -> None: assert payload["compactState"]["plotWidth"] >= 280, payload assert payload["compactState"]["colorbarWidth"] == pytest.approx(18, abs=1), payload assert payload["compactState"]["compact"] == "true", payload - assert payload["compactState"]["hiddenChrome"] == payload["compactState"]["chromeCount"], ( - payload - ) + # The compact form keeps the two EXTREME tick labels, stacked above and below + # the gradient, and drops the interior ladder plus the rotated title. Hiding + # every one of them — the previous contract this line asserted — left a colour + # ramp with no numbers on it, which is unreadable rather than condensed. The + # width and plot-space assertions above are what keep the fix free: restacking + # the endpoints costs no side gutter, so the collapse still hands the plot the + # room it was collapsing for. + assert payload["compactState"]["visibleTicks"] == ["0", "1"], payload + assert payload["compactState"]["titleHidden"] is True, payload + hidden = payload["compactState"]["hiddenChrome"] + assert hidden == payload["compactState"]["chromeCount"] - 2, payload assert payload["restoredState"] == {"compact": "false", "hiddenChrome": 0}, payload diff --git a/tests/test_polar_audit_fixes.py b/tests/test_polar_audit_fixes.py new file mode 100644 index 00000000..f578edf3 --- /dev/null +++ b/tests/test_polar_audit_fixes.py @@ -0,0 +1,560 @@ +"""Regressions for the polar/responsive audit round. + +One module per audit rather than per subsystem, because each of these is a +*specific* reported failure and the point is that it stays fixed: an unlabelled +mobile colorbar, a clipped Wind Rose title, a legend on top of the disc, four +axis keywords that shipped and were ignored, dates squeezed onto the rim, and a +wedge paying full-turn vertex cost for a 22.5-degree sweep. + +Client-only behaviour is pinned against the TypeScript source: these are layout +and teardown rules the browser owns, and the alternative — asserting them only +through a headless WebGL probe — leaves them unpinned wherever the probe is +skipped for want of a browser. +""" + +from __future__ import annotations + +import math +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import numpy as np +import pytest + +import xy +from xy import _svg, _textblock, components +from xy._svg import layout +from xy.config import ( + POLAR_BAR_SEGMENTS, + POLAR_BAR_SEGMENTS_MIN, + polar_bar_segments, +) + +ROOT = Path(__file__).resolve().parents[1] +CHARTVIEW = (ROOT / "js/src/50_chartview.ts").read_text(encoding="utf-8") + + +def _wind_rose(*children, **props): + rng = np.random.default_rng(11) + return xy.wind_rose( + rng.uniform(0.0, 360.0, 400), + rng.gamma(2.0, 3.0, 400), + *children, + **props, + ) + + +# -- wedge subdivision ------------------------------------------------------ + + +def test_wedge_subdivision_follows_the_authored_span() -> None: + """194 vertices for a 22.5-degree sector was the 50k-bar cliff. + + The count is proportional, so the per-segment angle — and therefore the + chord sagitta the constant is sized against — is unchanged for every span. + """ + turn = 2.0 * math.pi + assert polar_bar_segments(turn, turn) == POLAR_BAR_SEGMENTS + assert polar_bar_segments(turn / 2, turn) == POLAR_BAR_SEGMENTS // 2 + # A 16-sector wind rose: six segments, 14 vertices per bar, not 194. + assert polar_bar_segments(math.radians(22.5), turn) == 6 + + per_segment = [ + math.radians(degrees) / polar_bar_segments(math.radians(degrees), turn) + for degrees in (360, 180, 90, 45, 22.5) + ] + worst = math.radians(360) / POLAR_BAR_SEGMENTS + assert max(per_segment) <= worst + 1e-12 + + +def test_wedge_subdivision_never_degenerates_or_overpays() -> None: + turn = 2.0 * math.pi + for span in (0.0, 1e-9, math.radians(0.5)): + assert polar_bar_segments(span, turn) == POLAR_BAR_SEGMENTS_MIN + # Clamped at the full-turn count even for a nonsense span, and a zero turn + # falls back rather than dividing by it. + assert polar_bar_segments(10 * turn, turn) == POLAR_BAR_SEGMENTS + assert polar_bar_segments(1.0, 0.0) == POLAR_BAR_SEGMENTS + + +def test_client_wedge_subdivision_mirrors_the_python_formula() -> None: + assert "function xyPolarBarSegments(span, turn)" in CHARTVIEW + assert "Math.ceil(POLAR_BAR_SEGMENTS * (Math.abs(span) / turn))" in CHARTVIEW + # Both draw paths must use it: the compact scalar-width path and the + # four-edge path, which takes the widest span in the trace. + assert "xyPolarBarSegments(Number(g.width) * barGeom.dirUnit, 2 * Math.PI)" in CHARTVIEW + assert ( + "xyPolarBarSegments(this._polarRectMaxSpan(g) * rectGeom.dirUnit, 2 * Math.PI)" in CHARTVIEW + ) + # Cached on the trace, so the count cannot become view-dependent. + assert "if (g._polarMaxSpan !== undefined) return g._polarMaxSpan;" in CHARTVIEW + + +def test_flattened_wedge_polygon_shrinks_with_the_span() -> None: + """The raster twin flattens per wedge, so it pays per wedge.""" + chart = xy.polar_bar_chart( + xy.bar([0.0, 90.0, 180.0, 270.0], [1.0, 2.0, 3.0, 4.0], width=22.5), + xy.theta_axis(unit="degrees"), + width=520, + height=520, + ) + spec, blob = chart.figure().build_payload() + _w, _h, _compact, plot = layout(spec) + polar = _svg._PolarProjection(spec["x_axis"], spec["y_axis"], plot) + narrow = _svg.polar_wedge_points(polar, 0.0, 22.5, 0.0, 4.0) + wide = _svg.polar_wedge_points(polar, 0.0, 360.0, 0.0, 4.0) + assert 0 < len(narrow) < len(wide) + # Pinning an explicit count still overrides the per-wedge default. + pinned = _svg.polar_wedge_points(polar, 0.0, 22.5, 0.0, 4.0, steps=POLAR_BAR_SEGMENTS) + assert len(pinned) > len(narrow) + assert blob is not None + + +# -- radial autorange ------------------------------------------------------- + + +def test_time_radius_autoranges_from_the_data_not_epoch_zero() -> None: + """Pinning a time radius to r=0 puts 1970 at the centre, so every modern + instant lands in a hairline ring at the rim.""" + days = [datetime(2026, 1, 1, tzinfo=UTC) + timedelta(days=i) for i in range(12)] + theta = np.linspace(0.0, 300.0, 12) + spec, _blob = ( + xy.polar_chart( + xy.line(theta, days), + xy.theta_axis(unit="degrees"), + ) + .figure() + .build_payload() + ) + lo, hi = spec["y_axis"]["range"] + assert lo > 0.0, "a time radius must not autorange from epoch zero" + # The data occupies most of the radius instead of a sliver at the rim. + first, last = float(spec["y_axis"]["range"][0]), float(spec["y_axis"]["range"][1]) + span = last - first + data_span = (days[-1] - days[0]).total_seconds() * 1000.0 + assert data_span / span > 0.5 + + # A numeric radius keeps the centre origin: this is an exemption, not a + # change to the default. + numeric, _blob = ( + xy.polar_chart(xy.line(theta, np.linspace(100.0, 140.0, 12)), xy.theta_axis(unit="degrees")) + .figure() + .build_payload() + ) + assert numeric["y_axis"]["range"][0] == 0.0 + + +def test_radial_margin_is_honoured_instead_of_discarded() -> None: + """`margin=` asks for exactly the outer pad the centre-origin default drops.""" + values = np.linspace(10.0, 20.0, 8) + theta = np.linspace(0.0, 300.0, 8) + + def radial_range(**axis): + spec, _blob = ( + xy.polar_chart( + xy.line(theta, values), + xy.theta_axis(unit="degrees"), + xy.r_axis(**axis), + ) + .figure() + .build_payload() + ) + return spec["y_axis"]["range"] + + default_lo, default_hi = radial_range() + assert default_lo == 0.0 + assert default_hi == pytest.approx(20.0) + + margin_lo, margin_hi = radial_range(margin=0.2) + assert margin_lo == 0.0, "the centre origin is not what margin controls" + assert margin_hi > default_hi + + +# -- inert axis keywords ---------------------------------------------------- + + +@pytest.mark.parametrize( + ("kwargs", "message"), + ( + ({"minor_tick_values": [0.5]}, "no minor rings or spokes"), + ({"tick_label_min_gap": 12.0}, "collision pass"), + ({"tick_label_anchor": "start"}, "anchors radially"), + ({"minor_style": {"tick_color": "#f00"}}, "minor_style"), + ({"tick_label_strategy": "auto"}, "collision pass"), + ({"tick_label_strategy": "stagger"}, "collision pass"), + ), +) +@pytest.mark.parametrize("axis", ("theta", "r")) +def test_polar_refuses_axis_options_no_renderer_implements(axis, kwargs, message) -> None: + """Each of these rode the wire and was dropped by all three renderers, so + the documented polar axis surface advertised controls that did nothing.""" + factory = xy.theta_axis if axis == "theta" else xy.r_axis + with pytest.raises(ValueError, match=message): + factory(**kwargs) + + +@pytest.mark.parametrize("strategy", ("off", "none")) +@pytest.mark.parametrize("axis", ("theta", "r")) +def test_polar_keeps_the_tick_label_strategies_it_honours(axis, strategy) -> None: + factory = xy.theta_axis if axis == "theta" else xy.r_axis + theta = np.linspace(0.0, 300.0, 8) + values = np.linspace(1.0, 8.0, 8) + chart = xy.polar_chart(xy.line(theta, values), factory(tick_label_strategy=strategy)) + chart.figure().build_payload_split() + + +def test_the_pyplot_adapter_drops_what_a_hand_authored_axis_refuses() -> None: + """`projection="polar"` must keep working: every pyplot Axes carries an + rcParam-derived `minor_style`, and `minorticks_on()`/`ha=` add more. Refusing + a default nobody asked for would turn the whole projection into an error, so + the adapter drops instead — which is what the renderers already do. + """ + props = { + "minor_style": {"tick_color": "#f00"}, + "minor_tick_values": [0.5], + "tick_label_anchor": "start", + "tick_label_min_gap": 12.0, + "tick_label_strategy": "preserve", + "theta_unit": "degrees", + "label": "bearing", + } + stripped = components._polar_axis_kwargs(props) + assert stripped == {"theta_unit": "degrees", "label": "bearing"} + # `off`/`none` survive the strip, because polar honours them. + assert components._polar_axis_kwargs({"tick_label_strategy": "none"}) == { + "tick_label_strategy": "none" + } + + +def test_cartesian_axes_keep_every_refused_keyword() -> None: + """The refusals are polar-only; nothing about a Cartesian axis changed.""" + chart = xy.line_chart( + xy.line([0.0, 1.0, 2.0], [1.0, 2.0, 3.0]), + xy.x_axis(minor_tick_values=[0.5, 1.5], tick_label_min_gap=12.0), + xy.y_axis(tick_label_anchor="start", tick_label_strategy="stagger"), + ) + chart.figure().build_payload_split() + + +def test_polar_axes_keep_every_keyword_they_do_honour() -> None: + """The refusal must not have caught anything that works.""" + xy.theta_axis( + unit="degrees", + zero="N", + direction="clockwise", + sector=(0.0, 270.0), + grid_shape="linear", + label="bearing", + format=".0f°", + tick_values=[0.0, 90.0, 180.0], + tick_labels=["N", "E", "S"], + tick_count=4, + tick_label_angle=15.0, + style={"grid_color": "#eee"}, + ) + xy.r_axis( + hole=0.3, + label="speed", + type_="log", + domain=(1.0, 100.0), + reverse=True, + margin=0.1, + tick_label_angle=15.0, + ) + + +def test_authored_theta_format_wins_over_the_angular_default() -> None: + """`format=` shipped on the wire and was then overwritten by the built-in + degree text in every renderer.""" + axis = {"theta_unit": "degrees", "format": ".0f°", "kind": "linear"} + assert _svg._fmt_axis(axis, 90.0, 45.0) == "90°" + # Without a format the angular default still applies. + assert "deg" not in _svg._fmt_axis({"theta_unit": "degrees", "kind": "linear"}, 90.0, 45.0) + # And the client checks the authored spec before the angular branch. + ticks = (ROOT / "js/src/30_ticks.ts").read_text(encoding="utf-8") + assert "const authored = fmtNumberSpec(v, axis.format);" in ticks + assert "return authored || fmtAngle(v, axis.theta_unit, tickStep);" in ticks + + +# -- zero-size wedges ------------------------------------------------------- + + +def test_a_zero_width_wedge_draws_nothing_instead_of_raising() -> None: + """0% is a data state: a progress ring at zero, an empty aggregated + category, the first frame of a grow animation.""" + chart = xy.polar_bar_chart( + xy.bar([0.0], [1.0], base=0.6, width=0.0), + xy.theta_axis(unit="degrees"), + xy.r_axis(domain=(0.0, 1.0)), + ) + spec, _blob = chart.figure().build_payload() + assert spec["traces"] + + # The hand-rolled gauge recipe, swept from 0% to 100%. + for percent in (0, 1, 50, 100): + xy.polar_bar_chart( + xy.bar([percent * 3.6 / 2.0], [1.0], base=0.7, width=percent * 3.6), + xy.theta_axis(unit="degrees", zero="N", direction="clockwise"), + xy.r_axis(domain=(0.0, 1.0)), + ).figure().build_payload() + + # `pie_chart` reaches 0% too, and drops the row rather than showing a + # swatch that highlights nothing. + spec, _blob = xy.pie_chart(["done", "left"], [0.0, 8.0]).figure().build_payload() + assert len([t for t in spec["traces"] if t.get("name")]) == 1 + + +@pytest.mark.parametrize("width", (-1.0, float("nan"), float("inf"))) +def test_meaningless_bar_widths_are_still_refused(width) -> None: + with pytest.raises(ValueError, match="width"): + xy.bar_chart(xy.bar(["a"], [1.0], width=width)).figure().build_payload() + + +# -- title wrapping --------------------------------------------------------- + + +def test_wrapped_titles_reserve_the_lines_they_occupy() -> None: + long_title = "Wind rose — Fastnet Rock lighthouse, hourly observations 2024" + narrow = xy.polar_chart( + xy.line([0.0, 90.0, 180.0], [1.0, 2.0, 3.0]), + xy.theta_axis(unit="degrees"), + title=long_title, + width=380, + height=460, + ) + spec, _blob = narrow.figure().build_payload() + _w, _h, compact, plot = layout(spec) + assert compact + + wrap_width = plot["title_wrap_width"] + block = _textblock.measure(long_title, 14.0, max_width=wrap_width) + assert block.line_count > 1, "this title must wrap at a phone width" + # The reserved band holds the whole wrapped block, not one line of it. + assert plot["title_room"] >= block.height + + +def test_single_line_titles_reserve_exactly_what_they_did() -> None: + """The wrap rule must not move any chart whose title already fitted.""" + chart = xy.line_chart(xy.line([0.0, 1.0], [0.0, 1.0]), title="Latency", width=900, height=420) + spec, _blob = chart.figure().build_payload() + _w, _h, compact, plot = layout(spec) + assert not compact + block = _textblock.measure("Latency", 14.0, max_width=plot["title_wrap_width"]) + assert block.line_count == 1 + assert plot["title_room"] == pytest.approx(30.0) + + +def test_wrap_only_breaks_at_spaces_and_keeps_authored_newlines() -> None: + # An authored newline is a hard break: "delta" cannot join the line above it + # even though it would fit there. + lines = _textblock.wrap_lines(("alpha beta gamma", "delta"), 14.0, 60.0) + assert lines[-1] == "delta" + # Every multi-word line fits the limit. + assert all( + len(line.split()) == 1 or _textblock.measure(line, 14.0).width <= 60.0 for line in lines + ) + # A single word wider than the limit keeps its own line and overflows, + # which is what a browser does without an explicit overflow-wrap. + assert _textblock.wrap_lines(("unbreakablesupercalifragilistic",), 14.0, 10.0) == ( + "unbreakablesupercalifragilistic", + ) + + +def test_client_caps_the_title_box_at_the_measured_wrap_width() -> None: + assert ( + "this._titleWrapWidth = Math.max(40, this.size.w - authoredLeft - baseRight);" in CHARTVIEW + ) + assert "entry.text, titleFontSize, this._titleWrapWidth," in CHARTVIEW + assert "title.style.maxWidth = " in CHARTVIEW + assert "function xyWrapLines(lines, advance, maxWidth)" in CHARTVIEW + + +# -- legend overflow -------------------------------------------------------- + + +def test_a_long_legend_row_wraps_instead_of_scrolling_sideways() -> None: + """A pie legend grew a horizontal scrollbar, hiding the label it was showing. + + The box is capped at `--xy-legend-max-width`, but its grid columns were + `max-content` — they refused to shrink — so an over-wide row overflowed and + `overflow:auto` answered with a sideways scrollbar. Shrinkable columns let the + label wrap inside its column instead, so nothing needs to scroll sideways and + no text is dropped. Block-axis scrolling stays: it is what the browser legend + has over the static exporters, which can only ellipsize. + + The row deliberately stays a BLOCK, not a flex line. A flex container + blockifies its children's computed `display`, which would turn an author's + `inline-flex` swatch utility into `flex` + (`test_tailwind_root_customization.py`), and a nowrap label removes the very + wrapping that keeps a narrow chart's legend scrollable rather than clipped + (`test_legend_resize_regression.py`). The swatch keeps aligning through the + `vertical-align` it already carries. + """ + # Columns that can shrink, and no horizontal scroll axis. + assert "minmax(0,max-content)" in CHARTVIEW + assert "overflow-x:hidden;overflow-y:auto;" in CHARTVIEW + theme = (ROOT / "js/src/20_theme.ts").read_text(encoding="utf-8") + assert 'data-xy-slot="legend_item"]){' not in theme + assert 'data-xy-slot="legend_label"]){' not in theme + assert 'data-xy-slot="legend_swatch"]){display:inline-block;width:' in theme + # Clipping must never make text unreachable: same full-text-in-title/ARIA + # rule the categorical tick labels use. + assert "row.title = String(it.name);" in CHARTVIEW + assert 'row.setAttribute("aria-label", String(it.name));' in CHARTVIEW + + +# -- polar legend gutter ---------------------------------------------------- + + +def test_a_polar_legend_gets_a_gutter_beside_the_disc() -> None: + spec, _blob = _wind_rose(width=720, height=520).figure().build_payload() + _w, _h, compact, plot = layout(spec) + assert not compact + assert "legend_box_w" in plot, "a polar legend must reserve its own box" + # The box is outside the plot rect, on the right, and the disc no longer + # reaches into it. + assert plot["legend_box_x"] >= plot["x"] + plot["w"] + assert plot["legend_box_w"] == pytest.approx(_svg._polar_legend_room(720)) + + +def test_a_compact_polar_legend_takes_a_band_under_the_disc() -> None: + spec, _blob = _wind_rose(width=380, height=520).figure().build_payload() + _w, _h, compact, plot = layout(spec) + assert compact + assert plot["legend_box_h"] == pytest.approx(_svg._POLAR_LEGEND_BAND) + assert plot["legend_box_y"] >= plot["y"] + plot["h"] + + +def test_the_static_legend_places_itself_in_the_polar_gutter() -> None: + spec, _blob = _wind_rose(width=720, height=520).figure().build_payload() + _w, _h, _compact, plot = layout(spec) + named = _svg.legend_items(spec["traces"]) + assert named + placed = _svg._legend_layout(named, plot, spec.get("legend") or {}) + assert placed["x"] >= plot["x"] + plot["w"], "the legend must clear the disc" + + +def test_an_authored_anchor_reserves_no_polar_gutter() -> None: + """An anchor is an explicit plot-relative placement the author owns; + relocating it would be the same class of bug as ignoring a keyword.""" + spec, _blob = ( + _wind_rose(xy.legend(anchor=(0.9, 0.9)), width=720, height=520).figure().build_payload() + ) + _w, _h, _compact, plot = layout(spec) + assert "legend_box_w" not in plot + + +def test_authored_padding_reserves_no_polar_gutter() -> None: + """A four-tuple `padding` already states the box the plot should occupy, and + is the documented way to hand-reserve a caption band under a donut.""" + spec, _blob = ( + _wind_rose(width=720, height=520, padding=(20, 20, 140, 20)).figure().build_payload() + ) + _w, _h, _compact, plot = layout(spec) + assert "legend_box_w" not in plot + + +def test_a_cartesian_legend_still_overlays_its_plot() -> None: + spec, _blob = ( + xy.line_chart( + xy.line([0.0, 1.0], [0.0, 1.0], name="a"), + xy.legend(), + width=720, + height=420, + ) + .figure() + .build_payload() + ) + _w, _h, _compact, plot = layout(spec) + assert "legend_box_w" not in plot + + +def test_client_legend_places_in_the_reserved_box() -> None: + assert "_polarLegendReserve(compact)" in CHARTVIEW + assert "function xyPolarLegendRoom(width)" in CHARTVIEW + assert "const POLAR_LEGEND_ROOM_FRACTION = 0.22;" in CHARTVIEW + assert "room: xyPolarLegendRoom(this.size.w)," in CHARTVIEW + assert "const POLAR_LEGEND_BAND = 64;" in CHARTVIEW + # Placement and the responsive max-width both read the legend box, and an + # authored anchor still resolves against the plot. + assert "const plot = anchor ? this.plot : (this._legendRect || this.plot);" in CHARTVIEW + assert "const lb = this._legendRect || p;" in CHARTVIEW + + +# -- compact colorbar ------------------------------------------------------- + + +def test_compact_colorbars_keep_their_endpoint_labels() -> None: + """Hiding every tick left a gradient with no numbers on it. + + The two extremes survive, restacked above and below the gradient. Beside the + bar they would need a gutter wide enough for `0.25`, which costs 36 px of the + plot width the compact collapse exists to protect; centred on the 18 px bar + they fit in the gap already reserved, so the fix is free. + """ + assert ( + "const endpoint = !Number.isFinite(fraction) " + "|| fraction === lowest || fraction === highest;" in CHARTVIEW + ) + assert "node.hidden = compactVertical && !endpoint;" in CHARTVIEW + # Restacked, and the beside-the-bar placement is restored on the way out. + assert "tick._xyBesideCss = tick.style.cssText;" in CHARTVIEW + assert "node.style.cssText = node._xyBesideCss;" in CHARTVIEW + assert "const COMPACT_COLORBAR_LABEL_GAP = 3;" in CHARTVIEW + # The reservation is unchanged, which is what keeps the plot space the + # collapse was collapsing for. + assert "COMPACT_COLORBAR_GAP + COLORBAR_THICKNESS + 8" in CHARTVIEW + # The rotated title and the text-free minor ticks are what a phone cannot + # spend; `box.title` keeps the scale name reachable. + assert "'[data-xy-slot=\"colorbar_title\"], [data-xy-colorbar-minor]'" in CHARTVIEW + + +# -- dpr-baked buffers and animation cadence -------------------------------- + + +def test_a_dpr_change_rescales_the_buffers_baked_in_device_pixels() -> None: + assert "_rescaleDprBakedBuffers()" in CHARTVIEW + # Widths ride component 2 of the style row; radii are their own buffer. + assert "for (let i = 2; i < style.length; i += 4) style[i] *= factor;" in CHARTVIEW + assert "g._cpuRadius = values;" in CHARTVIEW + # Run before the layout/paint of the same frame. + assert "this._rescaleDprBakedBuffers();\n this._layout();" in CHARTVIEW + + +def test_the_dpr_rescale_defers_to_the_append_rebuild_on_a_short_mirror() -> None: + """The rescale re-uploads whole buffers from `_cpuStyle`/`_cpuRadius`, but the + streaming-append fast path extends `styleBuf` with a tail `bufferSubData` and + advances `n` without growing those mirrors (54_kernel.ts). Re-uploading a short + mirror would shrink the store out from under the appended rows, and scaling it + would leave that tail at the old dpr regardless. Leaving `_styleDpr` stale + hands the repair back to the append guard's rebuild — the fallback + `scripts/append_stream_smoke.py` asserts via `dprChangeRebuilds`. + """ + assert "const rows = Number(record.n);" in CHARTVIEW + assert "if (record._cpuStyle && record._cpuStyle.length !== rows * 4) return;" in CHARTVIEW + assert "if (record._cpuRadius && record._cpuRadius.length !== rows * 2) return;" in CHARTVIEW + # The guard the fallback runs through must stay in place. + kernel = (ROOT / "js/src/54_kernel.ts").read_text(encoding="utf-8") + assert "if (g.styleBuf && g._styleDpr !== this.dpr) return false;" in kernel + + +def test_a_dpr_change_stays_synchronous() -> None: + """`render_smoke_nonumpy.py`'s `dprw` probe calls `_onDprChange()` and reads + `dpr`/`canvas.width`/`chrome.width` on the next line: a DPR change with no + container resize has no later event to piggyback on. Deferring it into + `_queueResize` would read a stale `dpr` there, and it saved nothing anyway — + the ResizeObserver's queued pass already early-returns when width, height + and dpr are all unchanged, so the redundant second frame it was meant to + avoid does not exist. + """ + assert "this._resize(this.size.w, this.size.h); // re-reads devicePixelRatio" in CHARTVIEW + assert ( + "this._queueResize(this.size.w, this.size.h, this.fluid || this.fluidH);" not in CHARTVIEW + ) + + +def test_data_animations_throttle_the_label_dom_rebuild() -> None: + animation = (ROOT / "js/src/56_animation.ts").read_text(encoding="utf-8") + assert "const labelCadenceMs = (this._viewAnim || this._dataAnim) ? 80 : 0;" in CHARTVIEW + # And the settled labels always land when the transition ends. + assert "this._lastLabelDraw = null;" in animation diff --git a/tests/test_polar_charts.py b/tests/test_polar_charts.py new file mode 100644 index 00000000..40ebc099 --- /dev/null +++ b/tests/test_polar_charts.py @@ -0,0 +1,1661 @@ +"""Polar charts end to end: wire shape, refusals, and cross-renderer agreement. + +The transform itself is pinned by `test_polar_transform.py` against shared +fixtures. This file checks that each renderer actually *uses* it — the failure +mode two export-parity audits have already found in this repo is a renderer +quietly keeping its own geometry while the others move. +""" + +from __future__ import annotations + +import math +import re +from datetime import UTC, datetime, timedelta +from itertools import pairwise +from pathlib import Path + +import numpy as np +import pytest + +import xy +from xy._svg import _PolarProjection, axis_ticks, layout, minor_axis_ticks +from xy.config import POLAR_DIRECT_CEILING + +ROOT = Path(__file__).resolve().parent.parent + + +def _rose(n: int = 120): + theta = np.linspace(0.0, 2.0 * math.pi, n) + return theta, 1.0 + 0.5 * np.sin(5.0 * theta) + + +def _chart(**kwargs): + theta, r = _rose() + children = kwargs.pop("children", None) or [xy.line(theta, r, color="#2563eb", width=2.0)] + return xy.polar_chart(*children, width=520, height=520, **kwargs) + + +# -- wire ------------------------------------------------------------------ + + +def test_polar_spec_carries_coords_and_angular_descriptors() -> None: + spec, _ = _chart(children=[xy.line(*_rose())]).figure().build_payload_split() + assert spec["coords"] == "polar" + assert spec["x_axis"]["theta_unit"] == "radians" + assert spec["x_axis"]["theta_zero"] == "E" + assert spec["x_axis"]["theta_direction"] == "counterclockwise" + + +def test_cartesian_spec_omits_coords_entirely() -> None: + """Existing specs must stay byte-identical when polar is not in play.""" + spec, _ = xy.line_chart(xy.line([0, 1], [0, 1])).figure().build_payload_split() + assert "coords" not in spec + assert "theta_unit" not in spec["x_axis"] + + +def test_theta_axis_options_reach_the_wire() -> None: + chart = _chart( + children=[xy.line(*_rose()), xy.theta_axis(unit="degrees", zero="N", direction="clockwise")] + ) + spec, _ = chart.figure().build_payload_split() + assert spec["x_axis"]["theta_unit"] == "degrees" + assert spec["x_axis"]["theta_zero"] == "N" + assert spec["x_axis"]["theta_direction"] == "clockwise" + + +# -- ranges ---------------------------------------------------------------- + + +def test_radial_axis_starts_at_the_centre() -> None: + """A radial axis padded away from zero puts the smallest datum at the + centre and makes a 5%-variation series look like it radiates from nothing. + Matplotlib pins rmin=0 for the same reason.""" + spec, _ = _chart().figure().build_payload_split() + assert spec["y_axis"]["range"][0] == 0.0 + + +def test_explicit_radial_domain_still_wins() -> None: + chart = _chart(children=[xy.line(*_rose()), xy.r_axis(domain=(0.5, 2.0))]) + spec, _ = chart.figure().build_payload_split() + assert spec["y_axis"]["range"] == [0.5, 2.0] + + +@pytest.mark.parametrize( + ("unit", "expected"), + [("radians", 2.0 * math.pi), ("degrees", 360.0)], +) +def test_angular_axis_spans_a_full_turn(unit: str, expected: float) -> None: + """Theta is used directly as an angle, never rescaled into the axis range, + so autoscaling it to the data would put spokes at arbitrary angles.""" + theta, r = _rose() + if unit == "degrees": + theta = np.degrees(theta) + chart = _chart(children=[xy.line(theta, r), xy.theta_axis(unit=unit)]) + spec, _ = chart.figure().build_payload_split() + assert spec["x_axis"]["range"] == pytest.approx([0.0, expected]) + + +# -- refusals -------------------------------------------------------------- + + +@pytest.mark.parametrize("mark", ["histogram", "box", "hexbin"]) +def test_unsupported_marks_are_refused_not_approximated(mark: str) -> None: + """These kinds expand geometry in pixel space after the coordinate map, so + under polar they draw chord-edged shapes where arcs belong. A plausible + wrong picture is worse than an error (dossier §28).""" + builders = { + "histogram": lambda: xy.hist(np.array([1.0, 2.0, 3.0])), + "box": lambda: xy.box(np.array([1.0, 2.0, 3.0, 4.0])), + "hexbin": lambda: xy.hexbin(np.array([1.0, 2.0]), np.array([1.0, 2.0])), + } + chart = xy.polar_chart(builders[mark]()) + with pytest.raises(ValueError, match=r"coords='polar' does not support"): + chart.figure().build_payload_split() + + +def test_refusal_names_the_supported_set() -> None: + with pytest.raises(ValueError) as excinfo: + xy.polar_chart(xy.hist(np.array([1.0, 2.0]))).figure().build_payload_split() + message = str(excinfo.value) + for supported in ("area", "bar", "column", "line", "scatter"): + assert repr(supported) in message + + +def test_polar_forces_direct_tier() -> None: + """M4 buckets on a monotonic screen-x column and density bins an + axis-aligned grid; neither survives the polar transform.""" + from xy.config import DECIMATION_THRESHOLD + + theta = np.linspace(0.0, 2.0 * math.pi, DECIMATION_THRESHOLD * 2) + spec, _ = _chart(children=[xy.line(theta, np.sin(theta) + 2.0)]).figure().build_payload_split() + assert spec["traces"][0]["tier"] == "direct" + + +def test_theta_options_rejected_on_the_radial_axis() -> None: + with pytest.raises(ValueError, match="belong on an x axis"): + xy.polar_chart(xy.line(*_rose())).figure().set_axis("y", theta_unit="degrees") + + +# -- renderers ------------------------------------------------------------- + + +def _svg(chart) -> str: + return chart.figure().to_image(format="svg").decode() + + +def test_svg_draws_rings_spokes_and_one_outer_frame() -> None: + doc = _svg(_chart()) + assert doc.count('data-xy-grid="ring"') >= 3 + assert doc.count('data-xy-grid="spoke"') >= 3 + assert doc.count('data-xy-frame="polar"') == 1 + + +def test_svg_clips_marks_to_the_disc_but_not_the_legend() -> None: + """Two clip paths, deliberately. + + The rect clip also bounds every legend, so reusing one disc clip for both + made a legend sitting outside the circle vanish from the SVG while the + raster still drew it. + """ + doc = _svg(_chart()) + clips = re.findall(r"]*>(.*?)", doc, re.S) + assert any(" None: + theta, r = _rose() + doc = _svg(_chart(children=[xy.line(theta, r, name="series one")])) + assert "series one" in doc + + +def test_svg_angular_labels_use_pi_notation() -> None: + doc = _svg(_chart()) + assert "π/2" in doc or "π" in doc + + +def test_svg_degree_labels_carry_the_degree_sign() -> None: + theta, r = _rose() + doc = _svg(_chart(children=[xy.line(np.degrees(theta), r), xy.theta_axis(unit="degrees")])) + assert "°" in doc + + +def test_svg_line_geometry_matches_the_shared_projection() -> None: + """The rendered path must be the projection's output, not a lookalike.""" + theta, r = _rose(16) + chart = xy.polar_chart(xy.line(theta, r), width=520, height=520) + fig = chart.figure() + spec, blob = fig.build_payload_split() + _w, _h, _compact, plot = layout(spec) + project = _PolarProjection(spec["x_axis"], spec["y_axis"], plot) + want_x, want_y = project(theta, r) + + doc = _svg(chart) + path = re.search(r' None: + """Cross-renderer check: the PNG must have ink where the projection says, + and none at the cartesian location the same columns would produce. + + This is the check that catches an exporter silently keeping its own + geometry — the failure two export-parity audits found in this repo. + """ + # Constant r once autoranged to a padded band; it now centre-origins + # (constant-radius singleton fix), which would park these marks exactly on + # the outer ring and under the frame stroke. An explicit domain keeps them + # mid-disc so the negative probe below samples genuinely empty canvas. + theta = np.array([0.0, math.pi / 2, math.pi]) + r = np.array([1.0, 1.0, 1.0]) + chart = xy.polar_chart( + xy.scatter(theta, r, size=9.0, color="#000000"), + xy.r_axis(domain=(0.0, 2.0)), + width=400, + height=400, + ) + fig = chart.figure() + spec, _blob = fig.build_payload_split() + _w, _h, _compact, plot = layout(spec) + project = _PolarProjection(spec["x_axis"], spec["y_axis"], plot) + px, py = project(theta, r) + + from test_png_export import _decode_rgba + + # scale=1 so PNG pixels are in the same units as the layout rect. + pixels = _decode_rgba(fig.to_image(format="png", scale=1)) + height, width, _ = pixels.shape + + def darkest_near(x: float, y: float) -> int: + ix, iy = int(round(x)), int(round(y)) + assert 0 <= ix < width and 0 <= iy < height, f"({ix},{iy}) outside {width}x{height}" + window = pixels[ + max(0, iy - 3) : min(height, iy + 4), max(0, ix - 3) : min(width, ix + 4), 0 + ] + return int(window.min()) + + for i in range(len(theta)): + assert darkest_near(float(px[i]), float(py[i])) < 128, ( + f"raster has no ink at the projected position of point {i}" + ) + + # And nothing where a cartesian reading of the same columns would land. + # theta=pi, r=1 would sit far right on an x axis spanning [0, 2pi]; under + # polar it belongs on the left. If this fires, the raster path ignored + # `coords` and drew the columns as x/y. + from xy._svg import _Scale + + cart_x = _Scale(spec["x_axis"], plot["x"], plot["x"] + plot["w"])(math.pi) + cart_y = _Scale(spec["y_axis"], plot["y"] + plot["h"], plot["y"])(1.0) + assert darkest_near(float(cart_x), float(cart_y)) >= 128, ( + "raster drew a mark at the cartesian position — it ignored coords='polar'" + ) + + +# -- area and radar (P3) --------------------------------------------------- + + +def test_area_renders_under_polar() -> None: + theta, r = _rose(24) + doc = _svg(_chart(children=[xy.area(theta, r, color="#2563eb")])) + assert " None: + """Closing with the first *angle* would sweep the final segment backwards + through the whole circle; the closing sample sits at a full turn instead.""" + chart = xy.radar_chart(["a", "b", "c", "d"], xy.area([1.0, 2.0, 3.0, 4.0])) + mark = next(c for c in chart.children if getattr(c, "kind", None) == "area") + assert mark.x[-1] == pytest.approx(2.0 * math.pi) + assert mark.x[0] == pytest.approx(0.0) + assert mark.y[-1] == mark.y[0] == pytest.approx(1.0) + + +def test_radar_chart_labels_spokes_with_the_categories() -> None: + cats = ["speed", "power", "range", "agility"] + doc = xy.radar_chart(cats, xy.area([0.9, 0.7, 0.5, 0.8])).figure().to_image(format="svg") + text = doc.decode() + for name in cats: + assert name in text + + +def test_radar_chart_authored_theta_axis_wins() -> None: + chart = xy.radar_chart(["a", "b", "c"], xy.area([1.0, 2.0, 3.0]), xy.theta_axis(label="custom")) + axes = [c for c in chart.children if isinstance(c, xy.Axis) and c.which == "x"] + assert len(axes) == 1 and axes[0].label == "custom" + + +def test_radar_chart_rejects_a_value_count_mismatch() -> None: + with pytest.raises(ValueError, match="but there are 4 categories"): + xy.radar_chart(["a", "b", "c", "d"], xy.area([1.0, 2.0])) + + +def test_radar_chart_needs_three_categories() -> None: + with pytest.raises(ValueError, match="at least 3 categories"): + xy.radar_chart(["a", "b"], xy.area([1.0, 2.0])) + + +def test_authored_tick_labels_beat_the_angle_format() -> None: + """Radar category names must win over pi notation on the theta axis.""" + doc = _svg( + _chart( + children=[ + xy.line(*_rose()), + xy.theta_axis(tick_values=[0.0, math.pi], tick_labels=["north", "south"]), + ] + ) + ) + assert "north" in doc and "south" in doc + + +# -- bars and wind rose (P4) ----------------------------------------------- + + +def test_polar_bars_render_as_wedge_paths_in_svg() -> None: + """A polar bar is an annular sector: SVG expresses the arcs with `A`. + + A 180-degree bar with chorded ends would read as a triangle. + """ + chart = xy.polar_bar_chart( + xy.bar([0.0, math.pi / 2, math.pi], [1.0, 2.0, 3.0], width=0.8), + width=420, + height=420, + ) + doc = chart.figure().to_image(format="svg").decode() + wedges = [d for d in re.findall(r'= 3, "expected one arc path per bar" + + +def test_polar_wedge_points_close_the_sector() -> None: + from xy._svg import polar_wedge_points + + project = _PolarProjection({}, {"range": [0.0, 1.0]}, {"x": 0, "y": 0, "w": 400, "h": 400}) + # A ring segment with a hole: both arcs, so 2*(steps+1) points. + poly = polar_wedge_points(project, 0.0, math.pi / 2, 0.5, 1.0, steps=8) + assert len(poly) == 18 + outer = math.hypot(poly[0][0] - 200.0, poly[0][1] - 200.0) + inner = math.hypot(poly[-1][0] - 200.0, poly[-1][1] - 200.0) + assert outer == pytest.approx(200.0, abs=1e-6) + assert inner == pytest.approx(100.0, abs=1e-6) + + +def test_polar_wedge_from_the_centre_is_a_fan() -> None: + from xy._svg import polar_wedge_points + + project = _PolarProjection({}, {"range": [0.0, 1.0]}, {"x": 0, "y": 0, "w": 400, "h": 400}) + poly = polar_wedge_points(project, 0.0, math.pi / 2, 0.0, 1.0, steps=8) + assert poly[0] == pytest.approx((200.0, 200.0)) + assert len(poly) == 10 + + +def test_wind_rose_counts_every_observation() -> None: + rng = np.random.default_rng(3) + directions = rng.uniform(0, 360, 500) + speeds = rng.gamma(2.0, 2.0, 500) + chart = xy.wind_rose(directions, speeds, sectors=12) + bars = [c for c in chart.children if getattr(c, "kind", None) == "bar"] + # `y` is each band's own count (a HEIGHT above its base), so the totals sum + # directly. This assertion used to read `sum(y) - sum(base)`, which is what + # let the double-stacking bug through: it happened to cancel out. + counted = sum(float(np.asarray(b.y).sum()) for b in bars) + assert counted == pytest.approx(500.0) + + +def test_wind_rose_bands_stack_without_gaps() -> None: + rng = np.random.default_rng(4) + chart = xy.wind_rose(rng.uniform(0, 360, 300), rng.gamma(2.0, 2.0, 300), sectors=8) + bars = [c for c in chart.children if getattr(c, "kind", None) == "bar"] + for lower, upper in pairwise(bars): + # A band starts where the one below it ENDS: base + height, since the + # value is a height above the base rather than an absolute radius. + below_top = np.asarray(lower.props["base"], dtype=float) + np.asarray(lower.y, dtype=float) + assert np.asarray(upper.props["base"], dtype=float) == pytest.approx(below_top) + + +def test_wind_rose_uses_the_compass_convention() -> None: + """0 degrees is north and angles increase clockwise, or the rose is a + mirror image of the weather it describes.""" + rng = np.random.default_rng(5) + chart = xy.wind_rose(rng.uniform(0, 360, 100), rng.gamma(2.0, 2.0, 100)) + axis = next(c for c in chart.children if isinstance(c, xy.Axis) and c.which == "x") + assert axis.theta_zero == "N" + assert axis.theta_direction == "clockwise" + assert axis.theta_unit == "degrees" + + +def test_wind_rose_bins_bearings_centred_on_each_sector() -> None: + """A bearing of exactly 0 belongs to the sector centred on north, not to + the one starting there.""" + chart = xy.wind_rose(np.array([0.0, 0.0, 90.0]), np.array([1.0, 1.0, 1.0]), sectors=4) + bars = [c for c in chart.children if getattr(c, "kind", None) == "bar"] + totals = np.zeros(4) + for b in bars: + totals += np.asarray(b.y) - np.asarray(b.props["base"]) + assert totals[0] == pytest.approx(2.0) # sector centred on 0 degrees + assert totals[1] == pytest.approx(1.0) # sector centred on 90 degrees + + +def test_wind_rose_rejects_mismatched_inputs() -> None: + with pytest.raises(ValueError, match="same length"): + xy.wind_rose(np.array([0.0, 90.0]), np.array([1.0])) + + +def test_wind_rose_band_labels_are_readable() -> None: + """Raw quantiles make a legend like '<= 2.76651'.""" + rng = np.random.default_rng(6) + chart = xy.wind_rose(rng.uniform(0, 360, 400), rng.gamma(3.0, 2.0, 400)) + for bar_mark in [c for c in chart.children if getattr(c, "kind", None) == "bar"]: + value = bar_mark.name.split()[-1] + assert len(value.split(".")[-1]) <= 3, f"unreadable band label {bar_mark.name!r}" + + +def test_polar_bars_reach_the_raster_export() -> None: + chart = xy.polar_bar_chart( + xy.bar([0.0, math.pi], [1.0, 1.0], width=1.0, color="#000000"), + width=400, + height=400, + ) + fig = chart.figure() + spec, _ = fig.build_payload_split() + _w, _h, _c, plot = layout(spec) + project = _PolarProjection(spec["x_axis"], spec["y_axis"], plot) + from test_png_export import _decode_rgba + + pixels = _decode_rgba(fig.to_image(format="png", scale=1)) + height, width, _ = pixels.shape + # Mid-radius along theta=0 must be inside the first wedge. + px, py = project(0.0, 0.5) + window = pixels[max(0, int(py) - 2) : int(py) + 3, max(0, int(px) - 2) : int(px) + 3, 0] + assert int(window.min()) < 128, "raster drew no wedge at theta=0" + + +# -- styling parity -------------------------------------------------------- + + +def _styled(theta_style=None, r_style=None, **axis_kwargs): + theta_kw = axis_kwargs.get("theta", {}) + r_kw = axis_kwargs.get("r", {}) + if theta_style: + theta_kw = {**theta_kw, "style": theta_style} + if r_style: + r_kw = {**r_kw, "style": r_style} + theta, r = _rose() + return xy.polar_chart( + xy.line(theta, r), + xy.theta_axis(**theta_kw), + xy.r_axis(**r_kw), + width=400, + height=400, + ) + + +def test_grid_colour_separates_rings_from_spokes() -> None: + """The radial axis owns the rings, the angular axis owns the spokes. + + Conflating them is the easy mistake here: the cartesian code hides grid + lines and labels together through one hideX/hideY pair. + """ + doc = _styled(theta_style={"grid_color": "#00ffff"}, r_style={"grid_color": "#ff00ff"}) + doc = doc.figure().to_image(format="svg").decode() + ring = re.search(r' None: + """`text=False` works by setting tick_label_color transparent. + + The polar label writers originally read only the chart-level slot, so the + shorthand — and any explicit tick_label_color — silently did nothing while + the browser client honoured both. + """ + chart = _styled(**{which: {"text": False}}) + doc = chart.figure().to_image(format="svg").decode() + theta = re.search(r']*fill="([^"]*)"', doc) + radial = re.search(r']*fill="([^"]*)"', doc) + assert theta is not None and radial is not None + if theta_fill: + assert theta.group(1) == theta_fill + assert radial.group(1) != theta_fill + if r_fill: + assert radial.group(1) == r_fill + assert theta.group(1) != r_fill + + +def test_explicit_tick_label_colour_beats_the_chart_slot() -> None: + doc = _styled( + theta_style={"tick_label_color": "#ff0000"}, + r_style={"tick_label_color": "#00ff00"}, + ) + doc = doc.figure().to_image(format="svg").decode() + assert re.search(r']*fill="#ff0000"', doc) + assert re.search(r']*fill="#00ff00"', doc) + + +def test_raster_honours_axis_styling_too() -> None: + """The exporters share geometry but not text placement, so the raster path + needs its own assertion — this is where styling has silently diverged.""" + from test_png_export import _decode_rgba + + chart = _styled( + theta_style={"tick_label_color": "#ff0000"}, + r_style={"grid_color": "#ff00ff"}, + ) + pixels = _decode_rgba(chart.figure().to_image(format="png", scale=1)) + + def count(rgb: tuple[int, int, int]) -> int: + delta = np.abs(pixels[:, :, :3].astype(int) - np.array(rgb)).sum(axis=2) + return int((delta < 30).sum()) + + assert count((255, 0, 0)) > 0, "angular tick labels ignored tick_label_color" + assert count((255, 0, 255)) > 0, "rings ignored the radial grid_color" + + +def test_show_false_clears_the_outer_frame() -> None: + doc = _styled(**{"theta": {"show": False}}).figure().to_image(format="svg").decode() + frame = re.search(r'', doc) + assert frame is not None + assert 'stroke-width="0"' in frame.group(0) or "#00000000" in frame.group(0) + + +# -- audit round 2: chrome leaks, strategy off, angle, ceiling, pdf -------- + + +def test_no_cartesian_tick_stubs_leak_into_polar_svg() -> None: + """Edge-anchored tick marks have no polar geometry; they used to leak in + from the cartesian emission loops (raster never drew them — divergence).""" + theta, r = _rose() + chart = xy.polar_chart( + xy.line(theta, r), + xy.theta_axis(style={"tick_length": 8.0, "tick_color": "#ff00ff"}), + width=400, + height=400, + ) + doc = chart.figure().to_image(format="svg").decode() + stubs = [m for m in re.findall(r"", doc) if "ff00ff" in m] + assert stubs == [] + + +def test_strategy_off_hides_polar_labels_but_keeps_grid() -> None: + theta, r = _rose() + chart = xy.polar_chart( + xy.line(theta, r), xy.theta_axis(tick_label_strategy="off"), width=400, height=400 + ) + doc = chart.figure().to_image(format="svg").decode() + assert 'data-xy-tick="theta"' not in doc + assert doc.count('data-xy-grid="spoke"') >= 3 # grid survives "off" + assert 'data-xy-tick="r"' in doc # the other axis keeps its labels + + +def test_tick_label_angle_rotates_polar_labels() -> None: + theta, r = _rose() + chart = xy.polar_chart( + xy.line(theta, r), xy.theta_axis(tick_label_angle=45.0), width=400, height=400 + ) + doc = chart.figure().to_image(format="svg").decode() + rotated = re.findall(r']*transform="rotate\(45 ', doc) + assert len(rotated) >= 3 + + +def test_theta_axis_title_stays_on_canvas() -> None: + """The rect re-cut reclaims the bottom gutter — except when the theta axis + has a title, which is drawn there and was pushed below the canvas edge.""" + theta, r = _rose() + chart = xy.polar_chart(xy.line(theta, r), xy.theta_axis(label="bearing"), width=400, height=400) + doc = chart.figure().to_image(format="svg").decode() + m = re.search(r']*y="(-?[\d.]+)"[^>]*>bearing', doc) + assert m is not None and 0 <= float(m.group(1)) <= 400 + + +def test_polar_point_ceiling_is_enforced() -> None: + from xy.config import POLAR_DIRECT_CEILING + + theta = np.zeros(POLAR_DIRECT_CEILING + 1) + with pytest.raises(ValueError, match="polar ceiling"): + xy.polar_chart(xy.scatter(theta, theta)).figure().build_payload_split() + + +def test_radar_merges_categories_into_an_authored_theta_axis() -> None: + """An authored theta axis customises the spokes; it must not silently + replace the category labels with numeric angles.""" + chart = xy.radar_chart( + ["speed", "power", "range"], xy.area([1.0, 2.0, 3.0]), xy.theta_axis(label="custom") + ) + doc = chart.figure().to_image(format="svg").decode() + for name in ("speed", "power", "range", "custom"): + assert name in doc + + +def test_polar_pdf_export_round_trips() -> None: + """The PDF converter's clip subset was rect-only, so every polar chart + raised. The disc clip now lands as four Bezier quarter-arcs.""" + theta, r = _rose() + pdf = _chart(children=[xy.line(theta, r)]).figure().to_image(format="pdf") + assert pdf[:5] == b"%PDF-" + assert len(pdf) > 800 + + +def test_channel_styled_polar_scatter_stays_inside_the_disc() -> None: + """A colormapped/sized polar scatter took a second Rust affine fast path + that projected (theta, r) as cartesian (x, y): a diagonal line of points + outside the frame ring.""" + rng = np.random.default_rng(5) + theta = rng.uniform(0, 2 * math.pi, 200) + r = rng.uniform(0.2, 1.0, 200) + chart = xy.polar_chart( + xy.scatter(theta, r, color=r, colormap="viridis", size=6.0), width=400, height=400 + ) + fig = chart.figure() + spec, _ = fig.build_payload_split() + _w, _h, _c, plot = layout(spec) + project = _PolarProjection(spec["x_axis"], spec["y_axis"], plot) + + from test_png_export import _decode_rgba + + pixels = _decode_rgba(fig.to_image(format="png", scale=1)) + height, width, _ = pixels.shape + yy, xx = np.mgrid[0:height, 0:width] + outside = np.hypot(xx - project.cx, yy - project.cy) > project.radius + 12 + # Colormapped marks are saturated colours; chrome text is near-grey. Count + # strongly-saturated ink outside the disc. + rgb = pixels[:, :, :3].astype(int) + saturated = (rgb.max(axis=2) - rgb.min(axis=2)) > 60 + assert int((saturated & outside).sum()) == 0 + + +# -- layout robustness (audit round 2) ------------------------------------- + + +@pytest.mark.parametrize( + ("width", "height"), + [(60, 60), (80, 80), (100, 100), (120, 120), (1200, 300), (300, 900), (400, 400)], +) +def test_disc_stays_inside_the_canvas_at_every_size(width: int, height: int) -> None: + """The cartesian rect has a 40px floor that can exceed a small canvas, and + a disc centred in it then leaves the page (80x80 drew out to x=86).""" + theta, r = _rose(30) + chart = xy.polar_chart(xy.line(theta, r), width=width, height=height) + spec, _ = chart.figure().build_payload_split() + canvas_w, canvas_h, _compact, plot = layout(spec) + project = _PolarProjection(spec["x_axis"], spec["y_axis"], plot) + assert project.cx - project.radius >= -0.5 + assert project.cx + project.radius <= canvas_w + 0.5 + assert project.cy - project.radius >= -0.5 + assert project.cy + project.radius <= canvas_h + 0.5 + assert project.radius > 0 + + +def test_horizontal_colorbar_keeps_its_gutter() -> None: + """The rect re-cut extends the plot downward; a horizontal colorbar hangs + off the plot's bottom edge and was pushed clean off the canvas.""" + rng = np.random.default_rng(3) + theta = rng.uniform(0, 2 * math.pi, 120) + r = rng.uniform(0.1, 1.0, 120) + chart = xy.polar_chart( + xy.scatter(theta, r, color=r, colormap="viridis"), + xy.colorbar(orientation="horizontal"), + width=520, + height=500, + ) + doc = chart.figure().to_image(format="svg").decode() + tops = [float(y) for y in re.findall(r']*y="([\d.]+)"', doc)] + assert tops and max(tops) < 500 + + +def test_long_category_labels_reserve_measured_room() -> None: + """A fixed 30px allowance hard-clipped authored radar category names.""" + cats = ["EAST-NORTH-EAST", "SOUTH-SOUTH-WEST", "NORTH-WEST", "SOUTH-EAST", "WEST"] + chart = xy.radar_chart(cats, xy.area([0.9, 0.6, 0.7, 0.5, 0.8]), width=600, height=560) + doc = chart.figure().to_image(format="svg").decode() + xs = [float(x) for x in re.findall(r'= 0 and max(xs) <= 600 + + +def test_radar_fill_false_outlines_instead_of_filling() -> None: + chart = xy.radar_chart(["a", "b", "c"], xy.area([1.0, 2.0, 3.0]), fill=False) + kinds = [c.kind for c in chart.children if isinstance(c, xy.Mark)] + assert kinds == ["line"] + + +def test_radar_rejects_marks_it_cannot_close() -> None: + with pytest.raises(ValueError, match="supports area and line marks"): + xy.radar_chart(["a", "b", "c"], xy.scatter([1.0, 2.0, 3.0], [1.0, 2.0, 3.0])) + + +def test_radar_rejects_column_names_with_a_readable_error() -> None: + with pytest.raises(ValueError, match="must carry values directly"): + xy.radar_chart(["a", "b", "c"], xy.area("speed")) + + +# -- radial clipping semantics --------------------------------------------- + + +def test_below_range_scatter_is_culled_not_mirrored() -> None: + """A radius below an authored r_lo normalizes negative and mirrors through + the centre to a position INSIDE the disc, where no clip can hide it. The + client shader NaN-culls the point; both exporters must drop the same row.""" + theta = np.array([0.0, math.pi / 2]) + r = np.array([0.2, 0.75]) # first point below r_lo + chart = xy.polar_chart( + xy.scatter(theta, r, size=9.0, color="#000000"), + xy.r_axis(domain=(0.5, 1.0)), + width=400, + height=400, + ) + fig = chart.figure() + spec, _ = fig.build_payload_split() + _w, _h, _c, plot = layout(spec) + project = _PolarProjection(spec["x_axis"], spec["y_axis"], plot) + mirrored_x, mirrored_y = (float(v) for v in project(0.0, 0.2)) + kept_x, kept_y = (float(v) for v in project(math.pi / 2, 0.75)) + + doc = fig.to_image(format="svg").decode() + centres = [ + (float(cx), float(cy)) + for cx, cy in re.findall(r' int: + ix, iy = int(round(x)), int(round(y)) + window = pixels[max(0, iy - 3) : iy + 4, max(0, ix - 3) : ix + 4, 0] + return int(window.min()) + + assert darkest_near(mirrored_x, mirrored_y) >= 128, "raster drew the mirrored point" + assert darkest_near(kept_x, kept_y) < 128, "raster dropped the in-range point too" + + +def test_above_range_scatter_leaves_no_ink_beyond_the_ring() -> None: + """The raster path has no disc clip, so an above-range point used to draw + past the outer ring into the corner the disc does not cover.""" + chart = xy.polar_chart( + xy.scatter(np.array([math.pi / 4]), np.array([1.3]), size=10.0, color="#000000"), + xy.r_axis(domain=(0.0, 1.0)), + width=400, + height=400, + ) + fig = chart.figure() + spec, _ = fig.build_payload_split() + _w, _h, _c, plot = layout(spec) + project = _PolarProjection(spec["x_axis"], spec["y_axis"], plot) + px, py = (float(v) for v in project(math.pi / 4, 1.3)) + + from test_png_export import _decode_rgba + + pixels = _decode_rgba(fig.to_image(format="png", scale=1)) + window = pixels[int(py) - 3 : int(py) + 4, int(px) - 3 : int(px) + 4, 0] + assert int(window.min()) >= 128, "raster drew a mark beyond the outer ring" + + +def test_line_vertices_outside_the_radial_range_split_the_path() -> None: + """A chord with a culled endpoint is dropped whole in every renderer (§8): + the path splits into visible runs instead of routing through the mirrored + position of the out-of-range vertex.""" + theta = np.array([0.0, math.pi / 4, math.pi / 2, 3 * math.pi / 4, math.pi]) + r = np.array([0.75, 0.8, 0.2, 0.8, 0.75]) # middle vertex below r_lo + chart = xy.polar_chart( + xy.line(theta, r, color="#2563eb"), + xy.r_axis(domain=(0.5, 1.0)), + width=400, + height=400, + ) + doc = chart.figure().to_image(format="svg").decode() + line_path = re.search(r' None: + """Arc endpoints coincide at a full turn and SVG omits such segments, so a + 100% donut slice (a progress ring at 100%) rendered as nothing.""" + chart = xy.polar_chart( + xy.bar([180.0], [1.0], base=0.5, width=360.0, color="#7c3aed"), + xy.theta_axis(unit="degrees"), + xy.r_axis(domain=(0.0, 1.0)), + width=400, + height=400, + ) + fig = chart.figure() + doc = fig.to_image(format="svg").decode() + wedges = [d for d in re.findall(r' None: + """`_fmt_angle` used a hardcoded step of 1, so an authored 22.5-degree grid + labelled itself 22°/68° (round-half-even) instead of 22.5°/67.5°.""" + theta, r = _rose() + chart = xy.polar_chart( + xy.line(np.degrees(theta), r), + xy.theta_axis(unit="degrees", tick_values=[0.0, 22.5, 45.0, 67.5, 90.0]), + width=400, + height=400, + ) + doc = chart.figure().to_image(format="svg").decode() + assert "22.5°" in doc and "67.5°" in doc + + +def test_area_fill_clamps_to_the_radial_range_rather_than_vanishing() -> None: + """A fill's extent at each angle is [base, top] intersected with the radial + range. Culling an out-of-range endpoint instead made a whole radar polygon + disappear the moment zoom lifted the minimum above its baseline.""" + theta = np.linspace(0.0, 2.0 * math.pi, 24) + values = np.full(24, 3.0) + chart = xy.polar_chart( + xy.area(theta, values, color="#2563eb"), + xy.r_axis(domain=(1.0, 2.0)), # every value sits ABOVE the range + width=400, + height=400, + ) + doc = chart.figure().to_image(format="svg").decode() + fills = re.findall(r' None: + """A bar whose tip crosses the outer ring draws up to the ring.""" + from xy._svg import polar_wedge_points + + project = _PolarProjection({}, {"range": [0.0, 1.0]}, {"x": 0, "y": 0, "w": 400, "h": 400}) + poly = polar_wedge_points(project, 0.0, math.pi / 4, 0.0, 5.0, steps=8) + assert poly, "an over-range wedge vanished" + for px, py in poly: + assert math.hypot(px - 200.0, py - 200.0) <= 200.0 + 1e-6 + + +def test_wedge_entirely_outside_the_range_draws_nothing() -> None: + from xy._svg import polar_wedge_points + + project = _PolarProjection({}, {"range": [0.0, 1.0]}, {"x": 0, "y": 0, "w": 400, "h": 400}) + assert polar_wedge_points(project, 0.0, math.pi / 4, 2.0, 5.0, steps=8) == [] + + +def test_bar_below_the_radial_minimum_is_clipped_not_mirrored() -> None: + """A radius below the minimum normalizes negative, which would reflect the + wedge through the centre into the opposite quadrant.""" + from xy._svg import polar_wedge_points + + project = _PolarProjection({}, {"range": [2.0, 4.0]}, {"x": 0, "y": 0, "w": 400, "h": 400}) + poly = polar_wedge_points(project, 0.0, math.pi / 4, 0.0, 3.0, steps=8) + for px, py in poly: + # theta in [0, pi/4] is the upper-right quadrant; a mirrored point + # would land left of or below the centre. + assert px >= 200.0 - 1e-6 + assert py <= 200.0 + 1e-6 + + +# -- unequal-width sectors (pie/donut composition) ------------------------- + + +def test_unequal_slice_widths_render_as_wedges_not_rectangles() -> None: + """A donut needs per-slice angular width. Unequal widths route to the + four-edge rect path, which drew Cartesian rectangles inside polar chrome + until that path learned sectors.""" + slices = [ + xy.bar([45.0], [1.0], base=0.5, width=90.0, color="#7c3aed"), + xy.bar([200.0], [1.0], base=0.5, width=180.0, color="#0284c7"), + ] + doc = ( + xy.polar_chart( + *slices, + xy.theta_axis(unit="degrees"), + xy.r_axis(domain=(0.0, 1.0)), + width=420, + height=420, + ) + .figure() + .to_image(format="svg") + .decode() + ) + assert "")[-1], "slices drew as cartesian rects" + assert len([d for d in re.findall(r'= 2 + + +def test_point_annotations_project_through_polar() -> None: + """Centre text is `(any angle, r=0)`. The separable scales would put that + at the bottom-left corner instead of the middle of the disc.""" + doc = ( + xy.polar_chart( + xy.bar([45.0], [1.0], base=0.5, width=80.0), + xy.text(0.0, 0.0, "CENTRE", dx=0, dy=0, anchor="middle"), + xy.theta_axis(unit="degrees", show=False), + xy.r_axis(domain=(0.0, 1.0), show=False), + width=400, + height=400, + ) + .figure() + .to_image(format="svg") + .decode() + ) + m = re.search(r'CENTRE', doc) + assert m is not None, "the annotation was dropped" + assert 180.0 <= float(m.group(1)) <= 220.0 + assert 180.0 <= float(m.group(2)) <= 225.0 + + +# -- customizability probe fixes (evilcharts ECharts pie blocks) ------------ + + +def test_authored_padding_survives_the_polar_recut() -> None: + """`padding=` is how a donut reserves a band for its legend or caption. + + The recut used to symmetrise the cartesian gutters away and hand the whole + canvas to the disc, so an authored bottom band silently vanished while the + same padding on a cartesian chart was honoured. + """ + theta, r = _rose() + marks = [xy.line(theta, r), xy.theta_axis(show=False), xy.r_axis(show=False)] + plain = xy.polar_chart(*marks, width=400, height=420) + padded = xy.polar_chart(*marks, width=400, height=420, padding=[10, 10, 140, 10]) + + bottoms = [] + for chart in (plain, padded): + spec, _ = chart.figure().build_payload_split() + _w, _h, _c, plot = layout(spec) + project = _PolarProjection(spec["x_axis"], spec["y_axis"], plot) + bottoms.append(project.cy + project.radius) + assert bottoms[1] < bottoms[0] - 100, "authored bottom padding was reclaimed by the recut" + assert bottoms[1] <= 420 - 130 + + +def test_polar_wedge_corner_radius_reaches_every_renderer() -> None: + """`corner_radius` used to be accepted, shipped on the wire and ignored by + all three renderers — a silent approximation (§28). Rounding pulls the + corners in, so a rounded wedge covers strictly less area than a square one. + """ + from xy._svg import polar_wedge_points + + project = _PolarProjection( + {"theta_unit": "degrees"}, {"range": [0.0, 1.0]}, {"x": 0, "y": 0, "w": 400, "h": 400} + ) + square = polar_wedge_points(project, 0.0, 90.0, 0.5, 1.0, steps=24) + rounded = polar_wedge_points(project, 0.0, 90.0, 0.5, 1.0, steps=24, corner_radius=14.0) + assert rounded and square + + def area(poly: list[tuple[float, float]]) -> float: + total = 0.0 + for (x0, y0), (x1, y1) in zip(poly, [*poly[1:], poly[0]], strict=True): + total += x0 * y1 - x1 * y0 + return abs(total) / 2.0 + + assert area(rounded) < area(square), "corner_radius did not round the sector" + # Rounding removes area near the corners only — never more than a rough + # bound of four corner squares, or the profile is wrong rather than rounded. + assert area(square) - area(rounded) < 4 * 14.0 * 14.0 + + +def test_rounded_wedge_stays_within_the_square_wedge() -> None: + """Rounding must inset the boundary, never bulge past it.""" + from xy._svg import polar_wedge_points + + project = _PolarProjection( + {"theta_unit": "degrees"}, {"range": [0.0, 1.0]}, {"x": 0, "y": 0, "w": 400, "h": 400} + ) + poly = polar_wedge_points(project, 0.0, 90.0, 0.5, 1.0, steps=24, corner_radius=14.0) + for px, py in poly: + radius = math.hypot(px - 200.0, py - 200.0) + assert 100.0 - 1e-6 <= radius <= 200.0 + 1e-6 + angle = math.degrees(math.atan2(200.0 - py, px - 200.0)) + assert -1e-6 <= angle <= 90.0 + 1e-6 + + +def test_svg_rounded_slice_differs_from_a_square_one() -> None: + def slice_path(corner_radius: float) -> str: + chart = xy.polar_chart( + xy.bar([45.0], [1.0], base=0.5, width=80.0, corner_radius=corner_radius), + xy.theta_axis(unit="degrees", show=False), + xy.r_axis(domain=(0.0, 1.0), show=False), + width=400, + height=400, + ) + doc = chart.figure().to_image(format="svg").decode() + paths = re.findall(r' None: + """The gradient reached the SVG (`url(#g)`) and the browser but the raster + branch painted flat, so the PNG disagreed with both. + """ + from test_png_export import _decode_rgba + + chart = xy.polar_chart( + xy.bar( + [180.0], + [1.0], + base=0.4, + width=340.0, + color="#7c3aed", + fill="linear-gradient(to top, #7c3aed, #34d399)", + ), + xy.theta_axis(unit="degrees", show=False), + xy.r_axis(domain=(0.0, 1.0), show=False), + width=320, + height=320, + ) + fig = chart.figure() + assert "url(#" in fig.to_image(format="svg").decode() + + pixels = _decode_rgba(fig.to_image(format="png", scale=1)) + spec, _ = fig.build_payload_split() + _w, _h, _c, plot = layout(spec) + project = _PolarProjection(spec["x_axis"], spec["y_axis"], plot) + # Sample where the gradient actually varies: "to top" runs up the wedge's + # bounding box, so two points at the same radius but opposite ends of the + # vertical span must differ. A flat fill gives one colour at both. + swatches = [] + for degrees in (90.0, 270.0): + px, py = (float(v) for v in project(degrees, 0.8)) + swatches.append(tuple(int(v) for v in pixels[int(py), int(px), :3])) + assert swatches[0] != swatches[1], f"raster painted the wedge flat: {swatches}" + + +def test_client_projects_point_annotations_through_polar() -> None: + """Source guard: the client annotation layer must not read (theta, r) with + the separable cartesian scales. + + Every annotation in js/src/51_annotations.ts used to go through + `_dataPxX`/`_dataPxY`, so the browser strung a polar chart's labels out in + a horizontal row while both exporters placed them correctly — the exact + cross-renderer divergence this coordinate system is built to avoid. There + is no headless-JS harness for the DOM label layer, so the binding is a + source assertion plus the shared placement contract in polar-axes.md §9. + """ + source = (ROOT / "js" / "src" / "51_annotations.ts").read_text(encoding="utf-8") + point_kinds = source.count("_dataPxPoint(") + assert point_kinds >= 6, "point-anchored annotations must use the joint polar projection" + # rule/band stay on the separable path on purpose (deferred geometry). + assert "_dataPxX(Number(ann.value))" in source + + +# -- review round 3: reversed-r exports, PDF clips, singleton range, area cull + + +def test_reversed_radial_axis_keeps_wedges_in_static_exports() -> None: + """The static twin of the client reversed-r regression: `norm_radius` is + decreasing on a reversed axis, so taking the normalized endpoints + positionally made outer <= inner and silently dropped every wedge from + SVG/PNG while the shader (which min/maxes) kept drawing them.""" + + def wedge_paths(**rkw) -> int: + chart = xy.polar_chart( + xy.bar([0.0, 1.0, 2.0], [3.0, 5.0, 4.0], width=0.5), + xy.r_axis(**rkw), + width=360, + height=340, + ) + doc = chart.figure().to_image(format="svg").decode() + return len([d for d in re.findall(r' None: + """The marks clip is a only for the full disc; hole/sector emit a + clipPath, which the PDF converter refused — so the headline polar + features crashed `to_image(format="pdf")`. The path clip now lowers to PDF + ops with the SVG clip-rule mapped onto W/W*.""" + chart = xy.polar_chart( + xy.bar([30.0, 70.0], [3.0, 5.0], width=8.0), + xy.theta_axis(unit="degrees", **theta_kwargs), + xy.r_axis(**r_kwargs), + width=320, + height=300, + ) + pdf = chart.figure().to_image(format="pdf") + assert pdf[:5] == b"%PDF-" and len(pdf) > 800 + + +def test_constant_radius_series_still_starts_at_the_centre() -> None: + """The singleton (lo == hi) early-return fired before the polar branch, so + constant-radius data resolved to a padded [4.75, 5.25] — a unit circle + rendered as a ring floating mid-disc.""" + spec, _ = ( + xy.polar_chart(xy.line([0.0, 1.0, 2.0, 3.0], [5.0] * 4), width=320, height=300) + .figure() + .build_payload_split() + ) + assert spec["y_axis"]["range"] == [0.0, 5.0] + + +def test_raster_polar_area_culls_vertices_outside_the_sector() -> None: + """The raster polar area branch only clamped radii; SVG (via _curve_path's + position_mask) and the shader cull out-of-sector and NaN vertices. The PNG + painted the full-turn polygon with chords across the sector boundary and + let NaN reach the display list (§19).""" + from xy import _raster + + captured: list[int] = [] + original_fill = _raster._Cmd.fill + original_grad = _raster._Cmd.grad + + def spy_fill(self, pts, color): + captured.append(len(pts)) + return original_fill(self, pts, color) + + def spy_grad(self, pts, g0, g1, stops): + captured.append(len(pts)) + return original_grad(self, pts, g0, g1, stops) + + _raster._Cmd.fill = spy_fill + _raster._Cmd.grad = spy_grad + try: + theta = np.linspace(0.0, 360.0, 40) + r = 1 + 0.3 * np.sin(np.radians(theta) * 3) + r_nan = r.copy() + r_nan[20] = np.nan + chart = xy.polar_chart( + xy.area(theta, r_nan), + xy.theta_axis(unit="degrees", sector=(0.0, 90.0)), + xy.r_axis(domain=(0.0, 1.5)), + width=320, + height=300, + ) + chart.figure().to_image(format="png", scale=1) + finally: + _raster._Cmd.fill = original_fill + _raster._Cmd.grad = original_grad + + in_sector = int(((theta >= 0.0) & (theta <= 90.0)).sum()) + area_polys = [n for n in captured if n > 8] + assert area_polys, "the area fill vanished entirely" + # Every emitted polygon must be bounded by the visible-run size, not the + # full 2 * 40-vertex turn. + assert all(n <= 2 * in_sector for n in area_polys), (theta.size, area_polys) + + +# -- pie_chart composition --------------------------------------------------- + + +def test_pie_chart_slices_carry_category_value_and_share() -> None: + # Counts, not shares: the value and the percentage are different numbers, so + # both earn their place in the row. + chart = xy.pie_chart(["a", "b", "c"], [27.0, 21.0, 13.0], width=300, height=300) + spec, _ = chart.figure().build_payload() + names = [t["name"] for t in spec["traces"]] + assert names == ["a 27 (44%)", "b 21 (34%)", "c 13 (21%)"] + # The composition owns its readout: the tooltip is the slice name alone, + # never theta (layout) or the constant rim radius. + assert spec["tooltip"] == {"title": "{name}"} + # Full spans: the gap is carved by the renderer at a constant pixel width + # rather than by shrinking each angle, so the shares stay exact. + widths = [t["bar"]["width"] for t in spec["traces"]] + assert sum(widths) == pytest.approx(360.0, abs=1e-6) + + +def test_pie_chart_never_prints_the_same_number_twice() -> None: + """Percentage-shaped values made both defaults render the same digits. + + `[40, 30, 20, 10]` is how most pie data arrives, and it came out as + "Direct 40 (40%)" — a legend row that reads as repeated text, and long + enough to overflow the legend box that then grew a horizontal scrollbar. + """ + chart = xy.pie_chart( + ["Direct", "Partner", "Organic", "Other"], + [40.0, 30.0, 20.0, 10.0], + width=300, + height=300, + ) + spec, _ = chart.figure().build_payload() + names = [t["name"] for t in spec["traces"]] + assert names == ["Direct (40%)", "Partner (30%)", "Organic (20%)", "Other (10%)"] + + # The choice is made once for the whole pie, not per slice: a legend where + # one row carries a bare value and the next does not is worse than either + # consistent shape. 10.5 does not render as its 10% share, so every row keeps + # its value even though the other three would have collided. + mixed = xy.pie_chart(["a", "b", "c", "d"], [40.0, 30.0, 20.0, 10.5], width=300, height=300) + mixed_spec, _ = mixed.figure().build_payload() + assert [t["name"] for t in mixed_spec["traces"]] == [ + "a 40 (40%)", + "b 30 (30%)", + "c 20 (20%)", + "d 10.5 (10%)", + ] + + # A zero slice draws no wedge and gets no row, so it cannot veto the choice. + zeroed = xy.pie_chart(["a", "b", "c", "d"], [40.0, 30.0, 30.0, 0.0], width=300, height=300) + zero_spec, _ = zeroed.figure().build_payload() + assert [t["name"] for t in zero_spec["traces"]] == ["a (40%)", "b (30%)", "c (30%)"] + + # Either switch alone is untouched: with no share to collide with, the value + # is always shown. + values_only = xy.pie_chart(["a", "b"], [40.0, 60.0], show_percent=False, width=300, height=300) + values_spec, _ = values_only.figure().build_payload() + assert [t["name"] for t in values_spec["traces"]] == ["a 40", "b 60"] + + +def test_pie_chart_user_tooltip_wins() -> None: + chart = xy.pie_chart(["a", "b"], [1.0, 1.0], xy.tooltip(title="custom"), width=300, height=300) + spec, _ = chart.figure().build_payload() + assert spec["tooltip"]["title"] == "custom" + + +@pytest.mark.parametrize( + ("labels", "values", "message"), + [ + (["a"], [1.0, 2.0], "one value per label"), + ([], [], "at least one slice"), + (["a"], [-1.0], "finite and non-negative"), + (["a", "b"], [0.0, 0.0], "positive total"), + ], +) +def test_pie_chart_refusals(labels, values, message) -> None: + with pytest.raises(ValueError, match=message): + xy.pie_chart(labels, values) + + +def test_wind_rose_bands_are_their_own_count_not_the_cumulative_top() -> None: + """`bar` measures its value as a height above `base`, so authoring + `base + counts` stacked each band on its own offset twice: three + observations reached radius 5 and every band above the first was too + thick. The height is the band's count.""" + directions = np.array([0.0, 0.0, 0.0]) + speeds = np.array([1.0, 1.0, 9.0]) + chart = xy.wind_rose(directions, speeds, sectors=4, speed_bins=[2.0, 10.0]) + bars = [c for c in chart.children if getattr(c, "kind", None) == "bar"] + heights = [float(np.asarray(b.y, dtype=float)[0]) for b in bars] + bases = [float(np.asarray(b.props["base"], dtype=float)[0]) for b in bars] + assert heights == [2.0, 1.0] # two slow observations, one fast + assert bases == [0.0, 2.0] + spec, _ = chart.figure().build_payload_split() + assert spec["y_axis"]["range"][1] == pytest.approx(3.0) + + +def test_wind_rose_tooltip_reports_band_count_and_direction() -> None: + """A rose is the one polar composition where the angle IS data (a compass + bearing), so it names the direction row back in and pairs it with the + band's own count rather than the cumulative stack radius.""" + rng = np.random.default_rng(7) + chart = xy.wind_rose(rng.uniform(0, 360, 120), rng.gamma(2.0, 2.0, 120), sectors=8) + spec, _ = chart.figure().build_payload_split() + tip = spec["tooltip"] + assert tip["title"] == "{name}" + assert tip["fields"] == ["x", "y"] + assert tip["labels"]["y"] == "count" + assert "direction" in tip["labels"]["x"] + + +def test_wedge_gap_is_a_constant_width_not_a_constant_angle() -> None: + """A constant angular pad makes the seam `r · dtheta` wide, so it tapers to + nothing at the hole — the spacing visibly narrows toward the centre. The + gap is a length: the angular inset grows as the radius shrinks, so the arc + removed per edge is the same number of px at every radius.""" + from xy._svg import _PolarProjection, polar_wedge_points + + project = _PolarProjection( + {"theta_unit": "degrees"}, {"range": [0.0, 1.0]}, {"x": 0, "y": 0, "w": 400, "h": 400} + ) + plain = polar_wedge_points(project, 0.0, 90.0, 0.25, 1.0, steps=8) + gapped = polar_wedge_points(project, 0.0, 90.0, 0.25, 1.0, steps=8, wedge_gap=12.0) + assert plain and gapped + + def arc_inset(a: list, b: list, index: int) -> float: + """Arc length (px) the gap removed at one sampled boundary point.""" + ax, ay = a[index] + bx, by = b[index] + radius = math.hypot(ax - 200.0, ay - 200.0) + ta = math.atan2(200.0 - ay, ax - 200.0) + tb = math.atan2(200.0 - by, bx - 200.0) + return abs(ta - tb) * radius + + # First sample sits on the outer rim, last on the inner rim: the same 6 px + # (half the gap) must come off each, or the seam tapers. + outer = arc_inset(plain, gapped, 0) + inner = arc_inset(plain, gapped, -1) + assert outer == pytest.approx(6.0, abs=0.25), outer + assert inner == pytest.approx(6.0, abs=0.25), inner + + +def test_pie_chart_ships_true_shares_and_a_pixel_gap() -> None: + """The gap is carved by the renderer, so `width` stays the slice's real + share — which is what makes the hovered share exact.""" + chart = xy.pie_chart(["a", "b", "c", "d"], [40.0, 30.0, 20.0, 10.0], pad=6.0) + spec, _ = chart.figure().build_payload_split() + widths = [t["bar"]["width"] for t in spec["traces"]] + assert widths == pytest.approx([144.0, 108.0, 72.0, 36.0]) + assert sum(widths) == pytest.approx(360.0) + assert all(t["style"]["wedge_gap"] == 6.0 for t in spec["traces"]) + + +@pytest.mark.parametrize( + ("axis_kwargs", "turn"), + [({}, 2.0 * math.pi), ({"unit": "degrees"}, 360.0)], +) +def test_radar_spokes_follow_the_authored_angular_unit(axis_kwargs, turn) -> None: + """Spokes are derived, so they must be generated in the unit the angular + axis declares. Hard-coded radians against an authored degrees axis put + 0..2pi samples inside a 0..360 frame, squeezing the whole radar into the + first 6.28 degrees.""" + chart = xy.radar_chart( + ["a", "b", "c", "d"], xy.area([1.0, 2.0, 3.0, 2.0]), xy.theta_axis(**axis_kwargs) + ) + spec, _ = chart.figure().build_payload_split() + assert spec["x_axis"]["range"][1] == pytest.approx(turn) + mark = next(c for c in chart.children if getattr(c, "kind", None) in ("area", "line")) + # Evenly spaced across the turn, closed back at a full turn. + assert list(mark.x) == pytest.approx([turn * i / 4.0 for i in range(5)]) + + +@pytest.mark.parametrize( + ("label", "build"), + [ + ( + "secondary radial", + lambda: xy.polar_chart( + xy.line([0.0, 1.0, 2.0], [1.0, 2.0, 3.0]), + xy.line([0.0, 1.0, 2.0], [2.0, 4.0, 6.0], y_axis="y2"), + xy.r_axis(id="y2", domain=(0.0, 8.0)), + ), + ), + ( + "secondary angular", + lambda: xy.polar_chart( + xy.line([0.0, 1.0, 2.0], [1.0, 2.0, 3.0]), + xy.line([0.0, 1.0, 2.0], [2.0, 4.0, 6.0], x_axis="x2"), + xy.x_axis(id="x2"), + ), + ), + ], +) +def test_polar_refuses_a_secondary_axis(label, build) -> None: + """A second axis bound and validated like a Cartesian one, then every + renderer read only the primary pair. Overlapping ranges drew the secondary + series *pixel-identical* to the primary — inviting the reader to decode it + against a tick ladder it does not belong to — and a disjoint range culled + it away entirely, while the axis still got a straight Cartesian spine in + the gutter of a disc. A plausible wrong picture is worse than an error.""" + with pytest.raises(ValueError, match="single angular"): + build().figure().build_payload_split() + + +@pytest.mark.parametrize("scale", ["log", "symlog"]) +def test_polar_refuses_a_non_linear_angular_axis(scale) -> None: + """A non-linear angle was accepted, serialized, and then honoured by + exactly one renderer: the client scaled theta before projecting while the + static exporters ignored the scale outright, so one figure pointed the same + datum at opposite sides of the disc depending on where it was drawn.""" + chart = xy.polar_chart(xy.line([1.0, 2.0, 3.0], [1.0, 2.0, 3.0]), xy.theta_axis(type_=scale)) + with pytest.raises(ValueError, match="angular axis"): + chart.figure().build_payload_split() + + # A log *radial* axis stays supported — only the angle must be linear. + xy.polar_chart( + xy.line([1.0, 2.0, 3.0], [1.0, 2.0, 3.0]), xy.r_axis(type_="log") + ).figure().build_payload_split() + + +@pytest.mark.parametrize( + ("sector", "requested", "expected"), + [ + ((300.0, 420.0), [300.0, 330.0, 0.0, 30.0, 60.0], [300.0, 330.0, 0.0, 30.0, 60.0]), + ((-30.0, 30.0), [330.0, 340.0, 350.0, 0.0, 10.0, 20.0, 30.0], None), + # A sector that does not cross the seam must still drop what is outside. + ((0.0, 180.0), [0.0, 45.0, 90.0, 200.0, -10.0], [0.0, 45.0, 90.0]), + ], +) +def test_seam_crossing_sector_keeps_its_explicit_theta_ticks(sector, requested, expected) -> None: + """Tick trimming was linear while mark culling is modular, so a sector + spanning the 0/turn seam threw away every tick authored on the far side of + it: a *data point* at theta = 20 plotted inside sector (-30, 30) while a + *tick* at 20 silently vanished.""" + axis = { + "range": sector, + "sector": sector, + "theta_unit": "degrees", + "kind": "linear", + "tick_values": requested, + "minor_tick_values": [315.0, 15.0, 45.0], + } + ticks, _labelled, _step = axis_ticks(axis, 400.0, True) + assert ticks == pytest.approx(expected if expected is not None else requested) + if sector == (300.0, 420.0): + # Minor ticks trim through the same window, and were dropped too. This + # exercises the trimming helper directly: `minor_tick_values` on a polar + # axis is now refused at payload build (no renderer draws minor rings or + # spokes), so the branch is correct-but-dormant rather than reachable + # from a figure. See spec/design/polar-axes.md §9. + assert minor_axis_ticks(axis) == pytest.approx([315.0, 15.0, 45.0]) + + +def test_cartesian_tick_trimming_is_unchanged_by_the_modular_window() -> None: + """The modular window is angular-only: a Cartesian axis must still reject + values outside its range rather than wrap them into it.""" + axis = {"range": (0.0, 10.0), "kind": "linear", "tick_values": [-1.0, 0.0, 5.0, 10.0, 11.0]} + ticks, _labelled, _step = axis_ticks(axis, 400.0, True) + assert ticks == pytest.approx([0.0, 5.0, 10.0]) + + +def test_polar_refuses_reverse_on_the_angular_axis() -> None: + """`reverse` is the Cartesian flip switch; the angular axis spells the same + idea as `direction`. It rode the wire as `"reverse": true` and every + renderer ignored it, so the axis silently drew unreversed.""" + with pytest.raises(ValueError, match="reverse=True on the angular axis"): + xy.polar_chart( + xy.line([0.0, 1.0], [1.0, 2.0]), xy.theta_axis(reverse=True) + ).figure().build_payload_split() + + # The switch that does work, and the radial flip, stay supported. + xy.polar_chart( + xy.line([0.0, 1.0], [1.0, 2.0]), xy.theta_axis(direction="clockwise") + ).figure().build_payload_split() + xy.polar_chart( + xy.line([0.0, 1.0], [1.0, 2.0]), xy.r_axis(reverse=True) + ).figure().build_payload_split() + + +def test_wind_rose_names_a_fractional_sector_count() -> None: + """A non-integer count reached np.bincount's `minlength` and surfaced as a + raw NumPy TypeError naming neither the parameter nor the mistake.""" + with pytest.raises(ValueError, match="whole number"): + xy.wind_rose([10.0, 20.0], [1.0, 2.0], sectors=8.5) + xy.wind_rose([10.0, 20.0], [1.0, 2.0], sectors=8).figure().build_payload_split() + + +@pytest.mark.parametrize( + ("label", "build"), + [ + ( + "log radial annihilates every row", + lambda: xy.polar_chart( + xy.area([0.0, 90.0, 180.0], [1.0, 2.0, 3.0]), + xy.theta_axis(unit="degrees"), + xy.r_axis(type_="log"), + ), + ), + ( + "all-NaN radar polygon", + lambda: xy.radar_chart(["a", "b", "c"], xy.area([float("nan")] * 3)), + ), + ( + "area entirely outside the sector", + lambda: xy.polar_chart( + xy.area([200.0, 220.0, 240.0], [1.0, 2.0, 3.0]), + xy.theta_axis(unit="degrees", sector=(0.0, 90.0)), + ), + ), + ], +) +def test_fully_culled_polar_area_exports_without_malformed_path(label, build) -> None: + """`_curve_path` returned "" for a fully culled trace and the area join + stitched that into `" L Z"` — malformed path data that also reached the + PDF converter's `_parse_path`. An empty vertex array additionally reached + the native poly-path builder, which rejects a zero-length buffer, so the + export raised instead of drawing nothing.""" + figure = build().figure() + doc = figure.to_svg() + for d in re.findall(r' None: + """A zero-valued category is ordinary in aggregated data and `pie_chart` + accepts it (values are validated finite and non-negative), but a zero span + reached `bar(width=...)` and died as "bar width must be positive" — an + error from a layer below naming neither pie_chart nor the label.""" + doc = xy.pie_chart(["Direct", "Partner", "Organic"], [40.0, 0.0, 20.0]).figure().to_svg() + assert "Direct" in doc and "Organic" in doc + # The empty category draws no wedge rather than a zero-width one. + assert "Partner" not in doc + xy.pie_chart(["a", "b"], [1.0, 0.0]).figure().to_image(format="pdf") + + +@pytest.mark.parametrize( + ("label", "build"), + [ + ("polar_chart", lambda **k: xy.polar_chart(xy.line([0.0, 1.0], [1.0, 2.0]), **k)), + ("pie_chart", lambda **k: xy.pie_chart(["a", "b"], [1.0, 2.0], **k)), + ("radar_chart", lambda **k: xy.radar_chart(["a", "b", "c"], xy.area([1.0, 2.0, 3.0]), **k)), + ("wind_rose", lambda **k: xy.wind_rose([10.0, 20.0], [1.0, 2.0], **k)), + ], +) +def test_polar_helpers_refuse_a_cartesian_coords_override(label, build) -> None: + """`coords` is the only thing making these helpers polar — `Chart.kind` is + inert — so `setdefault` let `coords="cartesian"` return unlabelled rects + with no axes, silently dropping any authored theta/r axis. It also + re-opened every refusal `_validate_coords` adds, since that method returns + early for a non-polar figure.""" + with pytest.raises(ValueError, match="this chart is polar"): + build(coords="cartesian") + build().figure().build_payload_split() + + +def test_polar_refuses_a_time_angular_axis_by_resolved_kind() -> None: + """The declared spelling `theta_axis(type_="time")` was refused while an + *inferred* datetime column shipped: kind="time" pinned to a fixed 0..2pi + range, so consecutive days wrapped the disc billions of times and the + spokes were labelled as radians.""" + days = [datetime(2026, 1, 1, tzinfo=UTC) + timedelta(days=i) for i in range(6)] + values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] + with pytest.raises(ValueError, match="time angular axis"): + xy.polar_chart(xy.line(days, values)).figure().build_payload_split() + + # A time *radial* axis stays supported, and cartesian time is untouched. + xy.polar_chart(xy.line(values, days)).figure().build_payload_split() + xy.line_chart(xy.line(days, values)).figure().build_payload_split() + + +@pytest.mark.parametrize("kind", ["bar", "column", "errorbar"]) +def test_polar_direct_ceiling_covers_every_capped_mark(kind) -> None: + """The gate was narrowed to {line, scatter, area} so heatmap/contour cell + grids could exceed the *point* ceiling, which un-capped bar/column/errorbar + as collateral — and a polar bar is the most expensive mark there is, + 2*(96+1) verts per wedge against a cartesian quad's 4.""" + n = POLAR_DIRECT_CEILING + 1 + theta = np.linspace(0.0, 360.0, n) + values = np.ones(n) + marks = { + "bar": lambda: xy.bar(theta, values), + "column": lambda: xy.column(theta, values), + "errorbar": lambda: xy.errorbar(theta, values, yerr=values * 0.1), + } + with pytest.raises(ValueError, match="polar ceiling"): + xy.polar_chart(marks[kind]()).figure().build_payload_split() + + +def _text_boxes(doc: str) -> list[tuple[float, float, str, float]]: + boxes = [] + for match in re.finditer(r"]*)>([^<]*)", doc): + attrs, text = match.group(1), match.group(2) + x = re.search(r'\bx="([-\d.]+)"', attrs) + y = re.search(r'\by="([-\d.]+)"', attrs) + size = re.search(r'font-size="([\d.]+)"', attrs) + if x and y and text.strip(): + boxes.append( + ( + float(x.group(1)), + float(y.group(1)), + text.strip(), + float(size.group(1)) if size else 11.0, + ) + ) + return boxes + + +def _overlapping_pairs(boxes) -> int: + count = 0 + for i in range(len(boxes)): + for j in range(i + 1, len(boxes)): + x1, y1, t1, f1 = boxes[i] + x2, y2, t2, f2 = boxes[j] + w1, w2 = len(t1) * f1 * 0.55, len(t2) * f2 * 0.55 + if abs(x1 - x2) < (w1 + w2) / 2 and abs(y1 - y2) < (f1 + f2) / 2: + count += 1 + return count + + +@pytest.mark.parametrize( + ("hole", "size"), + [(0.7, 390), (0.6, 700), (0.0, 390)], +) +def test_radial_tick_labels_do_not_overlap(hole, size) -> None: + """Radial labels march along a 22.5-degree spoke, so their usable run is the + annulus width projected onto it — about a fifth of the plot. Sizing the tick + request off the full plot height packed a height's worth of labels into that + fifth, and the polar path skips the collision pass that would thin them. + Not a narrow-viewport effect: the 700px case overlapped worse than 390px.""" + chart = xy.polar_chart( + xy.line([0.0, 90.0, 180.0, 270.0], [10.0, 20.0, 30.0, 40.0]), + xy.theta_axis(unit="degrees"), + xy.r_axis(hole=hole, domain=(0.0, 60.0)), + width=size, + height=size, + ) + boxes = _text_boxes(chart.figure().to_svg()) + assert boxes, "no tick labels emitted" + assert _overlapping_pairs(boxes) == 0 + + +def test_negative_radial_autorange_keeps_its_pad() -> None: + """`min(0.0, lo)` collapsed to `lo` once the data went negative, throwing the + pad away and producing the picture the branch exists to forbid: four + readings within 0.7% of each other resolved to [-100.8, -100.1] and drew as + a full-disc star. Centre origin is only meaningful when zero ends the + range.""" + + def radial_range(values): + spec, _ = ( + xy.polar_chart(xy.line(list(range(len(values))), values)).figure().build_payload_split() + ) + return spec["y_axis"]["range"] + + lo, hi = radial_range([-100.5, -100.2, -100.8, -100.1]) + assert lo < -100.8 and hi > -100.1, (lo, hi) + + # Non-negative data keeps the centre-origin contract exactly as before. + assert radial_range([100.5, 100.2, 100.8, 100.1]) == [0.0, 100.8] + assert radial_range([1.0, 2.0, 3.0, 4.0]) == [0.0, 4.0] + + +def test_get_theta_offset_matches_matplotlibs_zero_to_two_pi_mapping() -> None: + """Matplotlib's mapping is 0..2pi ccw from east, so "S" reads 3*pi/2. The + getter returned the render tables' -pi/2 — the same angle, but a compat + getter has to return matplotlib's number, and the negative breaks both + `get_theta_offset() > 0` and a round-trip through `set_theta_offset`.""" + from xy import pyplot as plt + + expected = { + "E": 0.0, + "NE": math.pi / 4, + "N": math.pi / 2, + "NW": 3 * math.pi / 4, + "W": math.pi, + "SW": 5 * math.pi / 4, + "S": 3 * math.pi / 2, + "SE": 7 * math.pi / 4, + } + ax = plt.figure().add_subplot(projection="polar") + for location, radians in expected.items(): + ax.set_theta_zero_location(location) + assert ax.get_theta_offset() == pytest.approx(radians), location diff --git a/tests/test_polar_client_regressions.py b/tests/test_polar_client_regressions.py new file mode 100644 index 00000000..294b5dcf --- /dev/null +++ b/tests/test_polar_client_regressions.py @@ -0,0 +1,747 @@ +"""Focused browser/client regressions for polar interaction and clipping.""" + +from __future__ import annotations + +import math +import re +from pathlib import Path + +import pytest + +import xy +from conftest import probe_document, run_browser_probe +from xy.export import find_chromium + +ROOT = Path(__file__).resolve().parents[1] + + +def test_polar_client_routes_coupled_coordinates_and_disables_pan() -> None: + chartview = (ROOT / "js/src/50_chartview.ts").read_text() + annotations = (ROOT / "js/src/51_annotations.ts").read_text() + tooltip = (ROOT / "js/src/52_tooltip.ts").read_text() + interaction = (ROOT / "js/src/53_interaction.ts").read_text() + + assert '&& this._axisPolicy("pan_axes").length > 0;' in chartview + assert 'const panAxes = this._axisPolicy("pan_axes");' in interaction + assert "&& canPan && panAxes.length > 0" in interaction + assert interaction.count('&& this._axisPolicy("pan_axes").length > 0;') >= 1 + + assert "this._projectDataPoint(a.xAxis, a.yAxis, a.x, a.y)" in tooltip + assert "this._projectDataPoint(g.xAxis, g.yAxis, x, y, polarGeom)" in annotations + assert "[targetX, targetY] = project(ann.x1, ann.y1);" in annotations + assert "[px, py] = project(ann.x, ann.y);" in annotations + + +def test_every_polar_gl_mark_fragment_path_uses_annular_sector_clip() -> None: + source = (ROOT / "js/src/40_gl.ts").read_text() + for name in ( + "POINT_FS", + "POINT_SIMPLE_FS", + "PICK_FS", + "LINE_FS", + "SEGMENT_FS", + "AREA_FS", + "RECT_FS", + ): + block = source.split(f"export const {name} =", 1)[1].split("`;", 1)[0] + assert "${POLAR_FRAGMENT_CLIP_GLSL}" in block, name + assert "xyClipPolarFragment();" in block, name + + annotations = (ROOT / "js/src/51_annotations.ts").read_text() + assert "function xyClipPolarCanvas(ctx, geom)" in annotations + assert "if (polarGeom) xyClipPolarCanvas(ctx, polarGeom);" in annotations + + +def test_polar_fill_shaders_order_reversed_radial_clamp_bounds() -> None: + source = (ROOT / "js/src/40_gl.ts").read_text() + for name in ("AREA_VS", "RECT_VS", "BAR_VS"): + block = source.split(f"export const {name} =", 1)[1].split("`;", 1)[0] + assert "float rmin = min(u_rrange.x, u_rrange.y);" in block, name + assert "float rmax = max(u_rrange.x, u_rrange.y);" in block, name + assert not re.search( + r"clamp\([^;]*u_rrange\.x,\s*u_rrange\.y", + block, + ), name + + +def test_reversed_polar_radial_axis_keeps_bar_and_area_visible(tmp_path: Path) -> None: + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + + chart = xy.polar_chart( + xy.bar( + [0.0], + [8.0], + base=2.0, + width=0.6, + color="#ff0000", + opacity=1.0, + animation=False, + ), + xy.area( + [math.pi / 2.0, math.pi, 3.0 * math.pi / 2.0], + [8.0, 6.0, 8.0], + base=2.0, + color="#0000ff", + opacity=1.0, + line_width=0.0, + animation=False, + ), + xy.r_axis(domain=(1.0, 10.0), reverse=True), + width=420, + height=420, + ) + probe = """ + +""" + result = run_browser_probe( + chromium, + probe_document(chart, probe), + tmp_path / "polar_reversed_r.html", + "data-xy-polar-reversed-r", + label="polar reversed radial probe", + ) + + assert result["glError"] == 0 + assert result["range"] == pytest.approx([10.0, 1.0]) + assert all(math.isfinite(value) for value in result["barPoint"]) + assert all(math.isfinite(value) for value in result["areaPoint"]) + assert result["red"] > 100 + assert result["blue"] > 100 + + +def test_polar_keyboard_traversal_does_not_hover_culled_point(tmp_path: Path) -> None: + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + + chart = xy.polar_chart( + # Source order is deliberate: the first point lies outside the sector, + # while the second is visible and should receive the next key traversal. + xy.scatter([math.pi, math.pi / 4.0], [0.5, 0.5], size=12.0), + xy.theta_axis(sector=(0.0, math.pi / 2.0)), + xy.r_axis(domain=(0.0, 1.0)), + xy.interaction_config(hover=True), + width=420, + height=320, + ) + probe = """ + +""" + result = run_browser_probe( + chromium, + probe_document(chart, probe), + tmp_path / "polar_keyboard_cull.html", + "data-xy-polar-keyboard-cull", + label="polar keyboard cull probe", + ) + + culled = result["afterCulled"] + assert culled["index"] == 0 + assert culled["hoverId"] == -1 + assert culled["target"] is None + assert culled["display"] != "block" + assert "NaN" not in culled["left"] + assert "NaN" not in culled["top"] + assert culled["hoverEvents"] == 0 + + visible = result["afterVisible"] + assert visible["index"] == 1 + assert visible["targetIndex"] == 1 + assert visible["display"] == "block" + assert visible["left"].endswith("px") and "NaN" not in visible["left"] + assert visible["top"].endswith("px") and "NaN" not in visible["top"] + assert visible["hoverEvents"] == 1 + + +def test_polar_gl_chords_are_clipped_to_hole_and_sector(tmp_path: Path) -> None: + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + + chart = xy.polar_chart( + # This diameter crosses the hole. + xy.line([0.0, math.pi], [1.0, 1.0], color="#ff0000", width=14), + # This boundary-to-boundary chord crosses the excluded 270°..360° + # wedge of the partial sector. + xy.line( + [0.0, 1.5 * math.pi], + [1.0, 1.0], + color="#0000ff", + width=14, + ), + xy.scatter( + [1.0], + [0.7], + color="#ff0000", + size=32, + opacity=1.0, + density=False, + _marker_glyph="●", + ), + xy.text(1.0, 0.7, "joint", dx=0, dy=0), + xy.theta_axis(sector=(0.0, 1.5 * math.pi)), + xy.r_axis(domain=(0.0, 1.0), hole=0.35), + width=420, + height=420, + ) + probe = """ + +""" + result = run_browser_probe( + chromium, + probe_document(chart, probe), + tmp_path / "polar_fragment_clip.html", + "data-xy-polar-clip-probe", + label="polar fragment clip probe", + ) + + assert result["glError"] == 0 + assert result["hole"][3] == 0 + assert result["missingSector"][3] == 0 + assert result["visibleChord"][3] > 0 + assert result["tooltipAnchor"]["lx"] == pytest.approx(result["projected"][0]) + assert result["tooltipAnchor"]["ly"] == pytest.approx(result["projected"][1]) + assert result["annotationAnchor"] == pytest.approx(result["projected"]) + assert result["authoredMarkerRedPixels"] > 5 + assert result["initialDragMode"] != "pan" + assert result["panButton"] is False + assert result["afterZoom"] != pytest.approx(result["beforeZoom"]) + assert result["afterDrag"] == pytest.approx(result["afterZoom"]) + + +def test_polar_wheel_zoom_is_alive_and_keeps_r_lo_fixed(tmp_path: Path) -> None: + """Real wheel events must zoom the radial axis with r_lo pinned (§8). + + Two regressions in one probe. First, the wheel gate `dragMode === "none"` + conflated the user's modebar opt-out with the RESOLVED default — polar + disables pan/box/select, so its default drag tool is `none`, and the gate + made radial wheel zoom (polar's only navigation) dead on arrival. Second, + the assertion is on the §8 contract itself — r_lo fixed, r_hi scaled — + not just `after != before`. + """ + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + + theta = [i * 2.0 * math.pi / 40.0 for i in range(40)] + r = [1.0 + 0.4 * math.sin(3.0 * t) for t in theta] + chart = xy.polar_chart(xy.line(theta, r, animation=False), width=420, height=420) + probe = """ + +""" + result = run_browser_probe( + chromium, + probe_document(chart, probe), + tmp_path / "polar_wheel.html", + "data-xy-polar-wheel", + label="polar wheel zoom", + ) + before, after = result["before"], result["after"] + assert before[0] == 0.0 + assert after[0] == 0.0, f"radial zoom moved r_lo: {after}" + assert after[1] < before[1] * 0.9, f"wheel did not zoom: {before} -> {after}" + + +def test_polar_bar_hover_wraps_across_the_seam(tmp_path: Path) -> None: + """A wedge straddling theta = 0/turn (a wind-rose "N" sector) must be + hoverable on BOTH sides of the seam. `_barHover` compared |dataX - centre| + in unwrapped data space, so dataX = 355 missed a 30-degree bar centred on + 0 (|355 - 0| = 355), which drew correctly and was silently un-hoverable + on its wrap side — the exact miss spec §3.2 calls out.""" + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + + chart = xy.polar_chart( + xy.bar([0.0, 90.0, 180.0], [3.0, 2.0, 1.0], width=30.0, animation=False), + xy.theta_axis(unit="degrees", zero="N", direction="clockwise"), + width=420, + height=420, + ) + probe = """ + +""" + result = run_browser_probe( + chromium, + probe_document(chart, probe), + tmp_path / "polar_seam_hover.html", + "data-xy-polar-seam-hover", + label="polar seam hover", + ) + assert result == {"direct": True, "wrapped": True, "outside": False} + + +def test_polar_tooltip_content_speaks_polar(tmp_path: Path) -> None: + """Tooltip *content* on polar charts — the review's coverage gap. + + The default readout shows VALUES, not angles: the angle is where layout put + the mark and the cursor is already on it, so a numeric theta row is omitted + and only the series name plus the radial value appear. An authored spoke + label is a name rather than an angle and still shows (covered below), and + naming the row via `labels={"x": ...}` opts it back in. + """ + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + + theta = [i * 2.0 * math.pi / 24.0 for i in range(24)] + r = [1.0 + 0.4 * math.sin(3.0 * t) for t in theta] + chart = xy.polar_chart(xy.line(theta, r, name="gain", animation=False), width=420, height=400) + probe = """ + +""" + items = run_browser_probe( + chromium, + probe_document(chart, probe), + tmp_path / "polar_tooltip_line.html", + "data-xy-polar-tooltip", + label="polar tooltip content", + ) + assert items[0] == {"kind": "title", "value": "gain"} + assert not any(i.get("label") == "θ" for i in items), ( + f"a numeric angle is layout, not data, and must not show by default: {items}" + ) + assert any(i.get("label") == "r" for i in items), items + + +def test_polar_tooltip_angle_row_is_opt_in(tmp_path: Path) -> None: + """Naming the row brings the angle back, formatted through the axis's own + text function — so a radians chart reads "π/2", not "1.5708".""" + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + + theta = [i * 2.0 * math.pi / 24.0 for i in range(24)] + r = [1.0 + 0.4 * math.sin(3.0 * t) for t in theta] + chart = xy.polar_chart( + xy.line(theta, r, name="gain", animation=False), + xy.tooltip(labels={"x": "bearing"}), + width=420, + height=400, + ) + probe = """ + +""" + items = run_browser_probe( + chromium, + probe_document(chart, probe), + tmp_path / "polar_tooltip_optin.html", + "data-xy-opt-in", + label="polar tooltip opt-in angle", + ) + angle = next(i for i in items if i.get("label") == "bearing") + assert angle["value"] == "π/2", items + + +def test_polar_tooltip_degrees_and_radar_labels(tmp_path: Path) -> None: + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + + chart = xy.radar_chart( + ["speed", "power", "range", "agility"], + xy.area([0.9, 0.7, 0.5, 0.8], name="Model A"), + width=420, + height=400, + ) + probe = """ + +""" + items = run_browser_probe( + chromium, + probe_document(chart, probe), + tmp_path / "polar_tooltip_radar.html", + "data-xy-radar-tooltip", + label="radar tooltip content", + ) + assert items[0] == {"kind": "title", "value": "Model A"} + theta_item = next(i for i in items if i.get("label") == "θ") + assert theta_item["value"] == "power", items + + +def test_client_keeps_explicit_theta_ticks_across_the_seam(tmp_path: Path) -> None: + """The client mirror of the seam-crossing tick window (_svg.py + `_tick_window_filter`). `_axisTicks` trimmed authored angular ticks with a + linear `v >= a && v <= b`, so a sector spanning 0/turn silently dropped + every spoke on the far side of the seam while marks at those same angles + still drew — the renderers must agree on what a sector contains.""" + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + + chart = xy.polar_chart( + xy.line([310.0, 350.0, 30.0, 50.0], [1.0, 2.0, 3.0, 2.0]), + xy.theta_axis( + unit="degrees", + sector=(300.0, 420.0), + tick_values=[300.0, 330.0, 0.0, 30.0, 60.0], + ), + width=420, + height=420, + ) + probe = """ + +""" + result = run_browser_probe( + chromium, + probe_document(chart, probe), + tmp_path / "polar_seam_ticks.html", + "data-xy-seam-ticks", + label="polar seam ticks", + ) + assert result["seam"] == [300.0, 330.0, 0.0, 30.0, 60.0] + assert result["bounded"] == [0.0, 45.0, 90.0] + assert result["radialCount"] > 0 + + +def test_polar_rect_hover_span_matches_the_drawn_wedge(tmp_path: Path) -> None: + """`_rectHover`'s polar containment measured a *directional* span, + `mod(x1 - x0, turn)`, while anchoring the offset at `min(x0, x1)`. Both + renderers draw the band as the direct unwrapped interval between the edges + (GLSL `abs(a1 - a0)`; `_PolarProjection.wedge_angles` `min..max`), so edge + order carries no meaning — and the mismatch made a descending pair + (350, 300) report a 310-wide wedge covering 300..610 instead of 300..350, + and a 350/10 pair hoverable only outside itself.""" + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + + chart = xy.polar_chart( + xy.bar([30.0], [1.0], width=10.0), + xy.theta_axis(unit="degrees"), + width=420, + height=420, + ) + probe = """ + +""" + result = run_browser_probe( + chromium, + probe_document(chart, probe), + tmp_path / "polar_rect_span.html", + "data-xy-rect-span", + label="polar rect hover span", + ) + assert result["descIn"] and not result["descOut"], result + assert result["ascIn"] and not result["ascOut"], result + assert result["wideIn"] and not result["wideOut"], result + assert result["seamIn"] and result["seamAlsoIn"] and not result["seamOut"], result diff --git a/tests/test_polar_phase7_api.py b/tests/test_polar_phase7_api.py new file mode 100644 index 00000000..89995abc --- /dev/null +++ b/tests/test_polar_phase7_api.py @@ -0,0 +1,261 @@ +"""Phase-7 polar API and wire contracts (geometry is tested by renderer suites).""" + +from __future__ import annotations + +import math +from pathlib import Path + +import numpy as np +import pytest + +import xy +import xy.pyplot as plt +from xy.config import POLAR_MARK_KINDS, PROTOCOL_VERSION + + +@pytest.fixture(autouse=True) +def _close_pyplot_figures(): + yield + plt.close("all") + + +def _line() -> xy.Mark: + return xy.line([0.0, math.pi / 2.0, math.pi], [1.0, 2.0, 3.0]) + + +def _spec(*children: xy.Component) -> dict: + spec, _buffers = xy.polar_chart(*children).figure().build_payload_split() + return spec + + +def test_protocol_v12_is_locked_to_the_client() -> None: + header = Path(__file__).parents[1] / "js" / "src" / "00_header.ts" + assert PROTOCOL_VERSION == 12 + assert f"PROTOCOL = {PROTOCOL_VERSION};" in header.read_text() + + +def test_phase7_marks_are_legal_polar_primitives() -> None: + assert { + "line", + "scatter", + "area", + "bar", + "column", + "heatmap", + "contour", + "errorbar", + } == POLAR_MARK_KINDS + + +def test_theta_sector_and_grid_shape_reach_the_wire() -> None: + spec = _spec( + _line(), + xy.theta_axis(sector=(0.0, math.pi), grid_shape="linear"), + ) + assert spec["x_axis"]["sector"] == pytest.approx([0.0, math.pi]) + assert spec["x_axis"]["grid_shape"] == "linear" + # Numeric theta keeps its independent full-turn data/tick range. + assert spec["x_axis"]["range"] == pytest.approx([0.0, 2.0 * math.pi]) + + +def test_theta_domain_is_a_sector_alias_not_the_data_range() -> None: + spec = _spec( + xy.line([0.0, 90.0, 180.0], [1.0, 2.0, 3.0]), + xy.theta_axis(unit="degrees", domain=(30.0, 150.0)), + ) + assert spec["x_axis"]["sector"] == pytest.approx([30.0, 150.0]) + assert spec["x_axis"]["range"] == pytest.approx([0.0, 360.0]) + assert "domain" not in spec["x_axis"] + + +def test_polar_defaults_are_resolved_on_the_wire() -> None: + spec = _spec(_line()) + assert spec["x_axis"]["sector"] == pytest.approx([0.0, 2.0 * math.pi]) + assert spec["x_axis"]["grid_shape"] == "circular" + assert spec["y_axis"]["hole"] == 0.0 + assert "r_origin" not in spec["y_axis"] + + +@pytest.mark.parametrize( + ("axis", "match"), + [ + (lambda: xy.theta_axis(grid_shape="polygon"), "grid_shape"), + (lambda: xy.r_axis(hole=-0.01), "at least 0"), + (lambda: xy.r_axis(hole=1.0), "less than 1"), + (lambda: xy.r_axis(hole=0.2, origin=-1.0), "mutually exclusive"), + ], +) +def test_polar_axis_options_validate_eagerly(axis, match: str) -> None: + with pytest.raises(ValueError, match=match): + axis() + + +@pytest.mark.parametrize( + ("unit", "sector"), + [ + ("radians", (0.0, 2.0 * math.pi + 0.01)), + ("degrees", (0.0, 360.01)), + ], +) +def test_sector_must_not_exceed_one_turn(unit: str, sector: tuple[float, float]) -> None: + chart = xy.polar_chart(_line(), xy.theta_axis(unit=unit, sector=sector)) + with pytest.raises(ValueError, match="one full turn"): + chart.figure() + + +def test_categorical_theta_keeps_category_index_range() -> None: + spec = _spec( + xy.scatter(["north", "east", "south"], [1.0, 2.0, 3.0]), + xy.theta_axis(sector=(0.0, math.pi)), + ) + assert spec["x_axis"]["kind"] == "category" + assert spec["x_axis"]["categories"] == ["north", "east", "south"] + assert spec["x_axis"]["range"] == pytest.approx([0.0, 2.0]) + assert spec["x_axis"]["sector"] == pytest.approx([0.0, math.pi]) + + +def test_radial_hole_and_origin_reach_the_wire() -> None: + hole = _spec(_line(), xy.r_axis(domain=(0.0, 4.0), hole=0.25)) + assert hole["y_axis"]["hole"] == pytest.approx(0.25) + assert "r_origin" not in hole["y_axis"] + + origin = _spec(_line(), xy.r_axis(domain=(0.0, 4.0), origin=-1.0)) + assert origin["y_axis"]["hole"] == 0.0 + assert origin["y_axis"]["r_origin"] == pytest.approx(-1.0) + + +@pytest.mark.parametrize( + ("origin", "match"), + [ + (1.5, "must not exceed the resolved radial minimum"), + (4.0, "less than the resolved radial maximum"), + ], +) +def test_radial_origin_must_not_invert_visible_radius(origin: float, match: str) -> None: + chart = xy.polar_chart(_line(), xy.r_axis(domain=(1.0, 4.0), origin=origin)) + with pytest.raises(ValueError, match=match): + chart.figure().build_payload_split() + + +def test_reversed_radial_origin_extends_from_the_center_side_limit() -> None: + spec = _spec( + _line(), + xy.r_axis(domain=(1.0, 4.0), reverse=True, origin=5.0), + ) + assert spec["y_axis"]["range"] == pytest.approx([4.0, 1.0]) + assert spec["y_axis"]["r_origin"] == pytest.approx(5.0) + + inside = xy.polar_chart( + _line(), + xy.r_axis(domain=(1.0, 4.0), reverse=True, origin=3.0), + ) + with pytest.raises(ValueError, match="must not be less than the resolved radial maximum"): + inside.figure().build_payload_split() + + beyond_outer = xy.polar_chart( + _line(), + xy.r_axis(domain=(1.0, 4.0), reverse=True, origin=1.0), + ) + with pytest.raises(ValueError, match="must be greater than the resolved radial minimum"): + beyond_outer.figure().build_payload_split() + + +def test_log_radial_origin_must_be_positive() -> None: + chart = xy.polar_chart(_line(), xy.r_axis(type_="log", origin=0.0)) + with pytest.raises(ValueError, match="must be positive"): + chart.figure() + + +def test_log_radial_autorange_never_reintroduces_zero() -> None: + spec = _spec( + xy.line([0.0, math.pi / 2.0, math.pi], [1.0, 10.0, 100.0]), + xy.r_axis(type_="log"), + ) + assert spec["y_axis"]["scale"] == "log" + assert spec["y_axis"]["range"] == pytest.approx([1.0, 100.0]) + assert spec["y_axis"]["range"][0] > 0.0 + + +def test_large_polar_heatmap_is_not_subject_to_point_trace_ceiling() -> None: + # 451² cells exceed POLAR_DIRECT_CEILING while remaining a compact grid + # payload; the ceiling protects point primitives, not raster cells. + grid = np.zeros((451, 451), dtype=np.float64) + spec = _spec(xy.heatmap(grid)) + assert spec["traces"][0]["kind"] == "heatmap" + assert spec["traces"][0]["n_marks"] == grid.size + + +@pytest.mark.parametrize("annotation", [xy.hline(1.5), xy.x_band(0.2, 0.8)]) +def test_polar_rule_and_band_annotations_fail_loudly(annotation: xy.Annotation) -> None: + chart = xy.polar_chart(_line(), annotation) + with pytest.raises(ValueError, match="does not support rule/band annotations"): + chart.figure().build_payload_split() + + +def test_pyplot_sector_and_rorigin_route_to_core_wire() -> None: + fig, ax = plt.subplots(subplot_kw={"projection": "polar"}) + ax.plot([0.0, math.pi / 2.0, math.pi], [0.0, 1.0, 2.0]) + ax.set_thetamin(30.0) + ax.set_thetamax(270.0) + ax.set_rlim(0.0, 3.0) + ax.set_rorigin(-1.0) + + assert ax.get_thetamin() == pytest.approx(30.0) + assert ax.get_thetamax() == pytest.approx(270.0) + assert ax.get_rorigin() == pytest.approx(-1.0) + + spec, _buffers = fig._charts()[0].figure().build_payload_split() + assert spec["x_axis"]["sector"] == pytest.approx([math.radians(30.0), math.radians(270.0)]) + assert spec["y_axis"]["r_origin"] == pytest.approx(-1.0) + + +def test_pyplot_theta_limits_share_xlim_state_and_last_call_wins() -> None: + fig, ax = plt.subplots(subplot_kw={"projection": "polar"}) + ax.plot([0.0, math.pi / 2.0, math.pi], [1.0, 2.0, 3.0]) + + assert ax.get_xlim() == pytest.approx((0.0, 2.0 * math.pi)) + assert (ax.get_thetamin(), ax.get_thetamax()) == pytest.approx((0.0, 360.0)) + ax.set_thetagrids([0.0, 90.0, 180.0, 270.0]) + assert ax.get_xlim() == pytest.approx((0.0, 2.0 * math.pi)) + spec, _buffers = fig._charts()[0].figure().build_payload_split() + assert spec["x_axis"]["sector"] == pytest.approx([0.0, 2.0 * math.pi]) + + ax.set_thetamin(30.0) + ax.set_thetamax(120.0) + assert ax.get_xlim() == pytest.approx((math.radians(30.0), math.radians(120.0))) + spec, _buffers = fig._charts()[0].figure().build_payload_split() + assert spec["x_axis"]["sector"] == pytest.approx(ax.get_xlim()) + + # set_xlim is the same polar view state and, as the later call, wins. + ax.set_xlim(0.0, math.pi) + assert (ax.get_thetamin(), ax.get_thetamax()) == pytest.approx((0.0, 180.0)) + spec, _buffers = fig._charts()[0].figure().build_payload_split() + assert spec["x_axis"]["sector"] == pytest.approx([0.0, math.pi]) + + # The degree-spelled setter likewise becomes the latest x-domain edit. + ax.set_thetamin(45.0) + assert ax.get_xlim() == pytest.approx((math.radians(45.0), math.pi)) + spec, _buffers = fig._charts()[0].figure().build_payload_split() + assert spec["x_axis"]["sector"] == pytest.approx(ax.get_xlim()) + + +@pytest.mark.parametrize("method", ["clear", "cla"]) +def test_pyplot_clear_resets_polar_theta_and_radial_options(method: str) -> None: + fig, ax = plt.subplots(subplot_kw={"projection": "polar"}) + ax.plot([0.0, math.pi], [1.0, 2.0]) + ax.set_theta_zero_location("N") + ax.set_theta_direction(-1) + ax.set_thetamin(30.0) + ax.set_thetamax(120.0) + ax.set_rorigin(-1.0) + + getattr(ax, method)() + ax.plot([0.0, math.pi], [1.0, 2.0]) + + assert ax.get_xlim() == pytest.approx((0.0, 2.0 * math.pi)) + assert (ax.get_thetamin(), ax.get_thetamax()) == pytest.approx((0.0, 360.0)) + spec, _buffers = fig._charts()[0].figure().build_payload_split() + assert spec["x_axis"]["sector"] == pytest.approx([0.0, 2.0 * math.pi]) + assert spec["x_axis"]["theta_zero"] == "E" + assert spec["x_axis"]["theta_direction"] == "counterclockwise" + assert "r_origin" not in spec["y_axis"] diff --git a/tests/test_polar_phase7_static.py b/tests/test_polar_phase7_static.py new file mode 100644 index 00000000..b3d50435 --- /dev/null +++ b/tests/test_polar_phase7_static.py @@ -0,0 +1,500 @@ +"""Focused static-export coverage for phase-7 polar geometry.""" + +from __future__ import annotations + +import math +import re + +import numpy as np +import pytest + +import xy +from xy import _raster, _svg +from xy._svg import ( + _annotation_connector_unclipped, + _PolarProjection, + _Scale, + _segment_marks, + _tick_text, + axis_ticks, + layout, + polar_heatmap_rgba, +) + +PLOT = {"x": 0.0, "y": 0.0, "w": 200.0, "h": 200.0} + + +def test_log_radius_and_hole_are_mapped_in_scale_coordinates() -> None: + polar = _PolarProjection( + {}, + {"range": [1.0, 100.0], "scale": "log", "hole": 0.2}, + PLOT, + ) + assert float(polar.norm_radius(1.0)) == pytest.approx(0.2) + assert float(polar.norm_radius(10.0)) == pytest.approx(0.6) + assert float(polar.norm_radius(100.0)) == pytest.approx(1.0) + assert float(polar.radius_value(0.6)) == pytest.approx(10.0) + + +def test_radial_origin_below_the_view_creates_an_annulus() -> None: + polar = _PolarProjection( + {}, + {"range": [2.0, 10.0], "r_origin": 0.0}, + PLOT, + ) + assert polar.inner_fraction == pytest.approx(0.2) + assert float(polar.norm_radius(10.0)) == pytest.approx(1.0) + assert not bool(polar.visible_mask(1.99)) + assert bool(polar.visible_mask(2.0)) + + +def test_reversed_radial_origin_maps_static_geometry_inward_to_outward() -> None: + polar = _PolarProjection( + {}, + {"range": [10.0, 2.0], "r_origin": 12.0}, + PLOT, + ) + assert polar.inner_fraction == pytest.approx(0.2) + assert np.asarray(polar.norm_radius([10.0, 6.0, 2.0])).tolist() == pytest.approx( + [0.2, 0.6, 1.0] + ) + assert float(polar.radius_value(0.6)) == pytest.approx(6.0) + + figure = xy.polar_chart( + xy.area( + [0.0, math.pi / 2.0, math.pi], + [8.0, 6.0, 4.0], + base=[10.0, 10.0, 10.0], + color="#ff0000", + opacity=1.0, + line_width=0.0, + ), + xy.r_axis(domain=(2.0, 10.0), reverse=True, origin=12.0), + width=240, + height=220, + ).figure() + svg = figure.to_image(format="svg") + assert b'fill="#ff0000"' in svg + spec, blob, borrowed = figure._build_raster_payload() + pixels = _raster.render_raster(spec, blob, scale=1.0, borrowed=borrowed) + red = (pixels[:, :, 0] > 200) & (pixels[:, :, 1] < 80) & (pixels[:, :, 2] < 80) + assert int(red.sum()) > 100 + + +def test_near_one_hole_is_an_empty_native_clip_not_a_rejected_stream() -> None: + figure = xy.polar_chart( + xy.scatter([0.0], [1.0], color="#ff0000"), + xy.r_axis(domain=(0.0, 1.0), hole=1.0 - 1e-12), + width=200, + height=200, + ).figure() + assert figure.to_image(format="svg").startswith(b" None: + categories = ["N", "E", "S", "W"] + full = _PolarProjection( + {"kind": "category", "categories": categories}, + {"range": [0.0, 1.0]}, + PLOT, + ) + assert np.asarray(full.angle([0, 1, 2, 3])).tolist() == pytest.approx( + [0.0, math.pi / 2.0, math.pi, 3.0 * math.pi / 2.0] + ) + + partial = _PolarProjection( + { + "kind": "category", + "categories": categories, + "sector": [0.0, math.pi], + }, + {"range": [0.0, 1.0]}, + PLOT, + ) + assert np.asarray(partial.angle([0, 1, 2, 3])).tolist() == pytest.approx( + [0.0, math.pi / 3.0, 2.0 * math.pi / 3.0, math.pi] + ) + + +def test_partial_sector_uses_its_bounding_box_and_sector_tick_domain() -> None: + polar = _PolarProjection( + {"sector": [0.0, math.pi]}, + {"range": [0.0, 1.0]}, + {"x": 0.0, "y": 0.0, "w": 200.0, "h": 100.0}, + ) + # A semicircle is 2R by R, so it fills this 2:1 rectangle at R=100. + assert polar.radius == pytest.approx(100.0, abs=1e-6) + + axis = { + "kind": "linear", + "range": [0.0, 2.0 * math.pi], + "sector": [math.pi / 2.0, math.pi], + "theta_unit": "radians", + "tick_values": [0.0, math.pi / 2.0, math.pi, 3.0 * math.pi / 2.0], + } + ticks, labels, _step = axis_ticks(axis, 200.0, True) + assert ticks == labels == pytest.approx([math.pi / 2.0, math.pi]) + + +def test_category_labels_take_precedence_over_angular_formatting() -> None: + categories = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] + axis = { + "kind": "category", + "range": [0.0, 7.0], + "categories": categories, + "theta_unit": "radians", + "sector": [0.0, 2.0 * math.pi], + } + # A 300 px Cartesian category axis would normally thin eight labels to + # four. Categorical theta keeps all eight because each tick is also one + # spoke and one polygon-grid vertex. + ticks, labels, step = axis_ticks(axis, 300.0, True) + assert ticks == labels == list(np.arange(8.0)) + assert [_tick_text(axis, value, step) for value in labels] == categories + + +def test_svg_uses_annular_sector_clip_polygon_grid_and_polar_heatmap() -> None: + chart = xy.polar_chart( + xy.heatmap( + np.arange(12.0).reshape(3, 4), + x=np.linspace(0.0, math.pi, 4), + y=[1.0, 2.0, 3.0], + ), + xy.theta_axis(sector=(0.0, math.pi), grid_shape="linear"), + xy.r_axis(domain=(0.0, 3.0), hole=0.25), + width=360, + height=280, + ) + document = chart.figure().to_image(format="svg").decode() + assert 'data-xy-polar-heatmap="true"' in document + assert re.search(r"]*> None: + values = np.asarray([0.0, 0.33, 0.66, 1.0], dtype=np.float32) + hm = { + "w": 2, + "h": 2, + "buf": 0, + "x_range": [0.0, math.pi], + "y_range": [0.0, 2.0], + "colormap": "viridis", + } + cols = [{"dtype": "f32", "len": 4, "byte_offset": 0, "scale": 1.0, "offset": 0.0}] + polar = _PolarProjection( + {"sector": [0.0, math.pi]}, + {"range": [0.0, 2.0], "hole": 0.4}, + PLOT, + ) + rgba = polar_heatmap_rgba(hm, values.tobytes(), cols, {}, polar) + cx = int(round(polar.cx - polar.plot["x"])) + cy = min(rgba.shape[0] - 1, int(round(polar.cy - polar.plot["y"]))) + assert rgba[cy, cx, 3] == 0 # display-space hole + px, py = polar(math.pi / 2.0, 1.0) + ix = min(rgba.shape[1] - 1, max(0, int(float(px) - polar.plot["x"]))) + iy = min(rgba.shape[0] - 1, max(0, int(float(py) - polar.plot["y"]))) + assert rgba[iy, ix, 3] > 0 + # The lower half of the plot is outside the authored 0..pi sector. + assert rgba[-1, rgba.shape[1] // 2, 3] == 0 + + +def test_polar_heatmap_samples_canonical_source_and_native_device_scale(monkeypatch) -> None: + source_w, source_h = 400, 300 + values = np.linspace(-2.0, 6.0, source_w * source_h, dtype=np.float64) + values[17] = np.nan + hm = { + "w": source_w, + "h": source_h, + "buf": 0, + "enc": "canonical-f64", + "domain": [-1.0, 5.0], + "x_range": [0.0, 2.0 * math.pi], + "y_range": [0.0, 1.0], + "colormap": "viridis", + } + canonical_cols = [{"span": 1, "len": values.size, "dtype": "f64"}] + polar = _PolarProjection( + {}, + {"range": [0.0, 1.0]}, + {"x": 0.0, "y": 0.0, "w": 24.0, "h": 18.0}, + ) + sample_sizes: list[int] = [] + original_sample = _svg._heatmap_sample_column + + def record_sample(meta, indices, blob, borrowed): + sample_sizes.append(len(indices)) + return original_sample(meta, indices, blob, borrowed) + + monkeypatch.setattr(_svg, "_heatmap_sample_column", record_sample) + monkeypatch.setattr( + _svg, + "_heatmap_rgba_grid", + lambda *_args, **_kwargs: pytest.fail("polar heatmap expanded the full source grid"), + ) + canonical = polar_heatmap_rgba( + hm, + b"", + canonical_cols, + {}, + polar, + (values,), + output_scale=2.0, + ) + assert canonical.shape == (36, 48, 4) + assert sample_sizes and max(sample_sizes) <= 36 * 48 + assert max(sample_sizes) < values.size + + normalized = np.clip((values - hm["domain"][0]) / 6.0, 0.0, 1.0).astype(" None: + source_w, source_h = 300, 200 + size = source_w * source_h + channels = tuple(np.full(size, value, dtype=np.float32) for value in (0.25, 0.5, 0.75, 1.0)) + hm = { + "w": source_w, + "h": source_h, + "rgba_bufs": [0, 1, 2, 3], + "x_range": [0.0, 2.0 * math.pi], + "y_range": [0.0, 1.0], + } + cols = [{"span": index + 1, "len": size, "dtype": "f32"} for index in range(len(channels))] + polar = _PolarProjection( + {}, + {"range": [0.0, 1.0]}, + {"x": 0.0, "y": 0.0, "w": 20.0, "h": 16.0}, + ) + sample_sizes: list[int] = [] + original_sample = _svg._heatmap_sample_column + + def record_sample(meta, indices, blob, borrowed): + sample_sizes.append(len(indices)) + return original_sample(meta, indices, blob, borrowed) + + monkeypatch.setattr(_svg, "_heatmap_sample_column", record_sample) + rgba = polar_heatmap_rgba(hm, b"", cols, {"opacity": 0.5}, polar, channels) + visible = rgba[rgba[:, :, 3] > 0] + assert len(visible) + np.testing.assert_array_equal( + visible, + np.broadcast_to(np.asarray([63, 127, 191, 127], dtype=np.uint8), visible.shape), + ) + assert sample_sizes and max(sample_sizes) <= rgba.shape[0] * rgba.shape[1] + assert max(sample_sizes) < size + + +def test_borrowed_heatmap_dtype_mismatch_casts_only_sampled_cells(monkeypatch) -> None: + cast_sizes: list[int] = [] + + class GuardedArray(np.ndarray): + def astype(self, *args, **kwargs): + cast_sizes.append(self.size) + if self.size == source.size: + raise AssertionError("full borrowed source was cast before sampling") + return super().astype(*args, **kwargs) + + source = np.arange(10_000, dtype=np.float32).view(GuardedArray) + real_asarray = np.asarray + + def guarded_asarray(value, *args, **kwargs): + if value is source: + assert not args and kwargs.get("dtype") is None + return source + return real_asarray(value, *args, **kwargs) + + monkeypatch.setattr(_svg.np, "asarray", guarded_asarray) + sampled = _svg._heatmap_sample_column( + {"span": 1, "len": source.size, "dtype": "f64"}, + np.asarray([1, 500, 9_999], dtype=np.int64), + b"", + (source,), + ) + np.testing.assert_array_equal(sampled, [1.0, 500.0, 9_999.0]) + assert cast_sizes and set(cast_sizes) == {3} + + +def test_raster_polar_heatmap_forwards_device_scale(monkeypatch) -> None: + seen: dict[str, object] = {} + + def fake_heatmap(*_args, output_scale, **_kwargs): + seen["scale"] = output_scale + return np.zeros((5, 7, 4), dtype=np.uint8) + + class RecordingCmd: + s = 2.5 + + def image(self, *args, **kwargs): + seen["image"] = (args, kwargs) + + monkeypatch.setattr(_raster, "polar_heatmap_rgba", fake_heatmap) + polar = _PolarProjection({}, {"range": [0.0, 1.0]}, PLOT) + _raster._emit_grid( + RecordingCmd(), + "heatmap", + {"w": 1, "h": 1}, + b"", + [], + _Scale({"range": [0.0, 1.0]}, 0.0, 1.0), + _Scale({"range": [0.0, 1.0]}, 0.0, 1.0), + {}, + polar=polar, + ) + assert seen["scale"] == 2.5 + image_args, image_kwargs = seen["image"] + assert image_args[4:6] == (7, 5) + assert image_kwargs == {"nearest": True} + + +def test_segment_projection_clips_both_endpoints_jointly() -> None: + arrays = [ + np.asarray([0.0], dtype=np.float32), + np.asarray([math.pi / 2.0], dtype=np.float32), + np.asarray([-1.0], dtype=np.float32), + np.asarray([3.0], dtype=np.float32), + ] + blob = b"".join(value.tobytes() for value in arrays) + cols = [ + {"dtype": "f32", "len": 1, "byte_offset": index * 4, "scale": 1.0, "offset": 0.0} + for index in range(4) + ] + trace = {"x0": 0, "x1": 1, "y0": 2, "y1": 3} + axis = {"range": [0.0, 2.0]} + scale = _Scale(axis, 0.0, 100.0) + polar = _PolarProjection({}, axis, PLOT) + markup = _segment_marks(trace, blob, cols, scale, scale, {}, "#123456", polar) + + # Radial intersections are t=.25 and t=.75, so theta is interpolated to + # pi/8 and 3pi/8 instead of retaining/clamping the original angles. + want_x0, want_y0 = polar(math.pi / 8.0, 0.0) + want_x1, want_y1 = polar(3.0 * math.pi / 8.0, 2.0) + match = re.search( + r'x1="([^"]+)" y1="([^"]+)" x2="([^"]+)" y2="([^"]+)"', + markup, + ) + assert match is not None + assert [float(value) for value in match.groups()] == pytest.approx( + [float(want_x0), float(want_y0), float(want_x1), float(want_y1)], + abs=1e-2, + ) + + +def test_native_point_annotations_use_joint_polar_projection() -> None: + class RecordingCmd: + def __init__(self) -> None: + self.points: list[tuple[float, float]] = [] + self.strokes: list[list[tuple[float, float]]] = [] + + def point(self, x, y, *_args, **_kwargs): + self.points.append((x, y)) + + def stroke(self, points, *_args, **_kwargs): + self.strokes.append(list(points)) + + def fill(self, *_args, **_kwargs): + pass + + def clip(self, *_args, **_kwargs): + pass + + def polar_clip(self, *_args, **_kwargs): + pass + + theta_axis = {"range": [0.0, 2.0 * math.pi], "sector": [0.0, math.pi]} + r_axis = {"range": [0.0, 1.0], "hole": 0.25} + polar = _PolarProjection(theta_axis, r_axis, PLOT) + sx = _Scale(theta_axis, 0.0, 200.0) + sy = _Scale(r_axis, 200.0, 0.0) + cmd = RecordingCmd() + annotations = [ + {"kind": "marker", "x": math.pi / 2.0, "y": 0.75, "size": 8.0}, + { + "kind": "arrow", + "x0": 0.0, + "y0": 0.5, + "x1": math.pi / 2.0, + "y1": 0.75, + "style": {"head_style": "none"}, + }, + ] + _raster._emit_annotations(cmd, annotations, sx, sy, PLOT, 200.0, 200.0, polar=polar) + + marker = tuple(float(value) for value in polar(math.pi / 2.0, 0.75)) + assert cmd.points == [pytest.approx(marker)] + arrow_start = tuple(float(value) for value in polar(0.0, 0.5)) + arrow_end = tuple(float(value) for value in polar(math.pi / 2.0, 0.75)) + assert cmd.strokes[0][0] == pytest.approx(arrow_start) + assert cmd.strokes[0][-1] == pytest.approx(arrow_end) + assert _annotation_connector_unclipped(annotations[1], sx, sy, PLOT, polar) + outside = {**annotations[1], "x1": 3.0 * math.pi / 2.0} + assert not _annotation_connector_unclipped(outside, sx, sy, PLOT, polar) + + +def test_native_polar_clip_masks_area_hole_and_line_across_missing_sector() -> None: + theta0, theta1 = math.pi / 4.0, 7.0 * math.pi / 4.0 + figure = xy.polar_chart( + xy.area( + [theta0, math.pi, theta1], + [0.9, 0.9, 0.9], + base=[0.0, 0.0, 0.0], + color="#ff0000", + opacity=1.0, + ), + xy.line([theta0, theta1], [0.9, 0.9], color="#ff0000", width=8.0), + xy.theta_axis(sector=(theta0, theta1)), + xy.r_axis(domain=(0.0, 1.0), hole=0.35), + width=260, + height=220, + ).figure() + spec, blob, borrowed = figure._build_raster_payload() + pixels = _raster.render_raster(spec, blob, scale=1.0, borrowed=borrowed) + _width, _height, _compact, plot = layout(spec) + polar = _PolarProjection(spec["x_axis"], spec["y_axis"], plot) + + def red_near(x: float, y: float) -> bool: + ix, iy = int(round(x)), int(round(y)) + window = pixels[max(0, iy - 2) : iy + 3, max(0, ix - 2) : ix + 3, :3].astype(int) + return bool(((window[:, :, 0] > 200) & (window[:, :, 1] < 80)).any()) + + assert not red_near(polar.cx, polar.cy), "area fill painted through the authored hole" + endpoint0 = np.asarray(polar(theta0, 0.9), dtype=np.float64) + endpoint1 = np.asarray(polar(theta1, 0.9), dtype=np.float64) + midpoint = (endpoint0 + endpoint1) / 2.0 + assert not red_near(*midpoint), "line chord painted through the missing sector" + + +@pytest.mark.parametrize("kind", ["heatmap", "contour", "errorbar"]) +def test_phase7_marks_complete_both_static_exports(kind: str) -> None: + theta = np.linspace(0.0, 2.0 * math.pi, 8, endpoint=False) + radial = np.linspace(0.5, 2.5, 4) + values = np.sin(theta[None, :]) + radial[:, None] + if kind == "heatmap": + mark = xy.heatmap(values, x=theta, y=radial) + elif kind == "contour": + mark = xy.contour(values, x=theta, y=radial, levels=4) + else: + mark = xy.errorbar(theta[:4], radial, yerr=0.35) + figure = xy.polar_chart(mark, width=300, height=300).figure() + assert figure.to_image(format="svg").startswith(b" pixel contract, bound to shared fixtures. + +`tests/fixtures/polar_transform.json` is authored from +spec/design/polar-axes.md §3 rather than generated from any implementation, so +these assertions are a contract check and not a rubber stamp. The same file is +replayed against the real GLSL by `scripts/polar_parity_smoke.py`; that pairing +is the whole point — prose comments do not bind a shader to an exporter. +""" + +from __future__ import annotations + +import json +import math +from pathlib import Path + +import pytest + +from xy._svg import THETA_ZERO, _PolarProjection + +FIXTURES = json.loads( + (Path(__file__).parent / "fixtures" / "polar_transform.json").read_text(encoding="utf-8") +) +TOL = FIXTURES["tolerance_px"] +CASES = FIXTURES["cases"] + + +def _projection(case: dict) -> _PolarProjection: + cfg = case["config"] + return _PolarProjection( + { + "theta_unit": cfg["unit"], + "theta_zero": cfg["zero"], + "theta_direction": cfg["direction"], + }, + {"range": cfg["r_range"]}, + case["plot"], + ) + + +@pytest.mark.parametrize("case", CASES, ids=[c["name"] for c in CASES]) +def test_projection_matches_fixture(case: dict) -> None: + """Every fixture point lands where spec §3 says it does.""" + project = _projection(case) + for point in case["points"]: + px, py = project(point["theta"], point["r"]) + assert float(px) == pytest.approx(point["px"], abs=TOL), ( + f"{case['name']}: theta={point['theta']} r={point['r']} x — {case['pins']}" + ) + assert float(py) == pytest.approx(point["py"], abs=TOL), ( + f"{case['name']}: theta={point['theta']} r={point['r']} y — {case['pins']}" + ) + + +@pytest.mark.parametrize("case", CASES, ids=[c["name"] for c in CASES]) +def test_projection_is_vectorized(case: dict) -> None: + """Array input gives the same answer as scalar input, elementwise. + + Every mark helper projects whole columns at once, so a scalar-only + implementation would pass the test above and still fail in production. + """ + project = _projection(case) + thetas = [p["theta"] for p in case["points"]] + radii = [p["r"] for p in case["points"]] + xs, ys = project(thetas, radii) + for i, point in enumerate(case["points"]): + assert float(xs[i]) == pytest.approx(point["px"], abs=TOL) + assert float(ys[i]) == pytest.approx(point["py"], abs=TOL) + + +def test_y_axis_is_flipped_for_screen_space() -> None: + """Upward angles must DECREASE py. + + The single most likely parity bug: screen space grows downward while GL + clip space grows upward, so the two implementations of this transform + legitimately differ by the sign of one term. A mirrored chart is otherwise + entirely plausible-looking. + """ + project = _PolarProjection({}, {"range": [0.0, 1.0]}, {"x": 0, "y": 0, "w": 400, "h": 400}) + _, up = project(math.pi / 2, 1.0) + _, down = project(-math.pi / 2, 1.0) + _, centre = project(0.0, 0.0) + assert float(up) < float(centre) < float(down) + + +def test_circle_is_round_in_a_non_square_rect() -> None: + """Radius comes from min(w, h) and the circle is centred, not stretched.""" + plot = {"x": 50.0, "y": 20.0, "w": 600.0, "h": 300.0} + project = _PolarProjection({}, {"range": [0.0, 1.0]}, plot) + centre = (plot["x"] + plot["w"] / 2, plot["y"] + plot["h"] / 2) + for theta in (0.0, math.pi / 3, math.pi / 2, 2.0, math.pi, 4.5): + px, py = project(theta, 1.0) + assert math.hypot(float(px) - centre[0], float(py) - centre[1]) == pytest.approx( + 150.0, abs=1e-9 + ) + + +def test_polar_projection_is_never_affine() -> None: + """The affine fast paths bake a straight-line map into Rust. + + A polar chart on linear axes would otherwise satisfy `sx.affine and + sy.affine` and get silently projected through that map. + """ + assert ( + _PolarProjection({}, {"range": [0.0, 1.0]}, {"x": 0, "y": 0, "w": 10, "h": 10}).affine + is False + ) + + +def test_compass_composition() -> None: + """zero="N" + clockwise is the wind-rose convention: 90deg reads as East.""" + project = _PolarProjection( + {"theta_unit": "degrees", "theta_zero": "N", "theta_direction": "clockwise"}, + {"range": [0.0, 1.0]}, + {"x": 0, "y": 0, "w": 400, "h": 400}, + ) + for degrees, want in ((0, (200, 0)), (90, (400, 200)), (180, (200, 400)), (270, (0, 200))): + px, py = project(degrees, 1.0) + assert (float(px), float(py)) == pytest.approx(want, abs=1e-9) + + +def test_theta_zero_table_covers_the_cardinals() -> None: + assert set(THETA_ZERO) == {"E", "N", "W", "S"} + assert THETA_ZERO["E"] == 0.0 + + +def test_ring_is_closed_and_on_the_circle() -> None: + """Grid rings flatten to polylines — the raster path has no arc opcode.""" + project = _PolarProjection({}, {"range": [0.0, 10.0]}, {"x": 0, "y": 0, "w": 400, "h": 400}) + ring = project.ring(5.0, steps=64) + assert len(ring) == 64 + # r=5 of range [0, 10] is half the 200 px radius. + for px, py in ring: + assert math.hypot(px - 200.0, py - 200.0) == pytest.approx(100.0, abs=1e-9) + + +def test_negative_radius_falls_inside_the_ring_it_came_from() -> None: + """Below-range r normalizes negative, which mirrors across the origin. + + Documenting the raw behaviour: the transform itself does not clip, so the + kernel must drop these rows before they reach a renderer (§7 / D7). + """ + project = _PolarProjection({}, {"range": [0.0, 1.0]}, {"x": 0, "y": 0, "w": 400, "h": 400}) + px, _ = project(0.0, -1.0) + assert float(px) < 200.0 diff --git a/tests/test_trace_buffer_lifecycle.py b/tests/test_trace_buffer_lifecycle.py new file mode 100644 index 00000000..754e6018 --- /dev/null +++ b/tests/test_trace_buffer_lifecycle.py @@ -0,0 +1,69 @@ +"""The client must delete every GL buffer a trace build can create. + +Repeated data updates rebuild GPU traces: `_appendTraceInPlace` bails out, the +old record is torn down, and `_buildTrace` makes a new one. Teardown used a +hand-kept list of geometry buffer names, so the style, direct-rgba colour, +stroke, corner-radius, LOD-blend and dashed-line-length buffers were orphaned on +every update and the leak grew without bound. + +The fix is one shared list (`TRACE_GPU_BUFFERS`, js/src/00_header.ts) read by all +three teardown paths. This module pins the list against the build paths, so a new +channel buffer cannot reintroduce the leak by simply not being mentioned. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +JS_SRC = ROOT / "js/src" + +# `g.styleBuf = ...`, `d.dBuf = gl.createBuffer()`, `s._lenBuf = this._upload(x)` +# — an assignment that puts a buffer handle on a trace-shaped record. The `._` +# prefix form is included: private derived buffers leak exactly like public ones. +_BUFFER_ASSIGNMENT = re.compile(r"\b\w+\.(_?[A-Za-z][A-Za-z0-9]*Buf)\s*=(?!=)") + + +def _trace_gpu_buffers() -> list[str]: + source = (JS_SRC / "00_header.ts").read_text(encoding="utf-8") + block = source.split("export const TRACE_GPU_BUFFERS = [", 1)[1].split("];", 1)[0] + return re.findall(r'"([^"]+)"', block) + + +def _assigned_buffer_fields() -> dict[str, set[str]]: + """Buffer field name -> the modules that assign it.""" + assigned: dict[str, set[str]] = {} + for path in sorted(JS_SRC.glob("*.ts")): + for name in _BUFFER_ASSIGNMENT.findall(path.read_text(encoding="utf-8")): + assigned.setdefault(name, set()).add(path.name) + return assigned + + +def test_trace_teardown_deletes_every_gpu_buffer() -> None: + listed = _trace_gpu_buffers() + assert listed, "TRACE_GPU_BUFFERS could not be parsed out of 00_header.ts" + assert len(listed) == len(set(listed)), "TRACE_GPU_BUFFERS has duplicate names" + + assigned = _assigned_buffer_fields() + missing = {name: sorted(mods) for name, mods in assigned.items() if name not in listed} + assert not missing, ( + "these GL buffer fields are created but never deleted — add them to " + f"TRACE_GPU_BUFFERS in js/src/00_header.ts: {missing}" + ) + + +def test_every_teardown_path_reads_the_shared_buffer_list() -> None: + """No path may keep its own subset; that is how the leak started.""" + chartview = (JS_SRC / "50_chartview.ts").read_text(encoding="utf-8") + lod = (JS_SRC / "45_lod.ts").read_text(encoding="utf-8") + + # The live trace, its drill window, and a sample overlay. + assert "this._deleteBuffers(g, TRACE_GPU_BUFFERS);" in chartview + assert "this._deleteBuffers(g.drill, TRACE_GPU_BUFFERS);" in chartview + assert "this._deleteBuffers(s, TRACE_GPU_BUFFERS);" in chartview + assert "view._deleteBuffers(d, TRACE_GPU_BUFFERS);" in lod + + # The retained M4 overview owns only its geometry; its channel buffers are + # aliases of the live trace's and are deleted exactly once, above. + assert 'this._deleteBuffers(g._homeDecimated, ["xBuf", "yBuf", "baseBuf"]);' in chartview diff --git a/tests/test_type_surface.py b/tests/test_type_surface.py index 636fee84..3d54240d 100644 --- a/tests/test_type_surface.py +++ b/tests/test_type_surface.py @@ -50,9 +50,20 @@ "y_band", "text", ) +AXIS_FACTORIES = ( + "x_axis", + "y_axis", + "theta_axis", + "r_axis", +) CHART_FACTORIES = ( "chart", "scatter_chart", + "polar_chart", + "radar_chart", + "polar_bar_chart", + "pie_chart", + "wind_rose", "line_chart", "area_chart", "histogram_chart", @@ -242,8 +253,7 @@ def test_public_factories_are_typed_root_exports() -> None: for name in ( *MARK_FACTORIES, *ANNOTATION_FACTORIES, - "x_axis", - "y_axis", + *AXIS_FACTORIES, *CHROME_FACTORIES, *CHART_FACTORIES, ): @@ -261,8 +271,7 @@ def test_composition_alpha_contract_is_explicitly_exported() -> None: *ANNOTATION_FACTORIES, *CHART_FACTORIES, *CHROME_FACTORIES, - "x_axis", - "y_axis", + *AXIS_FACTORIES, } for name in sorted(contract): @@ -280,8 +289,7 @@ def test_public_component_factories_have_typed_signatures() -> None: expected_returns = { **{name: components.Mark for name in MARK_FACTORIES}, **{name: components.Annotation for name in ANNOTATION_FACTORIES}, - "x_axis": components.Axis, - "y_axis": components.Axis, + **{name: components.Axis for name in AXIS_FACTORIES}, "legend": components.Legend, "tooltip": components.Tooltip, "colorbar": components.Colorbar, @@ -349,11 +357,19 @@ def test_annotation_factory_kinds_are_registered_with_typed_appliers() -> None: def test_chart_factories_construct_named_lazy_charts() -> None: + # One table, so a factory that needs arguments cannot be registered as + # requiring them without also being exempted from the empty-children check. + required_arguments: dict[str, tuple[Any, ...]] = { + "radar_chart": (["a", "b", "c"],), + "wind_rose": ([0.0], [1.0]), + "pie_chart": (["a", "b"], [1.0, 2.0]), + } for name in CHART_FACTORIES: - chart = getattr(components, name)() + chart = getattr(components, name)(*required_arguments.get(name, ())) assert isinstance(chart, components.Chart), name assert chart.kind == name - assert chart.children == () + if name not in required_arguments: + assert chart.children == () assert chart._figure is None assert chart._widget is None