From 78a2e528ffa29005ebdca8ed510a3ce416185e10 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 30 Jul 2026 17:42:52 +0000 Subject: [PATCH 1/2] Document polar charts --- CHANGELOG.md | 18 + README.md | 4 +- docs/api-reference/changelog.md | 14 + docs/api-reference/chart-factories.md | 7 +- .../limitations-and-alpha-status.md | 16 + docs/app/scripts/check_html_routes.py | 2 +- docs/app/tests/test_docs_site.py | 452 ++++++++++- docs/app/xy_docs/api_reference.py | 40 +- docs/app/xy_docs/config.py | 5 + docs/app/xy_docs/gallery.py | 26 + docs/app/xy_docs/sidebar.py | 104 ++- docs/charts/contour-plot.md | 2 + docs/charts/heatmap.md | 3 + docs/charts/pie-chart.md | 707 +++++++++++++++++ docs/charts/polar-chart.md | 418 ++++++++++ docs/charts/radar-chart.md | 171 +++++ docs/charts/radial-bar-chart.md | 715 ++++++++++++++++++ docs/charts/uncertainty.md | 5 + docs/charts/wind-rose.md | 148 ++++ docs/components/annotations.md | 10 + .../modebars-and-interaction-controls.md | 8 + docs/components/tooltips.md | 18 +- docs/core-concepts/interactions.md | 13 + .../large-data-and-performance.md | 10 + docs/guides/display-and-export.md | 14 +- docs/integrations/matplotlib.md | 62 +- docs/overview/gallery.md | 12 +- python/xy/components.py | 16 +- scripts/check_public_api.py | 7 +- spec/api/chart-kind-contract.md | 13 +- spec/api/chart-roadmap.md | 31 +- spec/api/interaction.md | 8 +- spec/design/polar-axes.md | 39 +- spec/matplotlib/compat.md | 11 +- tests/test_type_surface.py | 32 +- 35 files changed, 3051 insertions(+), 110 deletions(-) create mode 100644 docs/charts/pie-chart.md create mode 100644 docs/charts/polar-chart.md create mode 100644 docs/charts/radar-chart.md create mode 100644 docs/charts/radial-bar-chart.md create mode 100644 docs/charts/wind-rose.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d38fb5a..548797ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,14 @@ in the README). 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 @@ -96,6 +104,16 @@ in the README). 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 diff --git a/README.md b/README.md index 56e90287..01416c81 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,9 @@ 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:** available through `xy.pyplot` and unequal-width core polar + bars today; a dedicated `xy.pie_chart(xy.pie(...))` convenience and automatic + labels remain - **Candlestick / OHLC and finance overlays:** SMA, VWAP, Bollinger, RSI, MACD; prototyped, awaiting a fresh landing - **Waterfall and funnel** - **Treemap, sunburst, and icicle** diff --git a/docs/api-reference/changelog.md b/docs/api-reference/changelog.md index 6fa0dbdc..256181d5 100644 --- a/docs/api-reference/changelog.md +++ b/docs/api-reference/changelog.md @@ -37,6 +37,20 @@ density families, facets, live `Chart.append()`/`pick()`/`select_range()`, browser-free native PNG, pure SVG export, CSS-compiled mark styling, and the experimental `xy.pyplot` compatibility layer. +The Unreleased polar expansion adds heatmap, contour, and error-bar marks; +partial sectors, holes/data-space origins, categorical theta, log/symlog +radius, and polygonal grids; and matching pyplot theta/r-origin controls. +Protocol v12 prevents an older client from silently interpreting that geometry +as the previous full-circle surface. + +Outlined radar charts now render reliably: `radar_chart(fill=False)` translates +area stroke settings into the corresponding line props, including color, width, +opacity, curve, and dash. + +Default tooltips now lead with a named series and speak polar coordinates on +polar charts: θ values reuse authored tick labels or the axis unit, and radial +values are labeled r instead of appearing as Cartesian x/y rows. + Read [Chart methods](/docs/xy/api-reference/figure-methods/), [Customize Each Part](/docs/xy/styling/customize/#fill,-stroke,-opacity,-and-gradients), and [Limitations and alpha status](/docs/xy/api-reference/limitations-and-alpha-status/) diff --git a/docs/api-reference/chart-factories.md b/docs/api-reference/chart-factories.md index 4cd84136..7682b63f 100644 --- a/docs/api-reference/chart-factories.md +++ b/docs/api-reference/chart-factories.md @@ -6,9 +6,10 @@ description: Reference XY chart containers, shared props, and facet construction # Chart Factories Chart factories compose marks, axes, annotations, and chrome into a public -`Chart`. The generated inventory below follows the same taxonomy as the -[Chart Gallery](/docs/xy/overview/gallery/). Its names, signatures, and -defaults come directly from XY's public Python callables. +`Chart`. The generated inventory below is organized by chart family; use the +[Chart Gallery](/docs/xy/overview/gallery/) when visual browsing is more +helpful. Its names, signatures, and defaults come directly from XY's public +Python callables. Use `chart()` when different mark kinds share a panel. A family container is usually clearer when the children represent one chart family. diff --git a/docs/api-reference/limitations-and-alpha-status.md b/docs/api-reference/limitations-and-alpha-status.md index 984d291e..3141d0e3 100644 --- a/docs/api-reference/limitations-and-alpha-status.md +++ b/docs/api-reference/limitations-and-alpha-status.md @@ -28,6 +28,10 @@ pre-1.0 releases can make breaking changes with migration notes. new visible-window representation. - Density is native-binned and GPU-rendered. It is not an all-GPU ingest and aggregation pipeline. +- Polar traces do not use Cartesian decimation, density, or refined-view + tiers. `line`, `scatter`, and `area` render directly and reject more than + 200,000 points per trace. Heatmap and contour grids are not governed by that + point ceiling. - Arrow ingest is zero-copy only for compatible, null-free primitive layouts. Chunking, nulls, dtype conversion, and datetime conversion can copy. - Disk-backed/out-of-core residency is not a shipped public tier. @@ -46,6 +50,18 @@ and [Benchmarks](/docs/xy/overview/benchmarks/) for scoped evidence. - Linked views synchronize viewport axes, not selections or cross-filtering. - Facets support display/export and shared domains but not `Chart` append, pick, or Python-side range-selection methods. +- Polar interaction currently consists of hover, radial zoom about a fixed + radial minimum, and reset. Authored sectors are supported; theta pan/rotation, + interactive sector zoom, box zoom, selection, brushing, and crosshairs are + disabled. +- Point-anchored `text`, `label`, `marker`, `arrow`, and `callout` annotations + use the joint `(theta, r)` projection consistently on polar charts in the + browser, SVG, and native raster output. Polar rules and bands remain deferred + because they require spoke/ring and sector/annulus geometry, and raise at + payload build instead of using Cartesian geometry. +- Polar histograms, box plots, hexbin/density, generic segments, and meshes + remain outside the mark allowlist. Polar LOD, facets/animation, and angular + navigation/selection are also deferred. - Browser context limits matter on large dashboards. XY's context governor defaults to 12 live contexts and reacquires off-screen charts as they return; more than that many simultaneously visible charts is not an unbounded diff --git a/docs/app/scripts/check_html_routes.py b/docs/app/scripts/check_html_routes.py index 45615f92..720097d2 100644 --- a/docs/app/scripts/check_html_routes.py +++ b/docs/app/scripts/check_html_routes.py @@ -12,7 +12,7 @@ ROUTES_ROOT = APP_ROOT / ".web" / "app" / "routes" LIVE_PREVIEW_MARKERS = ("python demo exec", "python demo-only exec") INLINE_SVG_PREVIEW_ROUTES = {"/overview/gallery/"} -INLINE_SVG_PREVIEW_COUNT = 29 +INLINE_SVG_PREVIEW_COUNT = 34 XY_PAYLOAD_PATTERN = re.compile(r'["\'](?P/docs/xy/xy/[a-f0-9]+\.xyf)["\']') XY_PAYLOAD_MAGIC = b"XYBF" LLMS_DIRECTIVE = "For AI agents: the complete XY documentation index is at" diff --git a/docs/app/tests/test_docs_site.py b/docs/app/tests/test_docs_site.py index 5467acee..aaf89923 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) == 15 + assert len(chart_section) == 20 assert "XYChart" not in rendered - assert rendered.count("dangerouslySetInnerHTML") == 29 + assert rendered.count("dangerouslySetInnerHTML") == 34 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]") == 29 - assert rendered.count("shadow-large") == 29 - assert rendered.count("transition-bg") == 29 + assert rendered.count("2xl:grid-cols-3") == 9 + assert rendered.count("aspect-[320/232]") == 34 + assert rendered.count("shadow-large") == 34 + assert rendered.count("transition-bg") == 34 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") == 29 + assert rendered.count("size:14") == 34 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", @@ -1294,7 +1344,7 @@ def test_chart_gallery_inline_svgs_share_the_component_preview_style() -> None: for item in group.items } - assert len(previews) == 29 + assert len(previews) == 34 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)} @@ -1362,6 +1456,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(", @@ -1410,7 +1509,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) == 29 + assert len(titles) == 34 assert section_titles[:3] == [ "Line and Area", "Distributions", @@ -1422,6 +1521,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 { @@ -1430,6 +1546,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", "Sankey", @@ -1438,6 +1559,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) @@ -1520,17 +1779,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 * 29 + "gallery-preview-surface aspect-[320/232] shadow-large", + preview * 34 + "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 * 28 + "gallery-preview-surface aspect-[320/232] shadow-large", + preview * 33 + "gallery-preview-surface aspect-[320/232] shadow-large", encoding="utf-8", ) - with pytest.raises(RuntimeError, match="28 previews, expected 29"): + with pytest.raises(RuntimeError, match="33 previews, expected 34"): check_html_routes.validate_inline_svg_gallery("/overview/gallery/", module_path) @@ -1625,30 +1884,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", @@ -1656,8 +1933,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", "/"), @@ -1682,10 +2004,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() @@ -1937,6 +2291,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",), @@ -2025,6 +2387,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 cbc62030..52f0513b 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" @@ -78,6 +79,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, @@ -106,6 +116,8 @@ AXES_AND_ANNOTATIONS = ( xy.x_axis, xy.y_axis, + xy.theta_axis, + xy.r_axis, xy.vline, xy.hline, xy.x_band, @@ -227,8 +239,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 fb038777..d74f65e4 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 c67128c7..bd788131 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, @@ -209,6 +220,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..57bbbb9f --- /dev/null +++ b/docs/charts/pie-chart.md @@ -0,0 +1,707 @@ +--- +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? + +No. Pie and donut geometry is composed from `xy.bar()` marks inside +`xy.polar_bar_chart()`. This keeps sector styling and export behavior on the +same renderer as radial bars. + +### 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 8c5e44ad..cd72e7bf 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 share, + progress-ring, and gauge blocks, and + [wind roses](/docs/xy/charts/wind-rose/) for directional distributions - Specialized: [stem](/docs/xy/charts/stem-plot/), [segments](/docs/xy/charts/segments/), [Sankey](/docs/xy/charts/sankey/), and diff --git a/python/xy/components.py b/python/xy/components.py index 51d10cf0..a8d68892 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -6390,9 +6390,9 @@ 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` and `xy.scatter` are reused verbatim rather than replaced by - polar-specific marks. Configure the angular axis with `xy.theta_axis` and - the radial axis with `xy.r_axis`. + `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"), @@ -6401,8 +6401,10 @@ def polar_chart(*children: Component, **props: Any) -> Chart: ) Supported mark kinds are listed in `xy.config.POLAR_MARK_KINDS`; anything - else is refused at build time, with the supported set named in the error, - rather than approximated. See spec/design/polar-axes.md. + 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) @@ -6554,7 +6556,9 @@ def polar_bar_chart(*children: Component, **props: Any) -> Chart: 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). + 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), 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/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index ef5e242e..1fc6217c 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -245,13 +245,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. @@ -263,6 +265,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 7c7ad5d8..ef5699dc 100644 --- a/spec/api/chart-roadmap.md +++ b/spec/api/chart-roadmap.md @@ -146,7 +146,7 @@ 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 | Implemented core | `xy.sankey_chart(links)` — Python layout (`_sankey.py`: layering, barycentre crossing minimisation, endpoint stacking) over the new `ribbon` primitive (per-end colours, flow-axis gradient, protocol v11). Alluvial/dependency-wheel remain compositions to build on the same primitive. Deferred: GPU picking (CPU hover only), legend swatches, cycle auto-breaking. | | 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 | Quiver, barbs, and bounded streamlines feed shared instanced segments; `xy.wind_rose` ships as stacked polar bars over Python-side binning. | +| 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 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. | @@ -213,7 +213,7 @@ depth: strip/swarm/boxen/rug distributions, regression diagnostics, richer |---:|---|---|---| | 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. | All implemented; `xy.wind_rose` rides the polar bar path. | +| 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. | @@ -414,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. @@ -449,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/design/polar-axes.md b/spec/design/polar-axes.md index 159411ba..68bededc 100644 --- a/spec/design/polar-axes.md +++ b/spec/design/polar-axes.md @@ -5,8 +5,8 @@ for the (θ, r) → pixel transform, the angular conventions, the wire shape, an 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 "awaits polar -support"), item 34 (specialist coordinate systems) in +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 @@ -174,7 +174,7 @@ Eight properties this pins down, each of which has matching coverage: ### 3.1 Where it sits in the existing pipeline The Cartesian pipeline is two independent 1-D maps. On the client -(`AXIS_GLSL`, `js/src/40_gl.ts:80`): +(`AXIS_GLSL` in `js/src/40_gl.ts`): ``` xyDecode(encoded, meta) → undo §16 offset encoding, back to data space @@ -182,8 +182,8 @@ xyAxisCoord(...) → apply the scale (log / symlog / linear) · map.x + map.y → affine to clip space ``` -and in Python, `_Scale.coord()` then `_Scale.__call__()` -(`python/xy/_svg.py:814`, `:827`) do the same two steps. +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 @@ -191,8 +191,8 @@ 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 -(`js/src/50_chartview.ts:1833`), clip space `[-1, 1]²` **is** the plot rect. So +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: @@ -260,7 +260,7 @@ Fixture cases are chosen so a human can check them by inspection: Three consumers must agree with that file: -1. **Python** — a unit test over `_polar_project`. Fast, always runs. +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 @@ -271,9 +271,8 @@ Three consumers must agree with that file: 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:477-767` are bound -by **nothing executable** — a gap that has already allowed a live divergence in -the tick-count target between client and exporters. Polar does not repeat it. +`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 @@ -441,6 +440,16 @@ grid blits will silently project through a straight-line map. `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 @@ -488,12 +497,14 @@ transfer unmodified and must not be silently reused: density renders as centre-concentrated. Both need their own design work in -[`lod-architecture.md`](lod-architecture.md). Until then the cap is explicit and -reported, per §28. +[`lod-architecture.md`](lod-architecture.md). Any future fallback or ceiling +change must be explicit and reported, per §28. ## 8. Interaction -MVP surface, deliberately small: +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. diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index bb8920a8..3c5b0239 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -98,11 +98,12 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent ## Outside 2-D chart-method compatibility -3-D and arbitrary custom 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, polar axes, -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/tests/test_type_surface.py b/tests/test_type_surface.py index b28417f9..f76a64fa 100644 --- a/tests/test_type_surface.py +++ b/tests/test_type_surface.py @@ -52,9 +52,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", @@ -244,8 +255,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, ): @@ -263,8 +273,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): @@ -282,8 +291,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, @@ -352,10 +360,18 @@ def test_annotation_factory_kinds_are_registered_with_typed_appliers() -> None: def test_chart_factories_construct_named_lazy_charts() -> None: for name in CHART_FACTORIES: - chart = getattr(components, name)() + if name == "radar_chart": + chart = components.radar_chart(["a", "b", "c"]) + elif name == "wind_rose": + chart = components.wind_rose([0.0], [1.0]) + elif name == "pie_chart": + chart = components.pie_chart(["a", "b"], [1.0, 2.0]) + else: + chart = getattr(components, name)() assert isinstance(chart, components.Chart), name assert chart.kind == name - assert chart.children == () + if name not in {"radar_chart", "wind_rose", "pie_chart"}: + assert chart.children == () assert chart._figure is None assert chart._widget is None From 16f642b73cbda6e53abb78c828b18c2d542b5bf5 Mon Sep 17 00:00:00 2001 From: Alek Date: Thu, 30 Jul 2026 12:58:59 -0700 Subject: [PATCH 2/2] Address polar docs review feedback --- README.md | 6 +++--- docs/app/tests/test_docs_site.py | 14 ++++++++++---- docs/app/xy_docs/api_reference.py | 14 +++++++++----- docs/app/xy_docs/sidebar.py | 3 ++- docs/charts/pie-chart.md | 6 +++++- spec/design/polar-axes.md | 2 +- 6 files changed, 30 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 01416c81..1f64d39a 100644 --- a/README.md +++ b/README.md @@ -296,9 +296,9 @@ 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:** available through `xy.pyplot` and unequal-width core polar - bars today; a dedicated `xy.pie_chart(xy.pie(...))` convenience and automatic - labels remain +- **Pie / donut:** `xy.pie_chart(labels, values, hole=...)` ships over + unequal-width core polar bars, with Matplotlib-shaped helpers in `xy.pyplot`; + 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** diff --git a/docs/app/tests/test_docs_site.py b/docs/app/tests/test_docs_site.py index aaf89923..f3c45aef 100644 --- a/docs/app/tests/test_docs_site.py +++ b/docs/app/tests/test_docs_site.py @@ -1477,6 +1477,7 @@ def test_chart_gallery_cards_link_to_family_pages_with_live_demo_anchors() -> No for route, mark in standalone_chart_marks.items(): assert route in pages, route assert mark in pages[route].content, route + assert "xy.pie_chart(" in pages["/charts/pie-chart/"].content # Bar and column share one page: the bar page carries the column chart as a # subsidiary section. @@ -1536,6 +1537,7 @@ def test_chart_gallery_combines_only_the_requested_related_tiles() -> None: assert [(item.title, item.route) for item in specialized_group.items] == [ ("Stem", "/charts/stem-plot/"), ("Segments", "/charts/segments/"), + ("Sankey", "/charts/sankey/"), ("Triangle Mesh", "/components/triangle-mesh/"), ] assert {"Step + Stairs", "Bar + Column"} <= titles @@ -2298,6 +2300,7 @@ def test_chart_gallery_pages_append_factory_api_tables() -> None: ), "/charts/radar-chart/": ("xy.radar_chart",), "/charts/radial-bar-chart/": ("xy.polar_bar_chart",), + "/charts/pie-chart/": ("xy.pie_chart",), "/charts/wind-rose/": ("xy.wind_rose",), "/charts/bar-chart/": ("xy.bar_chart", "xy.column_chart"), "/charts/histogram/": ("xy.histogram_chart",), @@ -2392,12 +2395,13 @@ def test_polar_axis_api_expands_forwarded_axis_props() -> None: 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) + theta_refused = frozenset({*_POLAR_INERT_AXIS_KEYWORDS, "reverse"}) + radial_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 + name for name in inspect.signature(xy.x_axis).parameters if name not in theta_refused ) y_axis_names = tuple( - name for name in inspect.signature(xy.y_axis).parameters if name not in refused + name for name in inspect.signature(xy.y_axis).parameters if name not in radial_refused ) assert theta_names[:5] == ("unit", "zero", "direction", "sector", "grid_shape") @@ -2406,7 +2410,9 @@ def test_polar_axis_api_expands_forwarded_axis_props() -> None: 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 not theta_refused & set(theta_names) + assert not radial_refused & set(radial_names) + assert "reverse" in 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 diff --git a/docs/app/xy_docs/api_reference.py b/docs/app/xy_docs/api_reference.py index 52f0513b..74f35665 100644 --- a/docs/app/xy_docs/api_reference.py +++ b/docs/app/xy_docs/api_reference.py @@ -84,10 +84,14 @@ 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) +# they refuse the keywords no polar renderer implements. Angular `reverse` is +# also rejected at figure build in favor of `direction`; radial `reverse` +# remains supported. Documenting the forwarded signatures verbatim would +# re-advertise exactly those traps, so they are dropped from the relevant table. +_REFUSED_POLAR_AXIS_PARAMETERS = { + xy.theta_axis: frozenset({*_POLAR_INERT_AXIS_KEYWORDS, "reverse"}), + xy.r_axis: frozenset(_POLAR_INERT_AXIS_KEYWORDS), +} MARKS = ( xy.line, @@ -253,7 +257,7 @@ def _documented_parameters( 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.name not in _REFUSED_POLAR_AXIS_PARAMETERS[component] and parameter.kind is not inspect.Parameter.VAR_KEYWORD ) documented: list[tuple[inspect.Parameter, Mapping[str, str]]] = [] diff --git a/docs/app/xy_docs/sidebar.py b/docs/app/xy_docs/sidebar.py index 201fa6a6..3e697731 100644 --- a/docs/app/xy_docs/sidebar.py +++ b/docs/app/xy_docs/sidebar.py @@ -62,7 +62,8 @@ _chart_gallery_routes["Uncertainty"], "shapes", tuple( - (title, _chart_gallery_routes[title]) for title in ("Uncertainty", "Stem", "Segments") + (title, _chart_gallery_routes[title]) + for title in ("Uncertainty", "Stem", "Segments", "Sankey") ), ), ) diff --git a/docs/charts/pie-chart.md b/docs/charts/pie-chart.md index 57bbbb9f..cb20014b 100644 --- a/docs/charts/pie-chart.md +++ b/docs/charts/pie-chart.md @@ -1,6 +1,8 @@ --- title: Pie and Donut Charts in Python description: Build polished pie, donut, progress-ring, and gauge blocks in Python with xy and Reflex. +components: + - xy.pie_chart --- # Pie and Donut Charts in Python @@ -8,7 +10,9 @@ description: Build polished pie, donut, progress-ring, and gauge blocks in Pytho 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()`. +`polar_bar_chart()`. For the standard composition, use +`xy.pie_chart(labels, values, hole=...)`; the examples below use the lower-level +bars directly to demonstrate custom sector geometry and dashboard layouts. The first example keeps the chart intentionally small. The examples after it combine XY's exportable sector geometry with ordinary Reflex layout for center diff --git a/spec/design/polar-axes.md b/spec/design/polar-axes.md index 68bededc..368d091d 100644 --- a/spec/design/polar-axes.md +++ b/spec/design/polar-axes.md @@ -176,7 +176,7 @@ Eight properties this pins down, each of which has matching coverage: The Cartesian pipeline is two independent 1-D maps. On the client (`AXIS_GLSL` in `js/src/40_gl.ts`): -``` +```text 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