{# Line chart region — UX-067 (cycle 28, v0.60.0). Contract: ~/.claude/skills/ux-architect/components/line-chart-region.md Server-rendered SVG polyline for time-series distributions. Consumes `bucketed_metrics` from _aggregate_via_groupby when group_by is a BucketRef (bucket(created_at, day|week|month|quarter|year)). Aesthetic target: Linear — subtle grid, hairline axis, primary-tint fill under the line. No JS, no Alpine — pure HTMX-friendly. 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 > 1 %} {# y-axis upper bound includes reference_lines / reference_bands AND overlay_series so all visual elements stay inside the plot area (#883). Bands also widen the floor below 0 if needed. #} {% set data_max = bucketed_metrics | map(attribute='value') | max %} {% set _line_vals = (reference_lines or []) | map(attribute='value') | list %} {% set _band_tops = (reference_bands or []) | map(attribute='to') | list %} {% set _band_bottoms = (reference_bands or []) | map(attribute='from') | list %} {# Overlay series values too — pull each bucket's `value` so the y-axis can fit a cohort-average line above the primary series. #} {% set ns_overlay = namespace(vals=[]) %} {% for ovl in (overlay_series_data or []) %} {% for b in (ovl.buckets or []) %} {% set _ = ns_overlay.vals.append(b.value) %} {% endfor %} {% endfor %} {% set _candidates = [data_max] + _line_vals + _band_tops + ns_overlay.vals %} {% set max_val = _candidates | max %} {% set max_val = max_val if max_val > 0 else 1 %} {% set min_val = (([0] + _band_bottoms) | min) %} {% set min_val = min_val if min_val < 0 else 0 %} {% set value_range = max_val - min_val %} {% set value_range = value_range if value_range > 0 else 1 %} {% set count = bucketed_metrics | length %} {# SVG 400×120, 8/8/28/8 padding (t/r/b/l). Bottom band reserved for x-axis tick labels. #} {% set w = 400 %} {% set h = 120 %} {% set pt = 8 %} {% set pr = 8 %} {% set pb = 28 %} {% set pl = 8 %} {% set plot_w = w - pl - pr %} {% set plot_h = h - pt - pb %} {# Grid: single baseline at the bottom of the plot area. #} {# Reference bands — render before the data so the line/area sit on top. Token-driven colour (#883). Use a fill-opacity that doesn't drown the data series. #} {% set _band_colours = { 'target': 'hsl(var(--primary))', 'positive': 'hsl(145, 55%, 45%)', 'warning': 'hsl(40, 90%, 55%)', 'destructive': 'hsl(var(--destructive))', 'muted': 'hsl(var(--muted-foreground))', } %} {% for band in (reference_bands or []) %} {% set band_top_y = pt + plot_h - (((band.to - min_val) / value_range) * plot_h) %} {% set band_bot_y = pt + plot_h - (((band['from'] - min_val) / value_range) * plot_h) %} {% set band_h = band_bot_y - band_top_y %} {% if band_h > 0 %} {% set band_colour = _band_colours.get(band.color, _band_colours['target']) %} {{ band.label }}: {{ band['from'] }}–{{ band.to }} {% endif %} {% endfor %} {# Reference lines — render before the data so circles + line sit above. style maps to stroke-dasharray (#883). #} {% set _line_dasharray = {'solid': '', 'dashed': '4,3', 'dotted': '1,3'} %} {% for ref in (reference_lines or []) %} {% set ref_y = pt + plot_h - (((ref.value - min_val) / value_range) * plot_h) %} {{ ref.label }}: {{ ref.value }} {% endfor %} {# Build the polyline points + area polygon. #} {% set step = plot_w / (count - 1) %} {% set line_points = [] %} {% for b in bucketed_metrics %} {% set x = pl + (loop.index0 * step) %} {% set y = pt + plot_h - (((b.value - min_val) / value_range) * plot_h) %} {% set _ = line_points.append(x | round(2) ~ ',' ~ y | round(2)) %} {% endfor %} {# Area fill: close the polyline back to the baseline. #} {% set first_x = pl %} {% set last_x = pl + plot_w %} {% set base_y = pt + plot_h %} {# The line itself. #} {# Overlay series (#883) — render BEFORE the primary line so the primary stays visually dominant. Each overlay aligns to the primary's bucket order by index (the overlay computes its own buckets from the same group_by, so order matches when they share enum/state-machine source values). Cycle through a small palette so multiple overlays stay distinguishable. #} {% set _ovl_palette = [ 'hsl(145, 55%, 45%)', 'hsl(40, 90%, 55%)', 'hsl(290, 55%, 55%)', 'hsl(210, 80%, 55%)', ] %} {% for ovl in (overlay_series_data or []) %} {% if ovl.buckets and ovl.buckets | length > 1 %} {% set ovl_colour = _ovl_palette[loop.index0 % (_ovl_palette | length)] %} {% set ovl_step = plot_w / (count - 1) %} {% set ovl_points = [] %} {% for ob in ovl.buckets[:count] %} {% set ox = pl + (loop.index0 * ovl_step) %} {% set oy = pt + plot_h - (((ob.value - min_val) / value_range) * plot_h) %} {% set _ = ovl_points.append(ox | round(2) ~ ',' ~ oy | round(2)) %} {% endfor %} {{ ovl.label }} {% endif %} {% endfor %} {# Data points + accessible tooltips. #} {% for b in bucketed_metrics %} {% set x = pl + (loop.index0 * step) %} {% set y = pt + plot_h - (((b.value - min_val) / value_range) * plot_h) %} {{ b.label }}: {{ b.value }} {% endfor %} {# X-axis labels. Render first, last, and middle to avoid collisions on wide series. For ≤5 buckets show them all. #} {% set show_every = 1 if count <= 5 else ((count / 5) | round(0, 'ceil') | int) %} {% for b in bucketed_metrics %} {% if loop.index0 == 0 or loop.last or (loop.index0 % show_every == 0) %} {% set x = pl + (loop.index0 * step) %} {{ b.label }} {% endif %} {% endfor %} {# Overlay legend (#883) — only when there's more than one polyline. Primary series + each overlay get a swatch + label. #} {% if overlay_series_data and overlay_series_data | length > 0 %}
{{ title }} {% for ovl in overlay_series_data %} {% set ovl_colour = _ovl_palette[loop.index0 % (_ovl_palette | length)] %} {{ ovl.label }} {% endfor %}
{% endif %}

{{ count }} buckets · peak {{ max_val }}{% if overlay_series_data %} · {{ overlay_series_data | length + 1 }} series{% endif %}

{% elif bucketed_metrics and bucketed_metrics | length == 1 %} {# Single-point series — fall back to a tall KPI tile rather than draw a zero-width line. #} {% set b = bucketed_metrics[0] %}
{{ b.value }}
{{ b.label }}
{% else %}

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

{% endif %}
{% endcall %}