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..1f64d39a 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:** `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/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..f3c45aef 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(", @@ -1378,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. @@ -1410,7 +1510,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 +1522,24 @@ 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/"), + ("Sankey", "/charts/sankey/"), + ("Triangle Mesh", "/components/triangle-mesh/"), + ] assert {"Step + Stairs", "Bar + Column"} <= titles assert {"Step", "Stairs", "Bar", "Column"}.isdisjoint(titles) assert { @@ -1430,6 +1548,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 +1561,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 +1781,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 +1886,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 +1935,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 +2006,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 +2293,15 @@ 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/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",), "/charts/ecdf/": ("xy.ecdf_chart",), @@ -2025,6 +2390,35 @@ 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) + 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 theta_refused + ) + y_axis_names = tuple( + 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") + 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 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 + 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..74f35665 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,19 @@ _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. 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, @@ -106,6 +120,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 +243,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[component] + 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..3e697731 100644 --- a/docs/app/xy_docs/sidebar.py +++ b/docs/app/xy_docs/sidebar.py @@ -12,9 +12,80 @@ 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", "Sankey") + ), + ), +) +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 +185,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 +205,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 +221,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 +312,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..cb20014b --- /dev/null +++ b/docs/charts/pie-chart.md @@ -0,0 +1,711 @@ +--- +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 + +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()`. 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 +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..368d091d 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,16 +174,16 @@ 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`): -``` +```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 ``` -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