{# Box plot region — v0.61.29 (#881). Server-rendered SVG quartile boxes per `group_by` bucket. Consumes `box_plot_stats` (computed by `_compute_box_plot_stats` in workspace_rendering.py from the already-fetched `items` — no extra DB query). Each entry carries label/n/min/q1/median/q3/max/iqr/ whisker_low/whisker_high/outliers. Layout: vertical SVG, one column per bucket. Y-axis is the value range (shared across all boxes for direct comparability). Box from Q1 to Q3, median line inside, whiskers to whisker_low/whisker_high (Tukey 1.5×IQR fences), outliers as small dots beyond the whiskers. 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 box_plot_stats and box_plot_stats | length > 0 %} {% set count = box_plot_stats | length %} {# Y-axis range = global min/max across boxes (including outliers so they fit inside the plot area). Build flat lists rather than using `default=` on min/max which Jinja's do_min/do_max in this version doesn't accept. #} {% set ns_yr = namespace(lo=[], hi=[]) %} {% for s in box_plot_stats %} {% set _ = ns_yr.lo.append(s.whisker_low) %} {% set _ = ns_yr.hi.append(s.whisker_high) %} {% for ov in (s.outliers or []) %} {% set _ = ns_yr.lo.append(ov) %} {% set _ = ns_yr.hi.append(ov) %} {% endfor %} {% endfor %} {% set y_min = ns_yr.lo | min %} {% set y_max = ns_yr.hi | max %} {% set y_range = y_max - y_min %} {% set y_range = y_range if y_range > 0 else 1 %} {# SVG geometry — wider for many boxes, capped at 460px. #} {% set w = (count * 56 + 64) if (count * 56 + 64) < 460 else 460 %} {% set h = 200 %} {% set pt = 8 %} {% set pr = 8 %} {% set pb = 32 %} {% set pl = 32 %} {% set plot_w = w - pl - pr %} {% set plot_h = h - pt - pb %} {% set col_w = plot_w / count %} {% set box_w = (col_w * 0.6) if (col_w * 0.6) < 36 else 36 %} {# Baseline + axis. #} {# Y-axis tick labels — min, max, and median of all boxes. #} {{ y_min | round(1) }} {{ y_max | round(1) }} {# Per-group box. #} {% for s in box_plot_stats %} {% set col_x = pl + (loop.index0 + 0.5) * col_w %} {% set q1_y = pt + plot_h - (((s.q1 - y_min) / y_range) * plot_h) %} {% set q3_y = pt + plot_h - (((s.q3 - y_min) / y_range) * plot_h) %} {% set median_y = pt + plot_h - (((s.median - y_min) / y_range) * plot_h) %} {% set whisker_low_y = pt + plot_h - (((s.whisker_low - y_min) / y_range) * plot_h) %} {% set whisker_high_y = pt + plot_h - (((s.whisker_high - y_min) / y_range) * plot_h) %} {# Whisker stem — vertical line from low to high. #} {# Whisker caps — short horizontal lines at the ends. #} {# Box body — Q1 to Q3, primary tint fill. #} {% set box_h = q1_y - q3_y %} {{ s.label }}: Q1 {{ s.q1 | round(1) }}, median {{ s.median | round(1) }}, Q3 {{ s.q3 | round(1) }}, n={{ s.n }} {# Median line — bold horizontal stroke inside the box. #} {# Outlier dots — small circles for points outside the fences. #} {% for ov in (s.outliers or []) %} {% set ov_y = pt + plot_h - (((ov - y_min) / y_range) * plot_h) %} {{ s.label }} outlier: {{ ov | round(1) }} {% endfor %} {# Group label below the axis. #} {{ s.label }} {% endfor %} {# Reference lines — horizontal markers (target/boundary), reused from #883 (line/area) since they map naturally to the y-axis here too. #} {% set _line_dasharray = {'solid': '', 'dashed': '4,3', 'dotted': '1,3'} %} {% for ref in (reference_lines or []) %} {% if ref.value >= y_min and ref.value <= y_max %} {% set ref_y = pt + plot_h - (((ref.value - y_min) / y_range) * plot_h) %} {{ ref.label }}: {{ ref.value }} {% endif %} {% endfor %}

{{ count }} groups · {{ box_plot_stats | sum(attribute='n') }} samples

{% else %}

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

{% endif %}
{% endcall %}