{# Radar / polar profile chart — v0.61.28 (#879). Server-rendered SVG polygon over a polar grid. Consumes `bucketed_metrics` (the same source bar_chart and line_chart use) — each entry's `value` becomes one spoke length. Each spoke's angle is `2π × i / N`, starting at the top (12 o'clock) and going clockwise so the natural reading order matches the bucket order. Single-series MVP: one polygon for the primary aggregate. Multi-series (target overlay, cohort comparison) deferred to a follow-up — requires `_compute_bucketed_aggregates` to return all metrics, not just the first. Card safety: region emits zero chrome + zero title. The dashboard slot owns both via region_card. #} {% from 'macros/region_wrapper.html' import region_card %} {% call region_card(title) %}
{% if bucketed_metrics and bucketed_metrics | length >= 3 %} {% set count = bucketed_metrics | length %} {# v0.61.32: multi-series support (#879). Each bucket carries a `metrics` sub-dict from `_compute_bucketed_aggregates` — iterate ALL metric names so each becomes its own polygon. The legacy single-series case (templates that only supplied `value`) still works because we fall back to `[bucket.value]` when `metrics` is absent. #} {% set _series_names = (bucketed_metrics[0].metrics.keys() | list) if bucketed_metrics[0].metrics is defined else [] %} {% if not _series_names %}{% set _series_names = ['value'] %}{% endif %} {# Y-axis max = global max across ALL series so polygons stay on the same scale. #} {% set _all_vals = [] %} {% for b in bucketed_metrics %} {% if b.metrics is defined %} {% for s in _series_names %}{% set _ = _all_vals.append(b.metrics[s]) %}{% endfor %} {% else %} {% set _ = _all_vals.append(b.value) %} {% endif %} {% endfor %} {% set max_val = _all_vals | max %} {% set max_val = max_val if max_val > 0 else 1 %} {# Series colour palette — same vocabulary as area_chart so a Dazzle dashboard reads consistently across chart types. #} {% set _palette = [ 'hsl(var(--primary))', 'hsl(145, 55%, 45%)', 'hsl(40, 90%, 55%)', 'hsl(290, 55%, 55%)', 'hsl(210, 80%, 55%)', ] %} {# SVG geometry: square viewBox so spokes don't squash. Centre + radius leave 32px padding for spoke labels around the edge. #} {% set side = 320 %} {% set cx = side / 2 %} {% set cy = side / 2 %} {% set r_max = (side / 2) - 32 %} {# Pre-compute per-spoke geometry. Earlier versions tried to use SVG `` chains because Jinja can't call cos/sin directly — that approach silently broke the data-vertex distribution (#929) and emitted zero-length ring/polygon segments because both endpoints were rotated together. The fix: a tiny `radar_polar_xy(index, count, ratio, cx, cy, r_max)` global (registered in `template_renderer.py`) does the polar → cartesian conversion in Python; the template emits explicit (x, y) coords everywhere. #} {% set ns = namespace(spokes=[]) %} {% for b in bucketed_metrics %} {% set per_series = {} %} {% for s in _series_names %} {% set v = (b.metrics[s] if b.metrics is defined else b.value) %} {% set _ = per_series.update({s: v / max_val}) %} {% endfor %} {% set axis_xy = radar_polar_xy(loop.index0, count, 1.0, cx, cy, r_max) %} {% set _ = ns.spokes.append({ 'label': b.label, 'value': b.value, 'metrics': b.metrics if b.metrics is defined else {'value': b.value}, 'index': loop.index0, 'per_series': per_series, 'axis_x': axis_xy.x, 'axis_y': axis_xy.y, }) %} {% endfor %} {# Concentric polar grid rings — 4 N-gons at 25/50/75/100% of r_max with vertices on the spoke endpoints. #} {% for ring_pct in [0.25, 0.5, 0.75, 1.0] %} {% set _ring_pts = [] %} {% for s in ns.spokes %} {% set p = radar_polar_xy(s.index, count, ring_pct, cx, cy, r_max) %} {% set _ = _ring_pts.append(p.x ~ ',' ~ p.y) %} {% endfor %} {% endfor %} {# Spoke axis lines — from centre to each spoke endpoint at r_max. #} {% for s in ns.spokes %} {% endfor %} {# Data polygons — one per series. Now that we have explicit (x, y) coords, render the outline as a single with a translucent fill plus N circle markers at the vertices — the readable shape every chart library converges on. #} {% for series_name in _series_names %} {% set series_colour = _palette[loop.index0 % (_palette | length)] %} {% set _poly_pts = [] %} {% set ns_v = namespace(vertices=[]) %} {% for s in ns.spokes %} {% set p = radar_polar_xy(s.index, count, s.per_series[series_name], cx, cy, r_max) %} {% set _ = _poly_pts.append(p.x ~ ',' ~ p.y) %} {% set _ = ns_v.vertices.append({'x': p.x, 'y': p.y, 'label': s.label, 'value': s.metrics[series_name]}) %} {% endfor %} {% for v in ns_v.vertices %} {{ v.label }} {{ series_name }}: {{ v.value | metric_number }} {% endfor %} {% endfor %} {# Spoke labels — placed slightly outside r_max so they don't collide with the outermost ring. Labels stay upright (no rotation) — radar labels rotated to follow the spoke read worse than horizontal text in user testing. #} {% for s in ns.spokes %} {% set label_pos = radar_polar_xy(s.index, count, 1.0, cx, cy, r_max + 14) %} {{ s.label }} {% endfor %} {# Series legend — one swatch + name per series. Multi-series only needs this; single-series matches the previous "peak X" line. #} {% if _series_names | length > 1 %}
{% for series_name in _series_names %} {% set series_colour = _palette[loop.index0 % (_palette | length)] %} {{ series_name }} {% endfor %}
{% endif %}

{{ count }} spokes · {{ _series_names | length }} {{ 'series' if _series_names | length > 1 else 'series' }} · peak {{ max_val | metric_number }}

{% elif bucketed_metrics and bucketed_metrics | length > 0 %} {# Fewer than 3 spokes is a degenerate radar (line or point); fall back to a compact KPI list rather than draw a meaningless shape. #}
{% for b in bucketed_metrics %}
{{ b.label }} {{ b.value | metric_number }}
{% endfor %}

Radar needs ≥ 3 spokes — showing values list instead.

{% else %}

{{ empty_message | default("No data available.") }}

{% endif %}
{% endcall %}