@adia-ai/web-components 0.8.43 → 0.8.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/components/avatar/avatar.class.js +12 -1
  3. package/components/button/button.d.ts +4 -0
  4. package/components/chart/chart.a2ui.json +26 -0
  5. package/components/chart/chart.class.js +1304 -222
  6. package/components/chart/chart.css +151 -1
  7. package/components/chart/chart.d.ts +6 -0
  8. package/components/chart/chart.yaml +57 -0
  9. package/components/chart-legend/chart-legend.a2ui.json +17 -2
  10. package/components/chart-legend/chart-legend.class.js +69 -4
  11. package/components/chart-legend/chart-legend.css +65 -1
  12. package/components/chart-legend/chart-legend.d.ts +4 -2
  13. package/components/chart-legend/chart-legend.examples.md +4 -4
  14. package/components/chart-legend/chart-legend.yaml +43 -3
  15. package/components/combobox/combobox.a2ui.json +3 -0
  16. package/components/combobox/combobox.class.js +9 -8
  17. package/components/combobox/combobox.css +25 -4
  18. package/components/combobox/combobox.yaml +6 -0
  19. package/components/command/command.css +17 -1
  20. package/components/context-menu/context-menu.d.ts +2 -0
  21. package/components/heatmap/heatmap.a2ui.json +5 -0
  22. package/components/heatmap/heatmap.d.ts +2 -0
  23. package/components/heatmap/heatmap.yaml +8 -0
  24. package/components/input/input.a2ui.json +2 -2
  25. package/components/input/input.class.js +12 -7
  26. package/components/input/input.yaml +6 -5
  27. package/components/integration-card/integration-card.d.ts +2 -0
  28. package/components/noodles/noodles.a2ui.json +0 -15
  29. package/components/noodles/noodles.yaml +0 -12
  30. package/components/number-format/number-format.class.js +10 -0
  31. package/components/pagination/pagination.class.js +88 -4
  32. package/components/popover/popover.d.ts +2 -0
  33. package/components/progress/progress.class.js +11 -6
  34. package/components/range/range.class.js +9 -1
  35. package/components/select/select.a2ui.json +5 -1
  36. package/components/select/select.class.js +14 -14
  37. package/components/select/select.css +14 -1
  38. package/components/select/select.yaml +7 -1
  39. package/components/stream/stream.d.ts +2 -0
  40. package/components/table/table.a2ui.json +6 -1
  41. package/components/table/table.class.js +18 -1
  42. package/components/table/table.css +31 -6
  43. package/components/table/table.d.ts +3 -1
  44. package/components/table/table.examples.md +2 -2
  45. package/components/table/table.yaml +9 -1
  46. package/components/table-toolbar/table-toolbar.a2ui.json +28 -4
  47. package/components/table-toolbar/table-toolbar.class.js +346 -11
  48. package/components/table-toolbar/table-toolbar.css +265 -0
  49. package/components/table-toolbar/table-toolbar.d.ts +5 -1
  50. package/components/table-toolbar/table-toolbar.yaml +130 -15
  51. package/components/tag/tag.class.js +13 -1
  52. package/components/text/text.a2ui.json +3 -3
  53. package/components/text/text.d.ts +5 -3
  54. package/components/text/text.yaml +3 -3
  55. package/components/textarea/textarea.class.js +9 -1
  56. package/components/tour/tour.d.ts +10 -0
  57. package/core/a11y.d.ts +22 -0
  58. package/core/a11y.js +64 -0
  59. package/core/anchor.js +9 -5
  60. package/core/element.js +1 -1
  61. package/custom-elements.json +73 -29
  62. package/dist/host.min.css +1 -1
  63. package/dist/host.sheet.js +1 -1
  64. package/dist/theme-provider.min.js +1 -1
  65. package/dist/web-components.min.css +1 -1
  66. package/dist/web-components.min.js +119 -119
  67. package/dist/web-components.sheet.js +1 -1
  68. package/package.json +1 -1
  69. package/patterns/access-requests/access-requests.examples.html +1 -1
  70. package/patterns/admin-keys-and-export/admin-keys-and-export.examples.html +7 -7
  71. package/patterns/agent-cost/agent-cost.examples.html +6 -6
  72. package/patterns/agent-memory/agent-memory.examples.html +3 -3
  73. package/patterns/agent-prompt-library/agent-prompt-library.examples.html +8 -8
  74. package/patterns/agent-tool-call/agent-tool-call.examples.html +1 -1
  75. package/patterns/approvals/approvals.examples.html +6 -6
  76. package/patterns/audit-log/audit-log.examples.html +1 -1
  77. package/patterns/bulk-action-toolbar/bulk-action-toolbar.examples.html +4 -4
  78. package/patterns/chart-in-card/chart-in-card.examples.html +114 -0
  79. package/patterns/chart-in-card/chart-in-card.examples.js +37 -0
  80. package/patterns/comments-and-collaboration/comments-and-collaboration.examples.html +5 -5
  81. package/patterns/data-tables-inline-edit-and-tree/data-tables-inline-edit-and-tree.examples.html +2 -2
  82. package/patterns/diff-review/diff-review.examples.html +6 -6
  83. package/patterns/export-flow/export-flow.examples.html +2 -2
  84. package/patterns/filter-bar/filter-bar.examples.html +2 -2
  85. package/patterns/forms-conditional-and-autocomplete/forms-conditional-and-autocomplete.examples.html +2 -2
  86. package/patterns/inline-dialog/inline-dialog.examples.html +2 -2
  87. package/patterns/marketing-engagement/marketing-engagement.examples.html +4 -4
  88. package/patterns/notifications-bell-and-digest/notifications-bell-and-digest.examples.html +1 -1
  89. package/patterns/permissions-matrix/permissions-matrix.examples.html +1 -1
  90. package/patterns/permissions-sharing/permissions-sharing.examples.html +2 -2
  91. package/patterns/profile-public-and-verification/profile-public-and-verification.examples.html +4 -4
  92. package/patterns/search-discovery/search-discovery.examples.html +2 -2
  93. package/styles/type/roles.css +29 -9
@@ -28,7 +28,15 @@
28
28
  * corner-radius — bar corner radius in px (default: null, falls back to --a-radius)
29
29
  * dots — line/multi-line marker visibility: all|none|hover|last (default: all)
30
30
  * series-emphasis — multi-line: series key that keeps full area+line treatment; others render line-only, dimmed
31
+ * ratio — ADR-0074 aspect-ratio bucket: 3:2|1:1|2:3 (default: '' — auto-snap via
32
+ * the chart's own live aspect; explicit value pins it regardless of the
33
+ * box's actual shape). Reflects the resolved bucket as data-ratio-resolved.
34
+ * y-min/y-max — pins the value-axis domain for cartesian types (bar, line, area,
35
+ * scatter, multi-line, stacked-bar, grouped-bar, composed). Unset
36
+ * (default) keeps auto-scale (floor 0, ceiling data max). N/A for
37
+ * radial/part-to-whole types (gh#1690).
31
38
  *
39
+
32
40
  * JS API:
33
41
  * .data = [{...}, ...] — array of data objects
34
42
  */
@@ -64,6 +72,15 @@ function esc(s) {
64
72
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
65
73
  }
66
74
 
75
+ /* REQ-R-003 (bar, 1:1) — "category labels rotate 0°→abbreviated": a square
76
+ container has less width per category than 3:2, so labels truncate to a
77
+ fixed character budget with an ellipsis rather than rotating (rotation
78
+ would collide with the SPEC's own 2:3 orientation-swap cell). */
79
+ function truncateLabel(label, maxChars) {
80
+ const s = String(label);
81
+ return s.length > maxChars ? s.slice(0, Math.max(1, maxChars - 1)) + '…' : s;
82
+ }
83
+
67
84
  /* Emit tooltip data-attributes for a datum shape. Consumed by the pointer
68
85
  delegate below. All fields optional — unused ones are skipped. */
69
86
  function tip({ label, value, pct, series }) {
@@ -201,6 +218,59 @@ function topRoundedBarPath(x, y, w, h, r = 0) {
201
218
  return `M${x},${y + h} V${y + rr} Q${x},${y} ${x + rr},${y} H${x + w - rr} Q${x + w},${y} ${x + w},${y + rr} V${y + h} Z`;
202
219
  }
203
220
 
221
+ /** Mirror of topRoundedBarPath for HORIZONTAL bars (REQ-R-003 bar, 2:3 —
222
+ * "Bars run horizontal, category on Y"): rounds the RIGHT (value) end,
223
+ * leaves the LEFT (category-axis) end square, so the bar still visually
224
+ * "sits flush" on its axis the same way the vertical bar does on its
225
+ * baseline. r is clamped to (h/2, w) to handle short bars safely. */
226
+ function rightRoundedBarPath(x, y, w, h, r = 0) {
227
+ if (h <= 0 || w <= 0) return '';
228
+ const rr = Math.max(0, Math.min(r, h / 2, w));
229
+ if (rr === 0) {
230
+ return `M${x},${y} H${x + w} V${y + h} H${x} Z`;
231
+ }
232
+ return `M${x},${y} H${x + w - rr} Q${x + w},${y} ${x + w},${y + rr} V${y + h - rr} Q${x + w},${y + h} ${x + w - rr},${y + h} H${x} Z`;
233
+ }
234
+
235
+ /* REQ-R-003 (bar 1:1 carry-forward from Wave A review; segments 1:1) —
236
+ "fewer visible [bars|segments] before scroll/aggregate": the matrix cell
237
+ itself names two possible affordances (scroll OR aggregate). Scroll would
238
+ add a new interactive overflow surface to a primitive that otherwise
239
+ renders one flat, non-scrolling SVG per instance; aggregate reuses
240
+ exactly the same synthetic "+N more" bucket shape either caller already
241
+ needs, so that's the reading this ships (named explicitly — a documented
242
+ choice, not a silent substitution). Caps the rendered category count at
243
+ `cap`, rolling every category past `cap - 1` into one synthetic tail
244
+ bucket. `sortByValue` selects which categories survive the cut:
245
+ segments is a proportional composition strip, so keeping the largest
246
+ contributors visible keeps the strip representative of the whole;
247
+ bar's category axis is normally ordered (e.g. months), so it keeps the
248
+ first `cap - 1` categories in their original authored order instead and
249
+ aggregates the trailing tail — reordering an ordered axis by value would
250
+ itself be a bigger, unasked-for behavior change. */
251
+ function aggregateTail(labels, vals, cap, sortByValue) {
252
+ if (vals.length <= cap) return { labels, vals };
253
+ const keepCount = Math.max(1, cap - 1);
254
+ const order = vals.map((_, i) => i);
255
+ const keepIdx = sortByValue
256
+ ? order.slice().sort((a, b) => vals[b] - vals[a]).slice(0, keepCount).sort((a, b) => a - b)
257
+ : order.slice(0, keepCount);
258
+ const keepSet = new Set(keepIdx);
259
+ const restIdx = order.filter(i => !keepSet.has(i));
260
+ const restSum = restIdx.reduce((s, i) => s + vals[i], 0);
261
+ return {
262
+ labels: keepIdx.map(i => labels[i]).concat([`+${restIdx.length} more`]),
263
+ vals: keepIdx.map(i => vals[i]).concat([restSum]),
264
+ };
265
+ }
266
+
267
+ // REQ-R-003 (bar, 1:1) / (segments, 1:1) — visible-category caps the
268
+ // aggregate-tail helper above enforces. Segments' strip reads cluttered
269
+ // past ~5 proportional slices; bar's individual columns tolerate one more
270
+ // before the same "too many to read" point (fewer per-bar chrome).
271
+ const BAR_1_1_VISIBLE_CAP = 6;
272
+ const SEGMENTS_1_1_VISIBLE_CAP = 5;
273
+
204
274
  /* ── Aspect ratios ─────────────────────────────────────────────── */
205
275
 
206
276
  // Implicit aspect ratio — a chart derives its height from width ÷ this ratio when
@@ -208,6 +278,15 @@ function topRoundedBarPath(x, y, w, h, r = 0) {
208
278
  // was retired (OD-CHART-02); parents size charts directly now.
209
279
  const ASPECTS = { std: { ratio: 4 / 3 } };
210
280
 
281
+ // ADR-0074 (Chart 2.0 ratio grammar, gh#1624 plan step 1) — the three
282
+ // ratified `ratio` buckets as width/height numbers, and the two midpoint
283
+ // boundaries the auto-snap classification (below) compares a live aspect
284
+ // against. Deliberately three discrete buckets, never interpolated — a
285
+ // live aspect strictly between two boundaries snaps to the single nearest
286
+ // named bucket (1:1 is the default bucket: neither boundary matched).
287
+ const CHART_RATIOS = { '3:2': 3 / 2, '1:1': 1, '2:3': 2 / 3 };
288
+ const RATIO_SNAP = { wide: 5 / 4, tall: 4 / 5 };
289
+
211
290
  /* ── Component ──────────────────────────────────────────────────── */
212
291
 
213
292
  export class UIChart extends UIElement {
@@ -224,6 +303,10 @@ export class UIChart extends UIElement {
224
303
  cornerRadius: { type: Number, default: null, reflect: true, attribute: 'corner-radius' },
225
304
  smooth: { type: Number, default: 0.4, reflect: true },
226
305
  size: { type: String, default: '', reflect: true },
306
+ // ADR-0074 (gh#1624 plan step 1) — grammar + snap plumbing only; no
307
+ // per-type rendering keys off this yet (later waves). See CHART_RATIOS
308
+ // / RATIO_SNAP + #dims() below for the auto-snap/pin mechanism.
309
+ ratio: { type: String, default: '', reflect: true },
227
310
  format: { type: String, default: 'abbr', reflect: true },
228
311
  loading: { type: Boolean, default: false, reflect: true },
229
312
  /* gh#561 — dot/marker visibility for line + multi-line. `all` is the
@@ -239,6 +322,14 @@ export class UIChart extends UIElement {
239
322
  (default) applies full treatment to every series — unchanged from
240
323
  pre-561 behavior. */
241
324
  seriesEmphasis: { type: String, default: '', reflect: true, attribute: 'series-emphasis' },
325
+ /* gh#1690 — pins the value axis domain for cartesian types (bar, line,
326
+ area, scatter, multi-line, stacked-bar, grouped-bar, composed).
327
+ Unset (default, null) keeps today's auto-scale (floor 0, ceiling the
328
+ data max). N/A for radial/part-to-whole types (pie, donut, radar,
329
+ radial-bar, gauge, funnel, treemap, sankey, segments) — those never
330
+ read a shared linear value axis. */
331
+ yMin: { type: Number, default: null, reflect: true, attribute: 'y-min' },
332
+ yMax: { type: Number, default: null, reflect: true, attribute: 'y-max' },
242
333
  };
243
334
 
244
335
  static template = () => null;
@@ -267,6 +358,40 @@ export class UIChart extends UIElement {
267
358
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 6;
268
359
  }
269
360
 
361
+ /** Resolves the value-axis domain [lo, hi] for a cartesian render path,
362
+ * honoring explicit `yMin`/`yMax` pins over the default auto-scale
363
+ * (floor 0, ceiling the data max) — gh#1690. `vals` is the full set of
364
+ * values the unpinned bound would otherwise be derived from (a single
365
+ * series' values, or every series' values flattened for multi-series
366
+ * types). Feeds directly into `niceScale(...this.#yBounds(vals), N)`. */
367
+ #yBounds(vals) {
368
+ const lo = this.yMin != null && Number.isFinite(this.yMin) ? this.yMin : 0;
369
+ const hi = this.yMax != null && Number.isFinite(this.yMax) ? this.yMax : Math.max(...vals);
370
+ return [lo, hi];
371
+ }
372
+
373
+ /** gh#1693 review — a datum outside a PINNED [yMin, yMax] domain must
374
+ * not escape the plot box: chart-ui's SVG has `overflow: visible`, so
375
+ * an unclamped bar/point renders off-canvas and bursts into
376
+ * surrounding page content (e.g. y-max="1", value 1.5 resolves to a
377
+ * negative y-coordinate above the chart). Clamps a computed pixel
378
+ * SPAN (bar/segment height or width) to `[0, max]` — used for bar,
379
+ * stacked-bar, grouped-bar, and composed's bar series. The displayed
380
+ * value LABEL and tooltip always show the real (unclamped) value —
381
+ * only the render geometry is clamped. */
382
+ #clampSpan(span, max) {
383
+ return Math.min(Math.max(span, 0), max);
384
+ }
385
+
386
+ /** Companion to #clampSpan for point-plotted series (line, area,
387
+ * scatter, multi-line, composed's line series) — clamps a computed
388
+ * pixel COORDINATE to the plot's own [lo, hi] pixel range so an
389
+ * out-of-domain point renders pinned to the nearest plot edge instead
390
+ * of escaping the viewBox. */
391
+ #clampCoord(coord, lo, hi) {
392
+ return Math.min(Math.max(coord, lo), hi);
393
+ }
394
+
270
395
  set data(arr) {
271
396
  // FEEDBACK-24: a non-array — typically the Chart.js `{labels, datasets}`
272
397
  // envelope — is silently coerced to [] below, producing a blank chart
@@ -418,9 +543,16 @@ export class UIChart extends UIElement {
418
543
  // use it. Otherwise derive from aspect ratio. Safe because resize sets
419
544
  // overflow:auto which prevents content from pushing height.
420
545
  const hasExplicitHeight = this.hasAttribute('resize') || this.style.height;
421
- let height = hasExplicitHeight && containerH > 40
422
- ? containerH
423
- : Math.round(width / aspect.ratio);
546
+ // ADR-0074 an explicit `ratio` PINS the box's own aspect, overriding
547
+ // whatever height the container/explicit-height path would otherwise
548
+ // give (REQ-R-002: "regardless of the container's actual aspect
549
+ // ratio"). Unset falls through to the pre-existing behavior unchanged.
550
+ const pinnedRatio = CHART_RATIOS[this.ratio];
551
+ let height = pinnedRatio
552
+ ? Math.round(width / pinnedRatio)
553
+ : hasExplicitHeight && containerH > 40
554
+ ? containerH
555
+ : Math.round(width / aspect.ratio);
424
556
 
425
557
  // Cap against --chart-max-height (CSS-resolved to px by getComputedStyle).
426
558
  // Keeps wide-viewport renders from producing absurdly tall charts while
@@ -429,6 +561,25 @@ export class UIChart extends UIElement {
429
561
  const maxH = parseFloat(getComputedStyle(this).maxHeight);
430
562
  if (isFinite(maxH) && maxH > 0 && height > maxH) height = Math.round(maxH);
431
563
 
564
+ // ADR-0074 auto-snap classification (gh#1624 plan step 1 — grammar +
565
+ // snap plumbing only, no per-type rendering keyed off this bucket yet).
566
+ // A pinned `ratio` reports its own bucket regardless of what the box's
567
+ // ACTUAL post-cap aspect ended up being (the pin is a label, per
568
+ // REQ-R-002, not merely a suggestion the cap can override); otherwise
569
+ // classify the chart's own live rendered aspect against the two
570
+ // midpoint boundaries — nearest bucket wins, never interpolated.
571
+ const liveAspect = width / Math.max(height, 1);
572
+ const resolvedRatio = pinnedRatio
573
+ ? this.ratio
574
+ : liveAspect >= RATIO_SNAP.wide
575
+ ? '3:2'
576
+ : liveAspect <= RATIO_SNAP.tall
577
+ ? '2:3'
578
+ : '1:1';
579
+ if (this.getAttribute('data-ratio-resolved') !== resolvedRatio) {
580
+ this.setAttribute('data-ratio-resolved', resolvedRatio);
581
+ }
582
+
432
583
  // Scale-aware font sizes: consistent regardless of chart dimensions
433
584
  // Use sm/md/lg or auto-detect from smallest dimension
434
585
  const minDim = Math.min(width, height);
@@ -442,12 +593,18 @@ export class UIChart extends UIElement {
442
593
  // out the Y-axis label gutter entirely — otherwise the plot area
443
594
  // collapses at small sizes.
444
595
  const noGrid = this.noGrid;
445
- const yLabelW = noGrid ? 0 : fontSize * 3.2;
596
+ // REQ-R-004 (Chart 2.0 ratio-and-surface SPEC) — at 1:1 and 2:3 the
597
+ // internal chart margins shrink further because those buckets have the
598
+ // least WIDTH budget to spare; 3:2 keeps the full gutter since width is
599
+ // abundant there. A flat 0.8 trim on the y-label gutter is enough to
600
+ // free real plot width without crowding the tick text against the axis.
601
+ const marginTrim = resolvedRatio === '3:2' ? 1 : 0.8;
602
+ const yLabelW = noGrid ? 0 : fontSize * 3.2 * marginTrim;
446
603
  const hasXLabels = !!this.x;
447
604
  const pad = {
448
605
  top: noGrid ? 2 : fontSize * 1.6,
449
606
  right: noGrid ? 2 : fontSize * 1.2,
450
- bottom: !hasXLabels || noGrid ? 2 : fontSize * 2.2,
607
+ bottom: !hasXLabels || noGrid ? 2 : fontSize * 2.2 * marginTrim,
451
608
  left: yLabelW,
452
609
  };
453
610
 
@@ -455,7 +612,7 @@ export class UIChart extends UIElement {
455
612
  const plotW = width - pad.left - pad.right;
456
613
  const barMinW = Math.max(4, plotW / n * 0.6);
457
614
 
458
- return { width, height, pad, fontSize, labelSize, valueSize, barMinW, plotW, n, sizeClass };
615
+ return { width, height, pad, fontSize, labelSize, valueSize, barMinW, plotW, n, sizeClass, ratioResolved: resolvedRatio };
459
616
  }
460
617
 
461
618
  /* ── Main render ──────────────────────────────────────────────── */
@@ -1025,15 +1182,49 @@ export class UIChart extends UIElement {
1025
1182
 
1026
1183
  /* ── Grid + axes helper ───────────────────────────────────────── */
1027
1184
 
1028
- #gridAndAxes(width, height, ticks, labels, pad, dims) {
1185
+ /**
1186
+ * opts (REQ-R-003 ratio-matrix hooks, all optional — every existing
1187
+ * caller that passes no opts renders byte-identically to pre-Wave-A):
1188
+ * thinGrid — reduce Y-gridline/tick count the same way the sm
1189
+ * sizeClass already does (line/area at 1:1/2:3).
1190
+ * vertical — draw gridlines as vertical rules at the X-label
1191
+ * stride instead of horizontal rules at each Y tick
1192
+ * (area, 2:3 — "gridlines vertical-only").
1193
+ * xTickMode:'sparse' — force exactly first/mid/last X labels instead of
1194
+ * the width-derived stride (line, 2:3 — "sparse
1195
+ * (start/end + one mid)").
1196
+ * xTickMode:'everyNth' — a distinct DENSITY-based fixed index stride
1197
+ * (label[0], label[N], label[2N], …, always the last)
1198
+ * rather than sparse's fixed 3-label set or 3:2's
1199
+ * pixel-width-fitted stride (line, 1:1 — "fewer
1200
+ * x-axis ticks (every Nth)", REQ-R-003). N scales
1201
+ * with the data length (targets ~4-5 visible labels)
1202
+ * so a short and a long series both read as "every
1203
+ * Nth", not a fixed magic number.
1204
+ * abbreviateLabels — truncate X labels to a fixed char budget (bar, 1:1).
1205
+ * pointAligned — position gridlines/labels at the exact data-point
1206
+ * X coordinate (`plotW / (labels.length - 1) * i`,
1207
+ * no half-step offset) instead of the default
1208
+ * category-BAND center (`plotW / labels.length * i
1209
+ * + step/2`). `#renderLine`'s own points use the
1210
+ * point formula (a line/area plot has no "band",
1211
+ * just N-1 gaps between N points) — every caller
1212
+ * that draws marks at continuous point positions
1213
+ * passes this so its gridlines/labels land exactly
1214
+ * under those marks; bar/scatter/segments etc. keep
1215
+ * the band-centered default, correct for their own
1216
+ * categorical geometry.
1217
+ */
1218
+ #gridAndAxes(width, height, ticks, labels, pad, dims, opts = {}) {
1029
1219
  const p = pad;
1030
1220
  const fs = dims?.fontSize || 10;
1031
1221
  const ls = dims?.labelSize || fs;
1032
1222
  let s = '';
1033
1223
 
1034
- // Reduce tick count at small sizes
1224
+ // Reduce tick count at small sizes, or when a ratio-matrix caller asks
1225
+ // for denser-bucket thinning (REQ-R-003: line/area "gridlines thinned").
1035
1226
  let displayTicks = ticks;
1036
- if (dims?.sizeClass === 'sm' && ticks.length > 4) {
1227
+ if ((dims?.sizeClass === 'sm' || opts.thinGrid) && ticks.length > 4) {
1037
1228
  displayTicks = ticks.filter((_, i) => i % 2 === 0 || i === ticks.length - 1);
1038
1229
  }
1039
1230
 
@@ -1044,9 +1235,35 @@ export class UIChart extends UIElement {
1044
1235
  if (!this.noGrid) {
1045
1236
  const tickRange = ticks[ticks.length - 1] - ticks[0];
1046
1237
  const safeRange = tickRange || 1; // Prevent division by zero
1047
- for (const t of displayTicks) {
1048
- const gy = p.top + (height - p.top - p.bottom) * (1 - (t - ticks[0]) / safeRange);
1049
- s += `<line data-chart-grid x1="${p.left}" y1="${gy}" x2="${width - p.right}" y2="${gy}"/>`;
1238
+
1239
+ if (opts.vertical) {
1240
+ /* REQ-R-003 (area, 2:3 "tall"): a horizontal rule per Y-tick reads
1241
+ as clutter once the plot is this narrow — a vertical rule per
1242
+ visible X-label instead emphasizes the tall silhouette, still
1243
+ anchored to real category geometry rather than a decorative
1244
+ substitute. */
1245
+ if (labels && labels.length) {
1246
+ const plotW = width - p.left - p.right;
1247
+ const last = labels.length - 1;
1248
+ // Point-aligned: matches #renderLine's own `plotW / (n-1) * i`
1249
+ // exactly, so a vertical rule lands ON the data point it marks
1250
+ // rather than at an unrelated category-band center (CodeRabbit
1251
+ // #1658 finding — verified: the pre-fix band-center formula and
1252
+ // #renderLine's point formula are two different coordinate
1253
+ // systems whenever labels.length > 1).
1254
+ const step = opts.pointAligned ? plotW / Math.max(last, 1) : plotW / labels.length;
1255
+ const stride = Math.max(1, Math.round(labels.length / 3));
1256
+ for (let i = 0; i < labels.length; i++) {
1257
+ if (i % stride !== 0 && i !== last) continue;
1258
+ const gx = opts.pointAligned ? p.left + step * i : p.left + step * i + step / 2;
1259
+ s += `<line data-chart-grid x1="${gx}" y1="${p.top}" x2="${gx}" y2="${height - p.bottom}"/>`;
1260
+ }
1261
+ }
1262
+ } else {
1263
+ for (const t of displayTicks) {
1264
+ const gy = p.top + (height - p.top - p.bottom) * (1 - (t - ticks[0]) / safeRange);
1265
+ s += `<line data-chart-grid x1="${p.left}" y1="${gy}" x2="${width - p.right}" y2="${gy}"/>`;
1266
+ }
1050
1267
  }
1051
1268
 
1052
1269
  /* Y-axis labels */
@@ -1055,19 +1272,46 @@ export class UIChart extends UIElement {
1055
1272
  s += `<text data-y-label x="${p.left - 4}" y="${gy + fs * 0.35}" text-anchor="end" font-size="${fs}">${this.#fmtValue(t)}</text>`;
1056
1273
  }
1057
1274
 
1058
- /* X-axis labels — stride based on label width so they never overlap */
1275
+ /* X-axis labels — stride based on label width so they never overlap,
1276
+ unless a ratio-matrix mode forces a different selection. */
1059
1277
  if (labels) {
1060
1278
  const plotW = width - p.left - p.right;
1061
1279
  const step = plotW / labels.length;
1062
- const maxChars = labels.reduce((m, l) => Math.max(m, String(l).length), 1);
1063
- const labelPx = maxChars * ls * 0.6 + ls * 0.75;
1064
- const stride = Math.max(1, Math.ceil(labelPx / step));
1065
1280
  const last = labels.length - 1;
1066
- for (let i = 0; i < labels.length; i++) {
1067
- if (i % stride !== 0 && i !== last) continue;
1068
- if (i !== last && last - i < stride) continue;
1069
- const lx = p.left + step * i + step / 2;
1070
- s += `<text data-x-label x="${lx}" y="${height - fs * 0.5}" text-anchor="middle" font-size="${ls}">${esc(labels[i])}</text>`;
1281
+ const indices = new Set();
1282
+
1283
+ if (opts.xTickMode === 'sparse' && last > 1) {
1284
+ indices.add(0);
1285
+ indices.add(Math.round(last / 2));
1286
+ indices.add(last);
1287
+ } else if (opts.xTickMode === 'everyNth' && last > 1) {
1288
+ // Every-Nth fixed index stride — mechanically distinct from BOTH
1289
+ // sparse's fixed 3-label set (start/end/mid only) and 3:2's
1290
+ // pixel-width-fitted stride below. N targets ~4-5 visible labels
1291
+ // regardless of series length.
1292
+ const n = Math.max(2, Math.ceil(labels.length / 4));
1293
+ for (let i = 0; i <= last; i += n) indices.add(i);
1294
+ indices.add(last);
1295
+ } else {
1296
+ const maxChars = labels.reduce((m, l) => Math.max(m, String(l).length), 1);
1297
+ const labelPx = maxChars * ls * 0.6 + ls * 0.75;
1298
+ const stride = Math.max(1, Math.ceil(labelPx / step));
1299
+ for (let i = 0; i < labels.length; i++) {
1300
+ if (i % stride !== 0 && i !== last) continue;
1301
+ if (i !== last && last - i < stride) continue;
1302
+ indices.add(i);
1303
+ }
1304
+ }
1305
+
1306
+ // Point-aligned mode (line/area): label X matches the exact data
1307
+ // point coordinate, same fix as the vertical-gridline branch above
1308
+ // — `step` here stays the band-width used for the width-fit stride
1309
+ // heuristic; `pointStep`/`lx` are the actual rendered position.
1310
+ const pointStep = plotW / Math.max(last, 1);
1311
+ for (const i of indices) {
1312
+ const lx = opts.pointAligned ? p.left + pointStep * i : p.left + step * i + step / 2;
1313
+ const text = esc(opts.abbreviateLabels ? truncateLabel(labels[i], 4) : labels[i]);
1314
+ s += `<text data-x-label x="${lx}" y="${height - fs * 0.5}" text-anchor="middle" font-size="${ls}">${text}</text>`;
1071
1315
  }
1072
1316
  }
1073
1317
  }
@@ -1081,27 +1325,57 @@ export class UIChart extends UIElement {
1081
1325
  const dims = this.#dims();
1082
1326
  const data = this.#data;
1083
1327
  const yKey = this.#yKeys()[0] || this.y;
1084
- const vals = data.map(v => +(v[yKey] ?? 0));
1085
- const labels = data.map(v => v[this.x] ?? '');
1086
- const ticks = niceScale(0, Math.max(...vals), 5);
1328
+ let vals = data.map(v => +(v[yKey] ?? 0));
1329
+ let labels = data.map(v => v[this.x] ?? '');
1330
+ // Kept for the average-line calc below — the average is a property of
1331
+ // the underlying data, not the display aggregation, so it stays
1332
+ // unaffected by the "+N more" tail bucket.
1333
+ const origVals = vals;
1334
+
1335
+ // REQ-R-003 (bar, 1:1) — Wave A review carry-forward: "fewer visible
1336
+ // bars before scroll/aggregate" (see the `aggregateTail` helper above
1337
+ // for the scroll-vs-aggregate reading). Runs before `ticks` is derived
1338
+ // so the scale reflects the (possibly aggregated) rendered set, not
1339
+ // the full original one.
1340
+ if (dims.ratioResolved === '1:1' && vals.length > BAR_1_1_VISIBLE_CAP) {
1341
+ ({ labels, vals } = aggregateTail(labels, vals, BAR_1_1_VISIBLE_CAP, false));
1342
+ }
1343
+
1344
+ const ticks = niceScale(...this.#yBounds(vals), 5);
1087
1345
  const maxVal = ticks[ticks.length - 1];
1346
+ const minVal = ticks[0];
1347
+
1348
+ // REQ-R-003 (bar, 2:3 "tall"): "Bars run horizontal (category on Y),
1349
+ // legend below, best for long category lists" — a tall, narrow box has
1350
+ // little width budget for a vertical bar's category labels underneath
1351
+ // it, but a full column of height for one row per category. This is a
1352
+ // genuine orientation swap, not the same layout rescaled.
1353
+ if (dims.ratioResolved === '2:3') {
1354
+ return this.#renderBarHorizontal(dims, labels, vals, ticks, maxVal);
1355
+ }
1088
1356
 
1089
1357
  const { width, height, pad } = dims;
1090
1358
  const plotH = height - pad.top - pad.bottom;
1091
1359
  const plotW = width - pad.left - pad.right;
1092
- const barW = plotW / data.length;
1360
+ const barW = plotW / labels.length;
1093
1361
  const barInner = barW * 0.6;
1094
1362
  const barGap = (barW - barInner) / 2;
1095
1363
 
1096
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1364
+ // REQ-R-003 (bar, 1:1 "square"): abbreviate category labels a square
1365
+ // has less width per category than 3:2. The synthetic "+N more" label
1366
+ // (already short) truncates harmlessly under the same budget.
1367
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
1368
+ abbreviateLabels: dims.ratioResolved === '1:1',
1369
+ });
1097
1370
 
1098
1371
  // At sm auto-hide value labels (they overlap at narrow widths).
1099
1372
  const showValues = !this.noValues && dims.sizeClass !== 'sm';
1100
- const showAverage = !this.noAverage && vals.length > 1 && dims.sizeClass !== 'sm' && !this.noGrid;
1373
+ const showAverage = !this.noAverage && origVals.length > 1 && dims.sizeClass !== 'sm' && !this.noGrid;
1101
1374
 
1102
- for (let i = 0; i < data.length; i++) {
1375
+ for (let i = 0; i < labels.length; i++) {
1103
1376
  const v = vals[i];
1104
- const barH = maxVal ? (v / maxVal) * plotH : 0;
1377
+ const rawH = (maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotH : 0;
1378
+ const barH = this.#clampSpan(rawH, plotH);
1105
1379
  const bx = pad.left + barW * i + barGap;
1106
1380
  const by = pad.top + plotH - barH;
1107
1381
 
@@ -1113,8 +1387,9 @@ export class UIChart extends UIElement {
1113
1387
  }
1114
1388
 
1115
1389
  if (showAverage) {
1116
- const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
1117
- const ay = pad.top + plotH - (maxVal ? (avg / maxVal) * plotH : 0);
1390
+ const avg = origVals.reduce((a, b) => a + b, 0) / origVals.length;
1391
+ const rawAvgH = (maxVal - minVal) ? ((avg - minVal) / (maxVal - minVal)) * plotH : 0;
1392
+ const ay = pad.top + plotH - this.#clampSpan(rawAvgH, plotH);
1118
1393
  svg += `<line data-avg x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}"/>`;
1119
1394
  svg += `<text data-avg-label x="${width - pad.right + 2}" y="${ay + 3}" text-anchor="start" font-size="${dims.valueSize}">${this.#fmtValue(avg)}</text>`;
1120
1395
  /* Wider invisible hit target so the thin dashed line is hoverable */
@@ -1124,6 +1399,71 @@ export class UIChart extends UIElement {
1124
1399
  return { svg, viewBox: `0 0 ${width} ${height}` };
1125
1400
  }
1126
1401
 
1402
+ /* REQ-R-003 (bar, 2:3) — horizontal orientation: category axis on the
1403
+ LEFT (Y), value axis along the BOTTOM (X). Deliberately a separate
1404
+ render path rather than a transposed reuse of the vertical one — the
1405
+ gutter sizing (driven by label WIDTH, not a fixed font multiple), the
1406
+ grid direction, and the bar path rounding (right end, not top end) all
1407
+ differ structurally, not just by a coordinate swap. */
1408
+ #renderBarHorizontal(dims, labels, vals, ticks, maxVal) {
1409
+ const { width, height } = dims;
1410
+ const fs = dims.fontSize;
1411
+ const ls = dims.labelSize;
1412
+ const noGrid = this.noGrid;
1413
+
1414
+ const maxChars = labels.reduce((m, l) => Math.max(m, String(l).length), 1);
1415
+ const leftGutter = noGrid ? 2 : Math.min(width * 0.42, Math.max(fs * 3, maxChars * ls * 0.55 + ls));
1416
+ const pad = {
1417
+ top: fs * 0.8,
1418
+ right: fs * 1.8,
1419
+ bottom: noGrid ? 2 : fs * 1.8,
1420
+ left: leftGutter,
1421
+ };
1422
+ const plotW = width - pad.left - pad.right;
1423
+ const plotH = height - pad.top - pad.bottom;
1424
+ const barH = plotH / labels.length;
1425
+ const barInner = barH * 0.6;
1426
+ const barGap = (barH - barInner) / 2;
1427
+
1428
+ let svg = '';
1429
+ if (!noGrid) {
1430
+ const tickRange = (ticks[ticks.length - 1] - ticks[0]) || 1;
1431
+ for (const t of ticks) {
1432
+ const gx = pad.left + plotW * ((t - ticks[0]) / tickRange);
1433
+ svg += `<line data-chart-grid x1="${gx}" y1="${pad.top}" x2="${gx}" y2="${height - pad.bottom}"/>`;
1434
+ svg += `<text data-y-label x="${gx}" y="${height - pad.bottom + fs * 1.3}" text-anchor="middle" font-size="${fs}">${this.#fmtValue(t)}</text>`;
1435
+ }
1436
+ }
1437
+
1438
+ const showValues = !this.noValues;
1439
+ const showAverage = !this.noAverage && vals.length > 1 && !noGrid;
1440
+
1441
+ const minVal = ticks[0];
1442
+ for (let i = 0; i < labels.length; i++) {
1443
+ const v = vals[i];
1444
+ const rawW = (maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotW : 0;
1445
+ const barW = this.#clampSpan(rawW, plotW);
1446
+ const by = pad.top + barH * i + barGap;
1447
+ const bx = pad.left;
1448
+
1449
+ svg += `<text data-x-label x="${pad.left - 6}" y="${by + barInner / 2 + fs * 0.35}" text-anchor="end" font-size="${ls}">${esc(labels[i])}</text>`;
1450
+ svg += `<path data-bar${tip({ label: labels[i], value: v })} d="${rightRoundedBarPath(bx, by, barW, barInner, this.#resolveRadius())}"/>`;
1451
+ if (showValues) {
1452
+ svg += `<text data-value x="${bx + barW + 4}" y="${by + barInner / 2 + fs * 0.35}" text-anchor="start" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
1453
+ }
1454
+ }
1455
+
1456
+ if (showAverage) {
1457
+ const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
1458
+ const rawAvgW = (maxVal - minVal) ? ((avg - minVal) / (maxVal - minVal)) * plotW : 0;
1459
+ const ax = pad.left + this.#clampSpan(rawAvgW, plotW);
1460
+ svg += `<line data-avg x1="${ax}" y1="${pad.top}" x2="${ax}" y2="${height - pad.bottom}"/>`;
1461
+ svg += `<line data-hit${tip({ label: 'Average', value: avg })} x1="${ax}" y1="${pad.top}" x2="${ax}" y2="${height - pad.bottom}" stroke="transparent" stroke-width="12"/>`;
1462
+ }
1463
+
1464
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1465
+ }
1466
+
1127
1467
  /* ── Line chart ───────────────────────────────────────────────── */
1128
1468
 
1129
1469
  #renderLine() {
@@ -1132,19 +1472,41 @@ export class UIChart extends UIElement {
1132
1472
  const yKey = this.#yKeys()[0] || this.y;
1133
1473
  const vals = data.map(v => +(v[yKey] ?? 0));
1134
1474
  const labels = data.map(v => v[this.x] ?? '');
1135
- const ticks = niceScale(0, Math.max(...vals), 5);
1475
+ const ticks = niceScale(...this.#yBounds(vals), 5);
1136
1476
  const maxVal = ticks[ticks.length - 1];
1477
+ const minVal = ticks[0];
1137
1478
 
1138
1479
  const { width, height, pad } = dims;
1139
1480
  const plotH = height - pad.top - pad.bottom;
1140
1481
  const plotW = width - pad.left - pad.right;
1141
1482
  const step = plotW / Math.max(data.length - 1, 1);
1142
1483
 
1143
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1484
+ // REQ-R-003 (line/area): 3:2 stays dense (full width-derived stride);
1485
+ // 1:1/2:3 thin the Y-gridlines ("gridlines thinned"). AREA specifically
1486
+ // at 2:3 switches to vertical-only gridlines ("tall... gridlines
1487
+ // vertical-only" — LINE's own 2:3 cell keeps thinned horizontal rules,
1488
+ // this is deliberately an area-only delta since #renderArea shares this
1489
+ // method with `this.type` still reading 'area'). X-ticks: 1:1 goes
1490
+ // every-Nth ("fewer x-axis ticks (every Nth)"), 2:3 goes sparse
1491
+ // (start/end + one mid, "emphasis on trend silhouette over tick
1492
+ // density") — two DISTINCT treatments, not one mode reused for both.
1493
+ const isArea = this.type === 'area';
1494
+ const ratio = dims.ratioResolved;
1495
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
1496
+ thinGrid: ratio !== '3:2',
1497
+ vertical: isArea && ratio === '2:3',
1498
+ xTickMode: ratio === '2:3' ? 'sparse' : ratio === '1:1' ? 'everyNth' : undefined,
1499
+ // CodeRabbit #1658 finding — a line/area plot has no category BAND,
1500
+ // just N-1 gaps between N continuous points (`step` below, matching
1501
+ // this same `plotW / (n-1)` formula); every gridline/label this
1502
+ // method draws must land at that exact X, not a band center.
1503
+ pointAligned: true,
1504
+ });
1144
1505
 
1145
1506
  const points = vals.map((v, i) => {
1146
1507
  const px = pad.left + step * i;
1147
- const py = pad.top + plotH - (maxVal ? (v / maxVal) * plotH : 0);
1508
+ const rawY = pad.top + plotH - ((maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotH : 0);
1509
+ const py = this.#clampCoord(rawY, pad.top, pad.top + plotH);
1148
1510
  return { x: px, y: py, v, label: labels[i] };
1149
1511
  });
1150
1512
 
@@ -1172,7 +1534,8 @@ export class UIChart extends UIElement {
1172
1534
 
1173
1535
  if (showAverage) {
1174
1536
  const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
1175
- const ay = pad.top + plotH - (maxVal ? (avg / maxVal) * plotH : 0);
1537
+ const rawAy = pad.top + plotH - ((maxVal - minVal) ? ((avg - minVal) / (maxVal - minVal)) * plotH : 0);
1538
+ const ay = this.#clampCoord(rawAy, pad.top, pad.top + plotH);
1176
1539
  svg += `<line data-avg x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}"/>`;
1177
1540
  svg += `<text data-avg-label x="${width - pad.right + 2}" y="${ay + 3}" text-anchor="start" font-size="${dims.valueSize}">${this.#fmtValue(avg)}</text>`;
1178
1541
  /* Wider invisible hit target so the thin dashed line is hoverable */
@@ -1194,10 +1557,64 @@ export class UIChart extends UIElement {
1194
1557
  // Responsive viewBox so pie respects chart-ui's max-height and
1195
1558
  // doesn't push the legend past the container bounds.
1196
1559
  const dims = this.#dims();
1197
- const { width, height } = dims;
1198
- const cx = width / 2;
1199
- const cy = height / 2;
1200
- const r = Math.max(30, Math.min(width, height) * 0.42);
1560
+ const { width, height, fontSize, valueSize } = dims;
1561
+ const ratio = dims.ratioResolved;
1562
+ // REQ-R-003 (pie, 1:1): "legend as a ring of labels around the pie" —
1563
+ // the ring needs real radial room OUTSIDE the pie's own circumference,
1564
+ // so the pie itself shrinks a bit (mirrors #renderRadar's own
1565
+ // labelPad-then-shrink technique for its external spoke labels) rather
1566
+ // than the ring overflowing the viewBox. 3:2/2:3 keep the original
1567
+ // radius unchanged — no ring at those ratios.
1568
+ //
1569
+ // CodeRabbit #1658 finding (verified real, not a false positive): a
1570
+ // flat 4px margin only reserves room for the label's ANCHOR point, not
1571
+ // its rendered extent — a near-top/bottom label's glyph height clips
1572
+ // the viewBox edge, and a near-left/right label (text-anchor start/
1573
+ // end) has its FULL RENDERED WIDTH growing outward from the anchor,
1574
+ // straight past the box edge, in any normally-sized square container,
1575
+ // not just a pathological zero-margin one. Fixed by reserving a real
1576
+ // width/height gutter.
1577
+ //
1578
+ // Wave A review carry-forward (2026-08-18): this comment used to claim
1579
+ // the gutter was "sized from the actual longest label string in THIS
1580
+ // dataset" — it wasn't. `catBudgetCeiling` below is a pure
1581
+ // WIDTH-derived safety ceiling (solved from `width`/`minR` alone, never
1582
+ // reading `labels`), so every dataset got the same worst-case
1583
+ // gutter/pie-shrink regardless of how short its real labels were —
1584
+ // correct (overflow was still mathematically excluded) but needlessly
1585
+ // conservative. Fixed for real this wave: `catBudget` now also
1586
+ // measures the dataset's OWN longest label and takes whichever is
1587
+ // smaller — a short-label dataset gets a bigger pie (a tighter gutter
1588
+ // than the ceiling alone would allow); a long-label one still clamps
1589
+ // at the same safe ceiling as before.
1590
+ const ringGap = fontSize;
1591
+ const pctSuffixLen = 8; // " (100.0%)" — worst-case suffix, generous
1592
+ const minR = 20;
1593
+ const fitHGutter = (catBudget) => (catBudget + pctSuffixLen) * valueSize * 0.62 + ringGap + 4;
1594
+ const vGutter = valueSize * 1.2 + ringGap + 4;
1595
+ // Safety ceiling: the largest category-label budget that still leaves
1596
+ // >= minR of pie radius on the width axis (the vertical gutter is
1597
+ // independent of label text length, so it never needs solving) — a
1598
+ // WIDTH-derived cap, not yet dataset-aware.
1599
+ const maxHGutterAtMinR = Math.max(0, width / 2 - minR);
1600
+ const catBudgetCeiling = Math.floor(
1601
+ (maxHGutterAtMinR - ringGap - 4) / (valueSize * 0.62)
1602
+ ) - pctSuffixLen;
1603
+ // The dataset's own longest label — the measurement the comment above
1604
+ // used to claim but never actually performed.
1605
+ const longestLabelLen = labels.reduce((m, l) => Math.max(m, String(l).length), 1);
1606
+ const catBudget = Math.max(4, Math.min(14, catBudgetCeiling, longestLabelLen));
1607
+ const hGutter = fitHGutter(catBudget);
1608
+ const r = ratio === '1:1'
1609
+ ? Math.max(minR, Math.min(width / 2 - hGutter, height / 2 - vGutter))
1610
+ : Math.max(30, Math.min(width, height) * 0.42);
1611
+ // REQ-R-003 (pie): 3:2 "slices left" — shift the pie toward the left
1612
+ // third, reserving right-side room; 2:3 "slices top" — shift up,
1613
+ // reserving room below; 1:1 stays centered — its own delta is the ring
1614
+ // of labels, not a center shift.
1615
+ const cx = ratio === '3:2' ? Math.max(r + 8, width * 0.32) : width / 2;
1616
+ const cy = ratio === '2:3' ? Math.min(height / 2, r + fontSize * 1.5) : height / 2;
1617
+ const ringLabels = ratio === '1:1';
1201
1618
 
1202
1619
  let svg = '';
1203
1620
  let angle = -Math.PI / 2;
@@ -1216,6 +1633,24 @@ export class UIChart extends UIElement {
1216
1633
  } else {
1217
1634
  svg += `<path${attrs} d="${arcPath(cx, cy, r, angle, end)}"/>`;
1218
1635
  }
1636
+
1637
+ if (ringLabels) {
1638
+ // REQ-R-003 (pie, 1:1) — one label per slice, positioned OUTSIDE
1639
+ // the ring at its mid-angle, anchored away from the circle so the
1640
+ // text grows outward rather than crowding the arc (same anchor
1641
+ // convention #renderRadar uses for its own external labels).
1642
+ const mid = angle + slice / 2;
1643
+ const lx = polarX(cx, r + ringGap, mid);
1644
+ const ly = polarY(cy, r + ringGap, mid);
1645
+ const cosMid = Math.cos(mid);
1646
+ const anchor = Math.abs(cosMid) < 0.15 ? 'middle' : cosMid > 0 ? 'start' : 'end';
1647
+ // Truncate to the budget the radius fit above was solved against —
1648
+ // keeps the rendered text within the reserved hGutter regardless
1649
+ // of how long the real category name is.
1650
+ const ringText = `${esc(truncateLabel(labels[i], catBudget))} (${pct}%)`;
1651
+ svg += `<text data-ring-label x="${lx}" y="${ly}" text-anchor="${anchor}" dominant-baseline="central" font-size="${valueSize}">${ringText}</text>`;
1652
+ }
1653
+
1219
1654
  angle = end;
1220
1655
  }
1221
1656
 
@@ -1239,11 +1674,16 @@ export class UIChart extends UIElement {
1239
1674
  const labels = data.map(d => d[this.x] ?? '');
1240
1675
 
1241
1676
  const dims = this.#dims();
1242
- const { width, height } = dims;
1243
- const cx = width / 2;
1244
- const cy = height / 2;
1245
- const outer = Math.max(30, Math.min(width, height) * 0.42);
1677
+ const { width, height, fontSize } = dims;
1678
+ const ratio = dims.ratioResolved;
1679
+ // REQ-R-003 (donut): 1:1 "Donut centered and MAXIMIZED" — a square has
1680
+ // no side legend competing for radius, so the ring grows; 3:2 "left" /
1681
+ // 2:3 "top" mirror pie's own shift for the same reason (room reserved
1682
+ // for the center-stat + legend that follows in DOM order).
1683
+ const outer = Math.max(30, Math.min(width, height) * (ratio === '1:1' ? 0.46 : 0.42));
1246
1684
  const inner = outer * 0.72;
1685
+ const cx = ratio === '3:2' ? Math.max(outer + 8, width * 0.32) : width / 2;
1686
+ const cy = ratio === '2:3' ? Math.min(height / 2, outer + fontSize * 1.5) : height / 2;
1247
1687
 
1248
1688
  let svg = '';
1249
1689
  let angle = -Math.PI / 2;
@@ -1296,12 +1736,33 @@ export class UIChart extends UIElement {
1296
1736
  // font-size and stroke widths at their intended visual scale).
1297
1737
  const dims = this.#dims();
1298
1738
  const { width, height, fontSize } = dims;
1739
+ const ratio = dims.ratioResolved;
1740
+
1741
+ // REQ-R-003 (radar) — three deliberately distinct treatments (no
1742
+ // side/below legend widget is invented here: radar's data model is
1743
+ // ONE polygon over categorical axes, not per-category series, so
1744
+ // there's no discrete legend content to show beyond the axis labels
1745
+ // already naming every spoke — same scope note as Wave A's own
1746
+ // choice not to build literal side/below legend REPOSITIONING for
1747
+ // bar/line/pie/donut despite the matrix naming "legend right/below"
1748
+ // for those rows too; flagged in Findings):
1749
+ // 3:2 — "axis labels external": generous labelPad/labelGap, labels
1750
+ // float clearly outside the polygon.
1751
+ // 1:1 — "radar fills the square, axis labels on the spokes": a much
1752
+ // smaller pad/gap so the polygon maximizes into the square and
1753
+ // labels sit tight against each spoke's own line rather than
1754
+ // floating away from it.
1755
+ // 2:3 — "radar fills top 60%, axis labels abbreviated": the radar's
1756
+ // own vertical extent is capped to the top 60% of the box
1757
+ // (leaving the bottom for whatever a page composes below it —
1758
+ // e.g. a stat readout), and axis label TEXT truncates.
1759
+ const isSquareFill = ratio === '1:1';
1760
+ const isTopFill = ratio === '2:3';
1761
+ const labelPad = isSquareFill ? fontSize * 1.8 : fontSize * 3.5;
1762
+ const fillH = isTopFill ? height * 0.6 : height;
1299
1763
  const cx = width / 2;
1300
- const cy = height / 2;
1301
- // Leave label breathing room proportional to font size so labels
1302
- // never clip at wide sizes or crowd the polygon at small sizes.
1303
- const labelPad = fontSize * 3.5;
1304
- const r = Math.max(40, Math.min(width, height) / 2 - labelPad);
1764
+ const cy = isTopFill ? fillH / 2 : height / 2;
1765
+ const r = Math.max(40, Math.min(width, fillH) / 2 - labelPad);
1305
1766
 
1306
1767
  let svg = '';
1307
1768
  const angleStep = (Math.PI * 2) / n;
@@ -1349,14 +1810,20 @@ export class UIChart extends UIElement {
1349
1810
  svg += `<circle data-hit${tip({ label: labels[i], value: vals[i] })} cx="${px}" cy="${py}" r="${hitR}" fill="transparent"/>`;
1350
1811
  }
1351
1812
 
1352
- /* Labels */
1353
- const labelGap = fontSize * 1.3;
1813
+ /* Labels — REQ-R-003 (radar, 1:1): "axis labels on the spokes" reads
1814
+ as tight to the spoke line rather than floating in open space, so
1815
+ 1:1's own gap shrinks well below 3:2/2:3's external placement. */
1816
+ const labelGap = isSquareFill ? fontSize * 0.6 : fontSize * 1.3;
1354
1817
  for (let i = 0; i < n; i++) {
1355
1818
  const a = -Math.PI / 2 + angleStep * i;
1356
1819
  const lx = cx + (r + labelGap) * Math.cos(a);
1357
1820
  const ly = cy + (r + labelGap) * Math.sin(a);
1358
1821
  const anchor = Math.abs(Math.cos(a)) < 0.1 ? 'middle' : Math.cos(a) > 0 ? 'start' : 'end';
1359
- svg += `<text data-x-label x="${lx}" y="${ly}" text-anchor="${anchor}" dominant-baseline="central" font-size="${fontSize}">${esc(labels[i])}</text>`;
1822
+ // REQ-R-003 (radar, 2:3): "axis labels abbreviated" — the top-60%
1823
+ // fill leaves less lateral room per spoke than the full-height 3:2/
1824
+ // 1:1 treatments, so labels truncate the same way bar@1:1 does.
1825
+ const text = esc(isTopFill ? truncateLabel(labels[i], 5) : labels[i]);
1826
+ svg += `<text data-x-label x="${lx}" y="${ly}" text-anchor="${anchor}" dominant-baseline="central" font-size="${fontSize}">${text}</text>`;
1360
1827
  }
1361
1828
 
1362
1829
  return { svg, viewBox: `0 0 ${width} ${height}` };
@@ -1364,6 +1831,16 @@ export class UIChart extends UIElement {
1364
1831
 
1365
1832
  /* ── Sparkline ────────────────────────────────────────────────── */
1366
1833
 
1834
+ /* REQ-R-003 (sparkline) — the ONE documented no-delta exception: "Same
1835
+ stroke; documented exception carries forward" at all three ratios.
1836
+ Deliberate, not an oversight — sparkline is intrinsically ratio-
1837
+ agnostic (no axis, no legend, full-width single stroke); REQ-P-004's
1838
+ "at least one delta" is satisfied by the HOST stat-tile's own layout
1839
+ change around it, not by this renderer. Confirmed by this method never
1840
+ reading `this.ratio` / `dims.ratioResolved` at all — it sizes purely
1841
+ from `clientWidth`/`clientHeight`, so the same data renders the same
1842
+ stroke shape regardless of which ratio bucket the container resolves
1843
+ to (chart.test.js asserts this explicitly). */
1367
1844
  #renderSparkline() {
1368
1845
  const data = this.#data;
1369
1846
  const yKey = this.#yKeys()[0] || this.y;
@@ -1432,22 +1909,38 @@ export class UIChart extends UIElement {
1432
1909
  const yKey = this.#yKeys()[0] || this.y;
1433
1910
  const vals = data.map(v => +(v[yKey] ?? 0));
1434
1911
  const labels = data.map(v => v[this.x] ?? '');
1435
- const ticks = niceScale(0, Math.max(...vals), 5);
1912
+ const ratio = dims.ratioResolved;
1913
+ // REQ-R-003 (scatter, 2:3): "y-axis emphasized (more y-ticks than x)"
1914
+ // — a deliberately DENSER Y scale than the family default (5 ticks),
1915
+ // distinct from the X-tick stride (which #gridAndAxes' own width-fit
1916
+ // stride already thins as plotW narrows — no extra opt needed there).
1917
+ const tickCount = ratio === '2:3' ? 8 : 5;
1918
+ const ticks = niceScale(...this.#yBounds(vals), tickCount);
1436
1919
  const maxVal = ticks[ticks.length - 1];
1920
+ const minVal = ticks[0];
1437
1921
 
1438
1922
  const { width, height, pad } = dims;
1439
1923
  const plotH = height - pad.top - pad.bottom;
1440
1924
  const plotW = width - pad.left - pad.right;
1441
1925
  const step = plotW / Math.max(data.length - 1, 1);
1442
1926
 
1443
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1927
+ // REQ-R-003 (scatter, 1:1): "axis labels abbreviated" — same
1928
+ // truncation treatment as bar@1:1's own square-container cell.
1929
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
1930
+ abbreviateLabels: ratio === '1:1',
1931
+ });
1444
1932
 
1445
- const dotR = dims.sizeClass === 'sm' ? 2.5 : 4;
1933
+ // REQ-R-003 (scatter, 1:1): "point size scales down slightly for
1934
+ // density" — a square container packs the same category count into
1935
+ // less width than 3:2, so points shrink a touch to stay legible.
1936
+ const baseDotR = dims.sizeClass === 'sm' ? 2.5 : 4;
1937
+ const dotR = ratio === '1:1' ? baseDotR * 0.85 : baseDotR;
1446
1938
  const hitR = Math.max(dotR * 2, 10);
1447
1939
 
1448
1940
  for (let i = 0; i < vals.length; i++) {
1449
1941
  const px = pad.left + step * i;
1450
- const py = pad.top + plotH - (maxVal ? (vals[i] / maxVal) * plotH : 0);
1942
+ const rawY = pad.top + plotH - ((maxVal - minVal) ? ((vals[i] - minVal) / (maxVal - minVal)) * plotH : 0);
1943
+ const py = this.#clampCoord(rawY, pad.top, pad.top + plotH);
1451
1944
  svg += `<circle data-dot data-scatter cx="${px}" cy="${py}" r="${dotR}"/>`;
1452
1945
  svg += `<circle data-hit${tip({ label: labels[i], value: vals[i] })} cx="${px}" cy="${py}" r="${hitR}" fill="transparent"/>`;
1453
1946
  }
@@ -1470,7 +1963,8 @@ export class UIChart extends UIElement {
1470
1963
  const maxVal = Math.max(...vals) || 1;
1471
1964
 
1472
1965
  const dims = this.#dims();
1473
- const { width, height } = dims;
1966
+ const { width, height, fontSize, valueSize } = dims;
1967
+ const ratio = dims.ratioResolved;
1474
1968
  const cx = width / 2;
1475
1969
  const cy = height / 2;
1476
1970
  const outerR = Math.max(30, Math.min(width, height) * 0.45);
@@ -1479,13 +1973,32 @@ export class UIChart extends UIElement {
1479
1973
  const bandW = (outerR - innerR) / ringCount;
1480
1974
  const gap = Math.min(2, bandW * 0.15);
1481
1975
 
1976
+ // REQ-R-003 (radial-bar) — each ring's fill starts at a rotated point
1977
+ // (the pre-existing dasharray-rotation mechanism); WHERE that start
1978
+ // point sits per ring is this row's own per-ratio delta:
1979
+ // 3:2 "bars radiate right-biased" — every ring starts its fill at
1980
+ // 3 o'clock (angle 0) and sweeps clockwise, biasing the filled
1981
+ // arcs toward the right half of the circle.
1982
+ // 2:3 "bars radiate top-biased" — every ring starts at 12 o'clock
1983
+ // (angle -π/2, the pre-Wave-B default), biasing toward the top.
1984
+ // 1:1 "full radial fan" — rather than one shared start point, EACH
1985
+ // ring gets its OWN start angle, evenly staggered around the
1986
+ // full circle (a pinwheel/fan spread) — mechanically distinct
1987
+ // from the single shared bias the other two ratios use, not
1988
+ // just a relabeling of the same geometry.
1989
+ const isFan = ratio === '1:1';
1990
+ const uniformBiasAngle = ratio === '3:2' ? 0 : -Math.PI / 2;
1991
+ const ringStartAngle = (i) => isFan
1992
+ ? -Math.PI / 2 + (Math.PI * 2 * i) / ringCount
1993
+ : uniformBiasAngle;
1994
+
1482
1995
  let svg = '';
1483
1996
 
1484
1997
  /* Backing tracks + filled arcs per datum, from inner to outer.
1485
1998
  Uses the stroke-dasharray technique: each ring is a single <circle>,
1486
- rotated so the dash starts at 12 o'clock, with dasharray sized to
1487
- (filled, remainder). Avoids the "full circle" vs "arc path" branch
1488
- that produced rendering artifacts at 100% fills. */
1999
+ rotated so the dash starts at its own `ringStartAngle`, with
2000
+ dasharray sized to (filled, remainder). Avoids the "full circle" vs
2001
+ "arc path" branch that produced rendering artifacts at 100% fills. */
1489
2002
  for (let i = 0; i < vals.length; i++) {
1490
2003
  const r0 = innerR + bandW * i + gap / 2;
1491
2004
  const r1 = innerR + bandW * (i + 1) - gap / 2;
@@ -1493,6 +2006,13 @@ export class UIChart extends UIElement {
1493
2006
  const thickness = r1 - r0;
1494
2007
  const circumference = 2 * Math.PI * mid;
1495
2008
  const filled = Math.max(0, Math.min(1, vals[i] / maxVal)) * circumference;
2009
+ const startAngle = ringStartAngle(i);
2010
+ // A circle's own stroke-dasharray trace starts at local angle 0 (3
2011
+ // o'clock); rotate() by this many degrees moves that start point to
2012
+ // `startAngle` in the same cos/sin convention `polarX`/`polarY` use
2013
+ // elsewhere in this file (rotate(-90) ⇒ start at 12 o'clock, the
2014
+ // pre-Wave-B default — verified against the untouched 2:3 case below).
2015
+ const rotateDeg = (startAngle * 180) / Math.PI;
1496
2016
 
1497
2017
  /* Backing ring — full circle, faint stroke */
1498
2018
  svg += `<circle data-radial-track cx="${cx}" cy="${cy}" r="${mid}" fill="none" stroke-width="${thickness}"/>`;
@@ -1503,17 +2023,35 @@ export class UIChart extends UIElement {
1503
2023
  const tipAttrs = tip({ label: labels[i], value: vals[i], pct });
1504
2024
 
1505
2025
  /* Filled arc — stroke-dasharray splits the circumference into
1506
- (drawn, gap). Rotated -90° around the center so the dash begins
1507
- at 12 o'clock and sweeps clockwise. stroke-linecap="butt" for
1508
- full rings (so the ends don't overlap into a wedge artifact);
1509
- "round" for partial arcs so ends read as bar caps. */
2026
+ (drawn, gap). Rotated around the center so the dash begins at
2027
+ this ring's own start angle and sweeps clockwise.
2028
+ stroke-linecap="butt" for full rings (so the ends don't overlap
2029
+ into a wedge artifact); "round" for partial arcs so ends read as
2030
+ bar caps. */
1510
2031
  const isFull = Math.abs(filled - circumference) < 0.5;
1511
2032
  const linecap = isFull ? 'butt' : 'round';
1512
2033
  const dashArray = isFull
1513
2034
  ? `${circumference} 0`
1514
2035
  : `${filled} ${circumference - filled}`;
1515
2036
 
1516
- svg += `<circle data-slice="${i % 10}"${tipAttrs} data-radial-bar cx="${cx}" cy="${cy}" r="${mid}" fill="none" stroke-width="${thickness}" stroke-linecap="${linecap}" stroke-dasharray="${dashArray}" transform="rotate(-90 ${cx} ${cy})"/>`;
2037
+ svg += `<circle data-slice="${i % 10}"${tipAttrs} data-radial-bar cx="${cx}" cy="${cy}" r="${mid}" fill="none" stroke-width="${thickness}" stroke-linecap="${linecap}" stroke-dasharray="${dashArray}" transform="rotate(${rotateDeg} ${cx} ${cy})"/>`;
2038
+ }
2039
+
2040
+ // REQ-R-003 (radial-bar, 1:1) — "legend as ring": one label per ring,
2041
+ // positioned outside outerR at that ring's own start angle — same
2042
+ // ring-of-labels mechanism #renderPie's own 1:1 cell ships, reused
2043
+ // rather than re-invented. The ordinary below-chart legend is
2044
+ // suppressed at 1:1 only (chart.css) since the ring now serves as it.
2045
+ if (isFan) {
2046
+ const ringLabelGap = fontSize;
2047
+ for (let i = 0; i < vals.length; i++) {
2048
+ const a = ringStartAngle(i);
2049
+ const lx = polarX(cx, outerR + ringLabelGap, a);
2050
+ const ly = polarY(cy, outerR + ringLabelGap, a);
2051
+ const cosA = Math.cos(a);
2052
+ const anchor = Math.abs(cosA) < 0.15 ? 'middle' : cosA > 0 ? 'start' : 'end';
2053
+ svg += `<text data-ring-label x="${lx}" y="${ly}" text-anchor="${anchor}" dominant-baseline="central" font-size="${valueSize}">${esc(labels[i])}</text>`;
2054
+ }
1517
2055
  }
1518
2056
 
1519
2057
  this.#legendData = data.map((d, i) => ({
@@ -1548,14 +2086,29 @@ export class UIChart extends UIElement {
1548
2086
  const pct = Math.max(0, Math.min(1, primary / maxVal));
1549
2087
 
1550
2088
  const dims = this.#dims();
1551
- const { width, height } = dims;
1552
-
1553
- /* Place the arc's visual center so the half-circle uses the full
1554
- width below the value label. cy sits lower so the half-arc has
1555
- room for the big center label above it. */
1556
- const cx = width / 2;
1557
- const cy = height * 0.68;
1558
- const outerR = Math.max(40, Math.min(width * 0.45, height * 0.6));
2089
+ const { width, height, fontSize } = dims;
2090
+ const ratio = dims.ratioResolved;
2091
+ const isWide = ratio === '3:2';
2092
+ const isTall = ratio === '2:3';
2093
+
2094
+ /* REQ-R-003 (gauge) three deliberately distinct placements (no
2095
+ side/below legend widget invented here either — same single-value
2096
+ rationale as radar/scatter above, since a gauge with one primary
2097
+ reading has no discrete per-category set to legend beyond the
2098
+ readout itself):
2099
+ 3:2 "gauge left, readout right" — cx shifts left (mirrors pie/
2100
+ donut's own 3:2 shift), the readout moves OUTSIDE to the
2101
+ right of the arc rather than sitting centered over it.
2102
+ 1:1 "gauge centered and maximized, readout inside the arc" — the
2103
+ biggest outerR of the three, readout stays centered in the
2104
+ arc's own hollow (the pre-Wave-B default position).
2105
+ 2:3 "gauge top, readout below" — cy shifts up (mirrors pie/
2106
+ donut's own 2:3 shift), the readout moves BELOW the arc's
2107
+ flat baseline instead of overlapping it. */
2108
+ const outerR = Math.max(40, Math.min(
2109
+ width * (ratio === '1:1' ? 0.48 : 0.45),
2110
+ height * (isTall ? 0.46 : 0.6),
2111
+ ));
1559
2112
  const innerR = outerR * 0.72;
1560
2113
  /* End-cap radius — fully-rounded pill ends matching the
1561
2114
  progress-ui convention (--progress-radius: var(--a-radius-full)).
@@ -1564,6 +2117,9 @@ export class UIChart extends UIElement {
1564
2117
  ends of the arc. */
1565
2118
  const capR = (outerR - innerR) / 2;
1566
2119
 
2120
+ const cx = isWide ? Math.max(outerR + 8, width * 0.34) : width / 2;
2121
+ const cy = isTall ? Math.min(height * 0.5, outerR + fontSize * 1.3) : height * 0.68;
2122
+
1567
2123
  let svg = '';
1568
2124
 
1569
2125
  /* Backing arc — full 180° upper semicircle, from 9 o'clock (angle π)
@@ -1580,13 +2136,27 @@ export class UIChart extends UIElement {
1580
2136
  svg += `<path data-slice="0"${tipAttrs} data-gauge-fill d="${donutArcPath(cx, cy, outerR, innerR, Math.PI, fillEnd, capR)}"/>`;
1581
2137
  }
1582
2138
 
1583
- /* Center value label */
2139
+ /* Readout label — position/anchor keyed off the ratio-specific layout
2140
+ decided above; font sizing stays keyed off outerR unconditionally. */
1584
2141
  const totalFs = Math.max(18, Math.round(outerR * 0.42));
1585
2142
  const labelFs = Math.max(10, Math.round(outerR * 0.2));
1586
- const labelY = cy - outerR * 0.15;
1587
- svg += `<text data-gauge-value x="${cx}" y="${labelY}" text-anchor="middle" dominant-baseline="central" font-size="${totalFs}">${this.#fmtValue(primary)}</text>`;
2143
+ let labelX, labelY, anchor;
2144
+ if (isWide) {
2145
+ labelX = cx + outerR + fontSize * 1.2;
2146
+ labelY = cy - outerR * 0.15;
2147
+ anchor = 'start';
2148
+ } else if (isTall) {
2149
+ labelX = cx;
2150
+ labelY = cy + totalFs * 0.7;
2151
+ anchor = 'middle';
2152
+ } else {
2153
+ labelX = cx;
2154
+ labelY = cy - outerR * 0.15;
2155
+ anchor = 'middle';
2156
+ }
2157
+ svg += `<text data-gauge-value x="${labelX}" y="${labelY}" text-anchor="${anchor}" dominant-baseline="central" font-size="${totalFs}">${this.#fmtValue(primary)}</text>`;
1588
2158
  if (maxVal !== primary) {
1589
- svg += `<text data-gauge-max x="${cx}" y="${labelY + totalFs * 0.8}" text-anchor="middle" dominant-baseline="central" font-size="${labelFs}">of ${this.#fmtValue(maxVal)}</text>`;
2159
+ svg += `<text data-gauge-max x="${labelX}" y="${labelY + totalFs * 0.8}" text-anchor="${anchor}" dominant-baseline="central" font-size="${labelFs}">of ${this.#fmtValue(maxVal)}</text>`;
1590
2160
  }
1591
2161
 
1592
2162
  return { svg, viewBox: `0 0 ${width} ${height}` };
@@ -1615,16 +2185,79 @@ export class UIChart extends UIElement {
1615
2185
  const dims = this.#dims();
1616
2186
  const { width, height } = dims;
1617
2187
  const fs = dims.fontSize;
1618
- const padX = Math.max(width * 0.18, 80); /* room for stage labels */
2188
+ const ratio = dims.ratioResolved;
2189
+
2190
+ this.#legendData = data.map((d, i) => ({
2191
+ label: labels[i], value: vals[i], pct: ((vals[i] / maxVal) * 100).toFixed(1), slot: i % 10,
2192
+ }));
2193
+
2194
+ let svg = '';
2195
+
2196
+ /* REQ-R-003 (funnel, 3:2) — "Wide stepped bars left-to-right": the
2197
+ vertical funnel's own trapezoid geometry rotated 90° — stage
2198
+ PROGRESSION reads along X instead of Y, and each value maps to a
2199
+ column's half-HEIGHT instead of a row's half-WIDTH. A wide box has
2200
+ width to spare and little height, so this orientation spends the
2201
+ abundant axis on the stage sequence and the scarce one on the value
2202
+ taper — the opposite trade the tall 2:3 cell below makes. */
2203
+ if (ratio === '3:2') {
2204
+ const padY = Math.max(height * 0.16, fs * 2.2); /* room for label/value/drop rows */
2205
+ const padX = fs * 0.8;
2206
+ const plotW = width - padX * 2;
2207
+ const plotH = height - padY * 2;
2208
+ const stageW = plotW / n;
2209
+ const gap = Math.max(2, stageW * 0.08);
2210
+ const cy = padY + plotH / 2;
2211
+
2212
+ for (let i = 0; i < n; i++) {
2213
+ const vLeft = vals[i];
2214
+ const vRight = (i < n - 1) ? vals[i + 1] : vals[i];
2215
+ const halfLeft = (vLeft / maxVal) * (plotH / 2);
2216
+ const halfRight = (vRight / maxVal) * (plotH / 2);
2217
+ const x0 = padX + stageW * i + gap / 2;
2218
+ const x1 = padX + stageW * (i + 1) - gap / 2;
2219
+ const midX = (x0 + x1) / 2;
2220
+
2221
+ const pct = ((vLeft / maxVal) * 100).toFixed(1);
2222
+ const tipAttrs = tip({ label: labels[i], value: vLeft, pct });
2223
+
2224
+ /* Trapezoid path: top-left, top-right, bottom-right, bottom-left,
2225
+ tapering vertically left-to-right instead of top-to-bottom. */
2226
+ const d = `M ${x0} ${cy - halfLeft} L ${x1} ${cy - halfRight} L ${x1} ${cy + halfRight} L ${x0} ${cy + halfLeft} Z`;
2227
+ svg += `<path data-slice="${i % 10}" data-funnel-stage${tipAttrs} d="${d}"/>`;
2228
+
2229
+ svg += `<text data-funnel-label x="${midX}" y="${padY - fs * 0.6}" text-anchor="middle" dominant-baseline="alphabetic" font-size="${fs}">${esc(labels[i])}</text>`;
2230
+ svg += `<text data-funnel-value x="${midX}" y="${height - padY + fs * 1.3}" text-anchor="middle" dominant-baseline="hanging" font-size="${fs}">${this.#fmtValue(vLeft)}</text>`;
2231
+ if (i > 0) {
2232
+ const dropPct = ((vals[i] / vals[0]) * 100).toFixed(0);
2233
+ svg += `<text data-funnel-drop x="${midX}" y="${height - padY + fs * 2.5}" text-anchor="middle" dominant-baseline="hanging" font-size="${fs * 0.85}">${dropPct}%</text>`;
2234
+ }
2235
+ }
2236
+
2237
+ return { svg, viewBox: `0 0 ${width} ${height}` };
2238
+ }
2239
+
2240
+ /* 1:1 and 2:3 keep the vertical (top-to-bottom) orientation, but move
2241
+ label chrome to free width for the funnel body — the scarce axis at
2242
+ both these buckets, per REQ-R-004:
2243
+ 1:1 — "stage labels inline": label sits INSIDE the trapezoid,
2244
+ freeing the whole left gutter (value + pct stay external
2245
+ right, unchanged from the pre-2.0 default).
2246
+ 2:3 — "stage labels + % drop external right": both stacked in the
2247
+ RIGHT gutter instead of split left/right, freeing the whole
2248
+ left edge for the widest possible body in a narrow frame —
2249
+ funnel's most natural ratio per the SPEC. */
2250
+ const isInline = ratio === '1:1';
2251
+ const isTallExternalRight = ratio === '2:3';
2252
+ const padXLeft = (isInline || isTallExternalRight) ? fs * 0.8 : Math.max(width * 0.18, 80);
2253
+ const padXRight = isTallExternalRight ? Math.max(width * 0.30, fs * 6) : Math.max(width * 0.18, 80);
1619
2254
  const padY = fs * 0.8;
1620
- const plotW = width - padX * 2;
2255
+ const plotW = width - padXLeft - padXRight;
1621
2256
  const plotH = height - padY * 2;
1622
2257
  const stageH = plotH / n;
1623
2258
  const gap = Math.max(2, stageH * 0.08);
2259
+ const cx = padXLeft + plotW / 2;
1624
2260
 
1625
- const cx = width / 2;
1626
-
1627
- let svg = '';
1628
2261
  for (let i = 0; i < n; i++) {
1629
2262
  const vTop = vals[i];
1630
2263
  const vBot = (i < n - 1) ? vals[i + 1] : vals[i];
@@ -1632,42 +2265,82 @@ export class UIChart extends UIElement {
1632
2265
  const halfBot = (vBot / maxVal) * (plotW / 2);
1633
2266
  const y0 = padY + stageH * i + gap / 2;
1634
2267
  const y1 = padY + stageH * (i + 1) - gap / 2;
2268
+ const midY = y0 + stageH / 2 - gap / 2;
1635
2269
 
1636
2270
  const pct = ((vTop / maxVal) * 100).toFixed(1);
1637
2271
  const tipAttrs = tip({ label: labels[i], value: vTop, pct });
1638
2272
 
1639
- /* Trapezoid path: top-left, top-right, bottom-right, bottom-left. */
1640
2273
  const d = `M ${cx - halfTop} ${y0} L ${cx + halfTop} ${y0} L ${cx + halfBot} ${y1} L ${cx - halfBot} ${y1} Z`;
1641
2274
  svg += `<path data-slice="${i % 10}" data-funnel-stage${tipAttrs} d="${d}"/>`;
1642
2275
 
1643
- /* Stage label — left-aligned outside the funnel */
1644
- svg += `<text data-funnel-label x="${padX - 8}" y="${y0 + stageH / 2}" text-anchor="end" dominant-baseline="central" font-size="${fs}">${esc(labels[i])}</text>`;
2276
+ if (isInline) {
2277
+ // Real-browser-probe finding (this build, not caught by Vitest
2278
+ // no layout/paint there): an unconditional inline label overflows
2279
+ // a narrow LATER stage's own trapezoid width, spilling the
2280
+ // overflow portion onto the plain page background where the
2281
+ // on-color fill token reads as invisible (near-white-on-white).
2282
+ // Truncate per-stage to that stage's own average width — the same
2283
+ // char-budget-from-available-space technique #renderPie's own
2284
+ // ring-label gutter and #renderTreemap's tile labels already use
2285
+ // — and skip the label entirely once even a 2-char abbreviation
2286
+ // wouldn't fit (an absent label reads better than an illegible,
2287
+ // spilling one).
2288
+ const avgHalfWidth = (halfTop + halfBot) / 2;
2289
+ const stageBudget = Math.floor((avgHalfWidth * 2 - fs) / (fs * 0.6));
2290
+ if (stageBudget >= 2) {
2291
+ svg += `<text data-funnel-label data-funnel-label-inline x="${cx}" y="${midY}" text-anchor="middle" dominant-baseline="central" font-size="${fs}">${esc(truncateLabel(labels[i], stageBudget))}</text>`;
2292
+ }
2293
+ } else {
2294
+ svg += `<text data-funnel-label x="${padXLeft - 8}" y="${midY}" text-anchor="end" dominant-baseline="central" font-size="${fs}">${esc(labels[i])}</text>`;
2295
+ }
1645
2296
 
1646
- /* Value + pct — right-aligned outside the funnel */
1647
- svg += `<text data-funnel-value x="${width - padX + 8}" y="${y0 + stageH / 2}" text-anchor="start" dominant-baseline="central" font-size="${fs}">${this.#fmtValue(vTop)}</text>`;
1648
- if (i > 0) {
1649
- const dropPct = ((vals[i] / vals[0]) * 100).toFixed(0);
1650
- svg += `<text data-funnel-drop x="${width - padX + 8}" y="${y0 + stageH / 2 + fs * 1.1}" text-anchor="start" dominant-baseline="central" font-size="${fs * 0.85}">${dropPct}%</text>`;
2297
+ if (isTallExternalRight) {
2298
+ svg += `<text data-funnel-label data-funnel-label-right x="${width - padXRight + 8}" y="${midY - fs * 0.6}" text-anchor="start" dominant-baseline="central" font-size="${fs}">${esc(labels[i])}</text>`;
2299
+ svg += `<text data-funnel-value x="${width - padXRight + 8}" y="${midY + fs * 0.6}" text-anchor="start" dominant-baseline="central" font-size="${fs}">${this.#fmtValue(vTop)}</text>`;
2300
+ if (i > 0) {
2301
+ const dropPct = ((vals[i] / vals[0]) * 100).toFixed(0);
2302
+ svg += `<text data-funnel-drop x="${width - padXRight + 8}" y="${midY + fs * 1.7}" text-anchor="start" dominant-baseline="central" font-size="${fs * 0.85}">${dropPct}%</text>`;
2303
+ }
2304
+ } else {
2305
+ svg += `<text data-funnel-value x="${width - padXRight + 8}" y="${midY}" text-anchor="start" dominant-baseline="central" font-size="${fs}">${this.#fmtValue(vTop)}</text>`;
2306
+ if (i > 0) {
2307
+ const dropPct = ((vals[i] / vals[0]) * 100).toFixed(0);
2308
+ svg += `<text data-funnel-drop x="${width - padXRight + 8}" y="${midY + fs * 1.1}" text-anchor="start" dominant-baseline="central" font-size="${fs * 0.85}">${dropPct}%</text>`;
2309
+ }
1651
2310
  }
1652
2311
  }
1653
2312
 
1654
- this.#legendData = data.map((d, i) => ({
1655
- label: labels[i], value: vals[i], pct: ((vals[i] / maxVal) * 100).toFixed(1), slot: i % 10,
1656
- }));
1657
-
1658
2313
  return { svg, viewBox: `0 0 ${width} ${height}` };
1659
2314
  }
1660
2315
 
1661
2316
  /* ── Treemap (hierarchical rect tiling) ────────────────────────
1662
- Flat squarified treemap: each datum is a rect whose area is
1663
- proportional to its value. Not nested — Phase 5 ships flat
1664
- rectangles; a nested variant (children arrays) is a natural
1665
- future extension.
1666
-
1667
- Uses the Bruls/Huijbregts/Van Wijk squarified algorithm adapted
1668
- for single-level data: sorts values desc, packs rows that hold
1669
- aspect ratios close to 1, alternating row direction based on
1670
- remaining container aspect. */
2317
+ Flat grid tiling: each datum is a rect whose area is proportional
2318
+ to its value. Not nested — Phase 5 ships flat rectangles; a nested
2319
+ variant (children arrays) is a natural future extension.
2320
+
2321
+ REQ-R-003 (treemap) — "wide grid, more columns than rows" (3:2) /
2322
+ "balanced grid, near-square cells" (1:1) / "tall grid, more rows
2323
+ than columns" (2:3). This wave REPLACES the prior Bruls/Huijbregts/
2324
+ Van Wijk squarified packer with a deliberate, ratio-keyed row-major
2325
+ grid a proposed amendment to the pre-Wave-C implementation, stated
2326
+ here and in this build's Findings, not silent. Measured first: the
2327
+ squarified packer's own row-membership decision is driven almost
2328
+ entirely by the DATA's value distribution, not by the container's
2329
+ aspect (a `gridBias`-scaled worst-ratio threshold was tried first and
2330
+ empirically failed on the SPEC's own non-goal case — a container
2331
+ shape alone, or a scaled variant of the same heuristic, does not
2332
+ reliably deliver "more columns than rows" vs. "more rows than
2333
+ columns"; several skewed/even/small-n datasets at the REAL width/
2334
+ height pairs `#dims()` actually produces — constant width, only
2335
+ height varies by ratio pin — inverted the intended topology). A
2336
+ row-major grid keyed directly on COLUMN COUNT is the "hand-authored,
2337
+ not a generic algorithm" reading REQ-P-004/this SPEC's own Non-goals
2338
+ section asks for: `cols` is chosen from `n` and the ratio bucket
2339
+ alone, so the topology is deterministic regardless of the dataset's
2340
+ value spread; each row's HEIGHT is still weighted by its own value
2341
+ sum (bigger-value rows get proportionally more height) and each
2342
+ tile's WIDTH by its value share of its row, so area-proportionality
2343
+ — the one property a treemap must never break — holds exactly. */
1671
2344
  #renderTreemap() {
1672
2345
  const data = this.#data;
1673
2346
  const yKey = this.#yKeys()[0] || this.y;
@@ -1679,71 +2352,35 @@ export class UIChart extends UIElement {
1679
2352
  const dims = this.#dims();
1680
2353
  const { width, height, fontSize } = dims;
1681
2354
  const total = vals.reduce((a, b) => a + b, 0) || 1;
2355
+ const ratio = dims.ratioResolved;
1682
2356
 
1683
- /* Sort indices by value desc squarified needs sorted input but
1684
- we keep original indices for color/label/hit mapping. */
2357
+ /* Sort indices by value desc (largest tiles read first, top-left)
2358
+ keep original indices for color/label/hit mapping. */
1685
2359
  const order = Array.from({ length: n }, (_, i) => i).sort((a, b) => vals[b] - vals[a]);
1686
2360
 
1687
- const rects = []; /* {i, x, y, w, h} in plot coords */
2361
+ const cols = ratio === '3:2'
2362
+ ? Math.max(2, Math.ceil(Math.sqrt(n * 2)))
2363
+ : ratio === '2:3'
2364
+ ? Math.max(1, Math.floor(Math.sqrt(n / 2)))
2365
+ : Math.max(1, Math.round(Math.sqrt(n)));
1688
2366
 
1689
- /* Scale areas so they fill the container. */
1690
- const area = width * height;
1691
- const scaled = order.map(i => (vals[i] / total) * area);
1692
-
1693
- /* Squarified packer — iterative rows. */
1694
- let x = 0, y = 0, w = width, h = height;
1695
- let row = [];
1696
- let rowStart = 0;
1697
-
1698
- const worst = (row, width) => {
1699
- if (row.length === 0) return Infinity;
1700
- const sum = row.reduce((a, b) => a + b, 0);
1701
- const max = Math.max(...row);
1702
- const min = Math.min(...row);
1703
- const wsq = width * width;
1704
- const ssq = sum * sum;
1705
- return Math.max((wsq * max) / ssq, ssq / (wsq * min));
1706
- };
2367
+ const rows = [];
2368
+ for (let i = 0; i < order.length; i += cols) rows.push(order.slice(i, i + cols));
2369
+ const rowSums = rows.map(r => r.reduce((s, i) => s + vals[i], 0));
2370
+ const rowSumTotal = rowSums.reduce((a, b) => a + b, 0) || 1;
1707
2371
 
1708
- const layoutRow = (row, rowStart, x, y, w, h) => {
1709
- const sum = row.reduce((a, b) => a + b, 0);
1710
- const horizontal = w >= h;
1711
- const rowH = horizontal ? sum / w : h;
1712
- const rowW = horizontal ? w : sum / h;
1713
-
1714
- let offset = 0;
1715
- for (let k = 0; k < row.length; k++) {
1716
- const frac = row[k] / sum;
1717
- const idx = order[rowStart + k];
1718
- if (horizontal) {
1719
- const segW = frac * w;
1720
- rects.push({ i: idx, x: x + offset, y, w: segW, h: rowH });
1721
- offset += segW;
1722
- } else {
1723
- const segH = frac * h;
1724
- rects.push({ i: idx, x, y: y + offset, w: rowW, h: segH });
1725
- offset += segH;
1726
- }
1727
- }
1728
- if (horizontal) return { x, y: y + rowH, w, h: h - rowH };
1729
- return { x: x + rowW, y, w: w - rowW, h };
1730
- };
1731
-
1732
- let remaining = scaled.slice();
1733
- while (remaining.length > 0) {
1734
- row = [remaining[0]];
1735
- rowStart = scaled.length - remaining.length;
1736
- const shortSide = Math.min(w, h);
1737
- let i = 1;
1738
- while (i < remaining.length) {
1739
- const next = row.concat(remaining[i]);
1740
- if (worst(next, shortSide) > worst(row, shortSide)) break;
1741
- row = next;
1742
- i++;
2372
+ const rects = []; /* {i, x, y, w, h} in plot coords */
2373
+ let y = 0;
2374
+ for (let ri = 0; ri < rows.length; ri++) {
2375
+ const rowH = (rowSums[ri] / rowSumTotal) * height;
2376
+ const rowValTotal = rowSums[ri] || 1;
2377
+ let x = 0;
2378
+ for (const idx of rows[ri]) {
2379
+ const w = (vals[idx] / rowValTotal) * width;
2380
+ rects.push({ i: idx, x, y, w, h: rowH });
2381
+ x += w;
1743
2382
  }
1744
- const newRect = layoutRow(row, rowStart, x, y, w, h);
1745
- x = newRect.x; y = newRect.y; w = newRect.w; h = newRect.h;
1746
- remaining = remaining.slice(row.length);
2383
+ y += rowH;
1747
2384
  }
1748
2385
 
1749
2386
  /* Emit rects + labels. */
@@ -1764,13 +2401,19 @@ export class UIChart extends UIElement {
1764
2401
  const canShowAny = r.w > fontSize * 4;
1765
2402
  const tall = canShowAny && r.h > fontSize * 2.5;
1766
2403
  const short = canShowAny && !tall && r.h > fontSize * 1.2;
2404
+ // REQ-R-003 (treemap, 2:3) — "label truncation more aggressive": the
2405
+ // `cols` choice above already narrows every tile's own width budget
2406
+ // at this bucket (fewest columns of the three), so the char budget
2407
+ // shrinks to match rather than relying on the generic overflow the
2408
+ // tile-size gate above already guards against.
2409
+ const labelChars = ratio === '2:3' ? 8 : 20;
1767
2410
 
1768
2411
  if (tall) {
1769
- svg += `<text data-treemap-label x="${labelX}" y="${r.y + fontSize + 4}" font-size="${fontSize}" dominant-baseline="hanging">${esc(labels[r.i])}</text>`;
2412
+ svg += `<text data-treemap-label x="${labelX}" y="${r.y + fontSize + 4}" font-size="${fontSize}" dominant-baseline="hanging">${esc(truncateLabel(labels[r.i], labelChars))}</text>`;
1770
2413
  svg += `<text data-treemap-value x="${labelX}" y="${r.y + fontSize * 2 + 6}" font-size="${fontSize * 0.9}" dominant-baseline="hanging">${this.#fmtValue(vals[r.i])}</text>`;
1771
2414
  } else if (short) {
1772
2415
  const cy = r.y + r.h / 2;
1773
- svg += `<text data-treemap-label x="${labelX}" y="${cy}" font-size="${fontSize}" dominant-baseline="central">${esc(labels[r.i])}</text>`;
2416
+ svg += `<text data-treemap-label x="${labelX}" y="${cy}" font-size="${fontSize}" dominant-baseline="central">${esc(truncateLabel(labels[r.i], labelChars))}</text>`;
1774
2417
  }
1775
2418
  }
1776
2419
 
@@ -1807,19 +2450,106 @@ export class UIChart extends UIElement {
1807
2450
 
1808
2451
  const dims = this.#dims();
1809
2452
  const { width, height, fontSize } = dims;
1810
- const nodeW = Math.max(8, width * 0.03);
1811
- const colPad = nodeW + fontSize * 5;
2453
+ const ratio = dims.ratioResolved;
2454
+ const sources = [...sourceSet.values()];
2455
+ const targets = [...targetSet.values()];
2456
+ const sourceTotal = sources.reduce((a, s) => a + s.flow, 0) || 1;
2457
+ const targetTotal = targets.reduce((a, t) => a + t.flow, 0) || 1;
1812
2458
 
1813
- const sourceTotal = [...sourceSet.values()].reduce((a, s) => a + s.flow, 0) || 1;
1814
- const targetTotal = [...targetSet.values()].reduce((a, t) => a + t.flow, 0) || 1;
2459
+ let svg = '';
1815
2460
 
1816
- const nodeGap = fontSize * 0.6;
2461
+ /* REQ-R-003 (sankey, 2:3) "Flow forced top-to-bottom, nodes stack
2462
+ vertically" — the SPEC's own "hardest cell" and the only one of the
2463
+ three that isn't a gutter/label tweak: sources become a horizontal
2464
+ row along the TOP, targets a horizontal row along the BOTTOM, and
2465
+ every link becomes a VERTICAL bezier band instead of a horizontal
2466
+ one. Node "flow" now maps to a WIDTH instead of a HEIGHT, and labels
2467
+ sit above the top row / below the bottom row instead of left/right
2468
+ of a column — the same column↔row transpose #renderFunnel's own
2469
+ 3:2 cell performs, applied to the opposite type of node layout. */
2470
+ if (ratio === '2:3') {
2471
+ const nodeH = Math.max(8, height * 0.05);
2472
+ const rowPad = nodeH + fontSize * 1.8;
2473
+ // CodeRabbit (PR #1662) — a many-node dataset can make
2474
+ // nodeGap * (count - 1) exceed width, driving sourceTotalW/
2475
+ // targetTotalW negative: every <rect data-sankey-node> gets a
2476
+ // negative width and the vertical link bands invert. Cap the
2477
+ // total gap budget at half the available width (regardless of
2478
+ // node count) so the remaining width for node bodies never
2479
+ // collapses to zero or below.
2480
+ const maxGapBudget = width * 0.5;
2481
+ const nodeGap = Math.min(
2482
+ fontSize * 0.8,
2483
+ maxGapBudget / Math.max(1, Math.max(sources.length, targets.length) - 1),
2484
+ );
2485
+ const sourceTotalW = Math.max(1, width - nodeGap * Math.max(0, sources.length - 1));
2486
+ const targetTotalW = Math.max(1, width - nodeGap * Math.max(0, targets.length - 1));
2487
+
2488
+ let x = 0;
2489
+ for (const s of sources) {
2490
+ const w = (s.flow / sourceTotal) * sourceTotalW;
2491
+ s.x0 = x; s.x1 = x + w; s.cursor = x;
2492
+ x += w + nodeGap;
2493
+ }
2494
+ x = 0;
2495
+ for (const t of targets) {
2496
+ const w = (t.flow / targetTotal) * targetTotalW;
2497
+ t.x0 = x; t.x1 = x + w; t.cursor = x;
2498
+ x += w + nodeGap;
2499
+ }
1817
2500
 
1818
- /* Assign y0/y1 to each source node, stacked top-to-bottom. */
1819
- const sources = [...sourceSet.values()];
1820
- const targets = [...targetSet.values()];
1821
- const sourceTotalH = height - nodeGap * (sources.length - 1);
1822
- const targetTotalH = height - nodeGap * (targets.length - 1);
2501
+ const nodeRx = Math.min(this.#resolveRadius(), nodeH / 2);
2502
+
2503
+ sources.forEach((s, i) => {
2504
+ const tipAttrs = tip({ label: s.name, value: s.flow });
2505
+ svg += `<rect data-sankey-node data-slice="${i % 10}"${tipAttrs} x="${s.x0}" y="${rowPad - nodeH}" width="${s.x1 - s.x0}" height="${nodeH}" rx="${nodeRx}"/>`;
2506
+ svg += `<text data-sankey-label x="${(s.x0 + s.x1) / 2}" y="${rowPad - nodeH - 6}" text-anchor="middle" dominant-baseline="alphabetic" font-size="${fontSize}">${esc(truncateLabel(s.name, 10))}</text>`;
2507
+ });
2508
+ targets.forEach((t, i) => {
2509
+ const tipAttrs = tip({ label: t.name, value: t.flow });
2510
+ svg += `<rect data-sankey-node data-slice="${(sources.length + i) % 10}"${tipAttrs} x="${t.x0}" y="${height - rowPad}" width="${t.x1 - t.x0}" height="${nodeH}" rx="${nodeRx}"/>`;
2511
+ svg += `<text data-sankey-label x="${(t.x0 + t.x1) / 2}" y="${height - rowPad + nodeH + fontSize + 2}" text-anchor="middle" dominant-baseline="alphabetic" font-size="${fontSize}">${esc(truncateLabel(t.name, 10))}</text>`;
2512
+ });
2513
+
2514
+ for (const link of data) {
2515
+ const s = sourceSet.get(link.source ?? link.from ?? '');
2516
+ const t = targetSet.get(link.target ?? link.to ?? '');
2517
+ if (!s || !t) continue;
2518
+ const v = +(link.value ?? link.v ?? 0);
2519
+ if (v <= 0) continue;
2520
+
2521
+ const sW = (v / sourceTotal) * sourceTotalW;
2522
+ const tW = (v / targetTotal) * targetTotalW;
2523
+ const sLeft = s.cursor; const sRight = sLeft + sW;
2524
+ const tLeft = t.cursor; const tRight = tLeft + tW;
2525
+ s.cursor += sW;
2526
+ t.cursor += tW;
2527
+
2528
+ const y0 = rowPad;
2529
+ const y1 = height - rowPad;
2530
+ const my = (y0 + y1) / 2;
2531
+ const tipAttrs = tip({ label: `${s.name} → ${t.name}`, value: v });
2532
+
2533
+ const path = `M ${sLeft} ${y0} C ${sLeft} ${my}, ${tLeft} ${my}, ${tLeft} ${y1} L ${tRight} ${y1} C ${tRight} ${my}, ${sRight} ${my}, ${sRight} ${y0} Z`;
2534
+ svg += `<path data-sankey-link${tipAttrs} d="${path}"/>`;
2535
+ }
2536
+
2537
+ return { svg, viewBox: `0 0 ${width} ${height}` };
2538
+ }
2539
+
2540
+ /* 3:2 (baseline — "wide flow left-to-right, node labels external",
2541
+ unchanged from the pre-2.0 default) and 1:1 ("flow compressed, node
2542
+ labels abbreviated/on-hover") share the horizontal column layout;
2543
+ 1:1 shrinks the label gutter and truncates node names (full name
2544
+ stays available via the node's own hover tooltip either way). */
2545
+ const isCompressed = ratio === '1:1';
2546
+ const nodeW = Math.max(8, width * 0.03);
2547
+ const colPad = nodeW + fontSize * (isCompressed ? 2.2 : 5);
2548
+ const labelChars = isCompressed ? 6 : 24;
2549
+
2550
+ const nodeGap = fontSize * 0.6;
2551
+ const sourceTotalH = height - nodeGap * Math.max(0, sources.length - 1);
2552
+ const targetTotalH = height - nodeGap * Math.max(0, targets.length - 1);
1823
2553
 
1824
2554
  let y = 0;
1825
2555
  for (const s of sources) {
@@ -1838,20 +2568,20 @@ export class UIChart extends UIElement {
1838
2568
  y += h + nodeGap;
1839
2569
  }
1840
2570
 
1841
- let svg = '';
1842
-
1843
2571
  const nodeRx = Math.min(this.#resolveRadius(), nodeW / 2);
1844
2572
 
1845
2573
  /* Source nodes — left column */
1846
2574
  sources.forEach((s, i) => {
1847
- svg += `<rect data-sankey-node data-slice="${i % 10}" x="${colPad - nodeW}" y="${s.y0}" width="${nodeW}" height="${s.y1 - s.y0}" rx="${nodeRx}"/>`;
1848
- svg += `<text data-sankey-label x="${colPad - nodeW - 6}" y="${(s.y0 + s.y1) / 2}" text-anchor="end" dominant-baseline="central" font-size="${fontSize}">${esc(s.name)}</text>`;
2575
+ const tipAttrs = tip({ label: s.name, value: s.flow });
2576
+ svg += `<rect data-sankey-node data-slice="${i % 10}"${tipAttrs} x="${colPad - nodeW}" y="${s.y0}" width="${nodeW}" height="${s.y1 - s.y0}" rx="${nodeRx}"/>`;
2577
+ svg += `<text data-sankey-label x="${colPad - nodeW - 6}" y="${(s.y0 + s.y1) / 2}" text-anchor="end" dominant-baseline="central" font-size="${fontSize}">${esc(truncateLabel(s.name, labelChars))}</text>`;
1849
2578
  });
1850
2579
 
1851
2580
  /* Target nodes — right column */
1852
2581
  targets.forEach((t, i) => {
1853
- svg += `<rect data-sankey-node data-slice="${(sources.length + i) % 10}" x="${width - colPad}" y="${t.y0}" width="${nodeW}" height="${t.y1 - t.y0}" rx="${nodeRx}"/>`;
1854
- svg += `<text data-sankey-label x="${width - colPad + nodeW + 6}" y="${(t.y0 + t.y1) / 2}" text-anchor="start" dominant-baseline="central" font-size="${fontSize}">${esc(t.name)}</text>`;
2582
+ const tipAttrs = tip({ label: t.name, value: t.flow });
2583
+ svg += `<rect data-sankey-node data-slice="${(sources.length + i) % 10}"${tipAttrs} x="${width - colPad}" y="${t.y0}" width="${nodeW}" height="${t.y1 - t.y0}" rx="${nodeRx}"/>`;
2584
+ svg += `<text data-sankey-label x="${width - colPad + nodeW + 6}" y="${(t.y0 + t.y1) / 2}" text-anchor="start" dominant-baseline="central" font-size="${fontSize}">${esc(truncateLabel(t.name, labelChars))}</text>`;
1855
2585
  });
1856
2586
 
1857
2587
  /* Links — bezier bands */
@@ -1893,6 +2623,7 @@ export class UIChart extends UIElement {
1893
2623
  #renderComposed() {
1894
2624
  const dims = this.#dims();
1895
2625
  const data = this.#data;
2626
+ const ratio = dims.ratioResolved;
1896
2627
  const keys = this.#yKeys();
1897
2628
  const barKey = keys[0] || 'bar';
1898
2629
  const lineKey = keys[1] || 'line';
@@ -1900,8 +2631,9 @@ export class UIChart extends UIElement {
1900
2631
  const barVals = data.map(d => +(d[barKey] ?? 0));
1901
2632
  const lineVals = data.map(d => +(d[lineKey] ?? 0));
1902
2633
  const allVals = [...barVals, ...lineVals];
1903
- const ticks = niceScale(0, Math.max(...allVals), 5);
2634
+ const ticks = niceScale(...this.#yBounds(allVals), 5);
1904
2635
  const maxVal = ticks[ticks.length - 1];
2636
+ const minVal = ticks[0];
1905
2637
 
1906
2638
  const { width, height, pad } = dims;
1907
2639
  const plotH = height - pad.top - pad.bottom;
@@ -1909,39 +2641,95 @@ export class UIChart extends UIElement {
1909
2641
  const barW = plotW / data.length;
1910
2642
  const barInner = barW * 0.6;
1911
2643
  const barGap = (barW - barInner) / 2;
1912
- const step = plotW / Math.max(data.length - 1, 1);
1913
2644
 
1914
2645
  let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1915
2646
 
1916
- /* Bar series (slot 0) */
2647
+ /* Bar series (slot 0) — "primary series only fully rendered" (2:3) is
2648
+ this same unconditional render at every ratio; the primary axis
2649
+ treatment never changes, only the SECONDARY series below does. */
1917
2650
  if (!this.#isSeriesHidden(barKey)) {
1918
2651
  for (let i = 0; i < data.length; i++) {
1919
2652
  const v = barVals[i];
1920
- const barH = maxVal ? (v / maxVal) * plotH : 0;
2653
+ const rawH = (maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotH : 0;
2654
+ const barH = this.#clampSpan(rawH, plotH);
1921
2655
  const bx = pad.left + barW * i + barGap;
1922
2656
  const by = pad.top + plotH - barH;
1923
2657
  svg += `<path${this.#seriesFill(0, barKey)}${tip({ label: labels[i], value: v, series: barKey })} d="${topRoundedBarPath(bx, by, barInner, barH, this.#resolveRadius())}"/>`;
1924
2658
  }
1925
2659
  }
1926
2660
 
1927
- /* Line series (slot 1) — anchored at bar center X positions */
1928
2661
  if (!this.#isSeriesHidden(lineKey)) {
1929
- const points = lineVals.map((v, i) => ({
1930
- x: pad.left + barW * i + barW / 2,
1931
- y: pad.top + plotH - (maxVal ? (v / maxVal) * plotH : 0),
1932
- v, label: labels[i],
1933
- }));
1934
- const t = Math.max(0, Math.min(1, this.smooth));
1935
- svg += `<path data-line${this.#seriesStroke(1, lineKey)} d="${smoothPath(points, t)}"/>`;
1936
- for (const p of points) {
1937
- svg += `<circle data-dot${this.#seriesFill(1, lineKey)} cx="${p.x}" cy="${p.y}" r="3"/>`;
1938
- svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: lineKey })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
2662
+ if (ratio === '2:3') {
2663
+ /* REQ-R-003 (composed, 2:3) "secondary as sparkline inset": a
2664
+ tall frame has too little WIDTH to overlay a full-scale line
2665
+ across the same cramped bar plot without the two competing for
2666
+ the same axis, so the line renders as a small self-scaled
2667
+ sparkline (own min/max, no shared axis) tucked into the plot's
2668
+ top-right corner instead the same "inset trend" reading a
2669
+ stat-tile's own corner sparkline uses elsewhere in this file. */
2670
+ const insetW = plotW * 0.34;
2671
+ const insetH = Math.min(plotH * 0.26, insetW * 0.6);
2672
+ const insetX = pad.left + plotW - insetW - 2;
2673
+ const insetY = pad.top + 2;
2674
+ const lo = Math.min(...lineVals);
2675
+ const hi = Math.max(...lineVals);
2676
+ const span = hi - lo || 1;
2677
+ const insetPoints = lineVals.map((v, i) => ({
2678
+ x: insetX + (lineVals.length > 1 ? (i / (lineVals.length - 1)) * insetW : insetW / 2),
2679
+ y: insetY + insetH - ((v - lo) / span) * insetH,
2680
+ v, label: labels[i],
2681
+ }));
2682
+ const t = Math.max(0, Math.min(1, this.smooth));
2683
+ svg += `<rect data-composed-inset-bg x="${insetX - 4}" y="${insetY - 4}" width="${insetW + 8}" height="${insetH + 8}" rx="${this.#resolveRadius()}"/>`;
2684
+ svg += `<path data-composed-inset-line${this.#seriesStroke(1, lineKey)} d="${smoothPath(insetPoints, t)}"/>`;
2685
+ for (const p of insetPoints) {
2686
+ svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: lineKey })} cx="${p.x}" cy="${p.y}" r="6" fill="transparent"/>`;
2687
+ }
2688
+ svg += `<text data-composed-inset-label x="${insetX}" y="${insetY - 6}" text-anchor="start" font-size="${dims.valueSize}">${esc(lineKey)}</text>`;
2689
+ } else {
2690
+ /* 3:2 baseline (unchanged) and 1:1 share the full-axis overlay;
2691
+ 1:1 additionally de-emphasizes the secondary series. */
2692
+ const points = lineVals.map((v, i) => {
2693
+ const rawY = pad.top + plotH - ((maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotH : 0);
2694
+ return {
2695
+ x: pad.left + barW * i + barW / 2,
2696
+ y: this.#clampCoord(rawY, pad.top, pad.top + plotH),
2697
+ v, label: labels[i],
2698
+ };
2699
+ });
2700
+ const t = Math.max(0, Math.min(1, this.smooth));
2701
+ /* REQ-R-003/REQ-R-007 (composed, 1:1) — "secondary series
2702
+ de-emphasized via -container-low tint": a square view crams
2703
+ both series onto the same cramped axis with nothing to visually
2704
+ subordinate the secondary reading, so its stroke/dots switch
2705
+ from the ordinary categorical palette to the muted neutral role
2706
+ token ADR-0073 already uses for de-emphasis elsewhere — a COLOR
2707
+ swap, never an opacity hack (REQ-R-007 explicitly rules that
2708
+ out as a "chart-invented" substitute for the ratified tokens). */
2709
+ const deemphasized = ratio === '1:1';
2710
+ const lineAttrs = deemphasized
2711
+ ? ` data-slice="1" data-deemphasized-tint style="stroke: var(--md-sys-color-neutral-on-surface-variant)"`
2712
+ : this.#seriesStroke(1, lineKey);
2713
+ const dotAttrs = deemphasized
2714
+ ? ` data-slice="1" data-deemphasized-tint style="fill: var(--md-sys-color-neutral-on-surface-variant)"`
2715
+ : this.#seriesFill(1, lineKey);
2716
+ svg += `<path data-line${lineAttrs} d="${smoothPath(points, t)}"/>`;
2717
+ for (const p of points) {
2718
+ svg += `<circle data-dot${dotAttrs} cx="${p.x}" cy="${p.y}" r="3"/>`;
2719
+ svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: lineKey })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
2720
+ }
1939
2721
  }
1940
2722
  }
1941
2723
 
1942
2724
  this.#legendData = [
1943
2725
  { label: barKey, key: barKey, slot: 0 },
1944
- { label: lineKey, key: lineKey, slot: 1 },
2726
+ // CodeRabbit (PR #1662) the 1:1 de-emphasis above swaps the
2727
+ // drawn series to a muted neutral token, but the legend payload
2728
+ // still reported plain slot-1 palette data, so #buildLegend (and
2729
+ // any external chart-legend-ui[for] mirroring it) painted a
2730
+ // legend dot that didn't match what was actually drawn. Signal
2731
+ // the state so consumers can mirror it.
2732
+ { label: lineKey, key: lineKey, slot: 1, deemphasized: ratio === '1:1' },
1945
2733
  ];
1946
2734
 
1947
2735
  return { svg, viewBox: `0 0 ${width} ${height}` };
@@ -1954,9 +2742,43 @@ export class UIChart extends UIElement {
1954
2742
  #renderSegments() {
1955
2743
  const data = this.#data;
1956
2744
  const yKey = this.#yKeys()[0] || this.y;
1957
- const vals = data.map(d => +(d[yKey] ?? 0));
2745
+ let vals = data.map(d => +(d[yKey] ?? 0));
2746
+ let labels = data.map(d => d[this.x] ?? '');
1958
2747
  const total = vals.reduce((a, b) => a + b, 0) || 1;
1959
- const labels = data.map(d => d[this.x] ?? '');
2748
+
2749
+ // #dims() also classifies + reflects `data-ratio-resolved` (its own
2750
+ // side effect) — needed here since this renderer otherwise sizes
2751
+ // itself from clientWidth/a fixed strip height, not #dims()'s own
2752
+ // width/height/pad numbers.
2753
+ const dims = this.#dims();
2754
+ const ratio = dims.ratioResolved;
2755
+
2756
+ // REQ-R-003 (segments, 1:1) — "fewer segments visible before '+N
2757
+ // more'": the same aggregate-tail mechanism as bar@1:1's own Wave A
2758
+ // carry-forward cell, sorted by VALUE (a proportional strip reads
2759
+ // best keeping its biggest contributors visible, unlike bar's ordered
2760
+ // category axis — see `aggregateTail`'s own doc comment above).
2761
+ if (ratio === '1:1' && vals.length > SEGMENTS_1_1_VISIBLE_CAP) {
2762
+ ({ labels, vals } = aggregateTail(labels, vals, SEGMENTS_1_1_VISIBLE_CAP, true));
2763
+ }
2764
+
2765
+ // Legend data (same shape pie/donut use) — built from the (possibly
2766
+ // aggregated) visible set so "legend below"/"legend stacked" always
2767
+ // mirrors exactly what's drawn, never a longer list than the strip
2768
+ // itself shows.
2769
+ this.#legendData = labels.map((label, i) => ({
2770
+ label,
2771
+ value: vals[i],
2772
+ pct: ((vals[i] / total) * 100).toFixed(1),
2773
+ slot: i % 10,
2774
+ }));
2775
+
2776
+ // REQ-R-003 (segments, 2:3) — "tall vertical stacked bars": the same
2777
+ // orientation-swap rationale as bar's own 2:3 cell, applied to this
2778
+ // type's single composite strip instead of N independent bars.
2779
+ if (ratio === '2:3') {
2780
+ return this.#renderSegmentsVertical(dims, labels, vals, total);
2781
+ }
1960
2782
 
1961
2783
  // ViewBox matches actual render width so there's no horizontal
1962
2784
  // stretching — keeps rounded corners from flattening into ellipses
@@ -2010,17 +2832,63 @@ export class UIChart extends UIElement {
2010
2832
  }
2011
2833
  }
2012
2834
 
2013
- // Legend data (same shape pie/donut use).
2014
- this.#legendData = data.map((d, i) => ({
2015
- label: labels[i],
2016
- value: vals[i],
2017
- pct: ((vals[i] / total) * 100).toFixed(1),
2018
- slot: i % 10,
2019
- }));
2020
-
2021
2835
  return { svg, viewBox: `0 0 ${w} ${h}` };
2022
2836
  }
2023
2837
 
2838
+ /* REQ-R-003 (segments, 2:3) — vertical mirror of the horizontal strip
2839
+ above: a fixed-thickness column (same magic-number role `h = 24` plays
2840
+ for the horizontal variant, just on the width axis instead) stacked
2841
+ top-to-bottom, sized from #dims()'s own ratio-aware height so the
2842
+ column genuinely grows tall rather than staying the horizontal
2843
+ variant's flat strip. */
2844
+ #renderSegmentsVertical(dims, labels, vals, total) {
2845
+ const w = 24;
2846
+ const height = Math.max(60, dims.height);
2847
+ const r = Math.min(this.#resolveRadius(), w / 2);
2848
+ const cs = getComputedStyle(this);
2849
+ const gap = Math.max(0, parseFloat(cs.getPropertyValue('--chart-segments-gap')) || 2);
2850
+
2851
+ const heights = [];
2852
+ for (let i = 0; i < vals.length; i++) {
2853
+ const endY = (i === vals.length - 1) ? height : ((vals.slice(0, i + 1).reduce((s, v) => s + v, 0)) / total) * height;
2854
+ const startY = (i === 0) ? 0 : ((vals.slice(0, i).reduce((s, v) => s + v, 0)) / total) * height;
2855
+ heights.push({ y: startY, h: Math.max(0, endY - startY) });
2856
+ }
2857
+
2858
+ let svg = '';
2859
+ for (let i = 0; i < heights.length; i++) {
2860
+ const { y, h: segH } = heights[i];
2861
+ if (segH <= 0) continue;
2862
+
2863
+ const isFirst = i === 0;
2864
+ const isLast = i === heights.length - 1;
2865
+ const drawH = Math.max(0, segH - (isLast ? 0 : gap));
2866
+
2867
+ const pct = ((vals[i] / total) * 100).toFixed(1);
2868
+ const tipAttrs = tip({ label: labels[i], value: vals[i], pct });
2869
+ const a11yTitle = `<title>${esc(labels[i])}: ${vals[i]} (${pct}%)</title>`;
2870
+
2871
+ if ((isFirst && isLast) || (!isFirst && !isLast) || r === 0) {
2872
+ svg += `<rect data-slice="${i % 10}"${tipAttrs} x="0" y="${y}" width="${w}" height="${drawH}"${(isFirst || isLast) && r ? ` rx="${r}"` : ''}>${a11yTitle}</rect>`;
2873
+ } else {
2874
+ // Asymmetric rounded corners: TOP two corners for the first
2875
+ // segment, BOTTOM two for the last — the vertical mirror of the
2876
+ // horizontal variant's left/right corner rounding above.
2877
+ const rr = r;
2878
+ const y2 = y + drawH;
2879
+ let d;
2880
+ if (isFirst) {
2881
+ d = `M 0 ${y + rr} A ${rr} ${rr} 0 0 1 ${rr} ${y} H ${w - rr} A ${rr} ${rr} 0 0 1 ${w} ${y + rr} V ${y2} H 0 Z`;
2882
+ } else { // isLast
2883
+ d = `M 0 ${y} H ${w} V ${y2 - rr} A ${rr} ${rr} 0 0 1 ${w - rr} ${y2} H ${rr} A ${rr} ${rr} 0 0 1 0 ${y2 - rr} Z`;
2884
+ }
2885
+ svg += `<path data-slice="${i % 10}"${tipAttrs} d="${d}">${a11yTitle}</path>`;
2886
+ }
2887
+ }
2888
+
2889
+ return { svg, viewBox: `0 0 ${w} ${height}` };
2890
+ }
2891
+
2024
2892
  /* ── Stacked bar ──────────────────────────────────────────────── */
2025
2893
 
2026
2894
  #renderStackedBar() {
@@ -2030,10 +2898,23 @@ export class UIChart extends UIElement {
2030
2898
 
2031
2899
  /* Get stacked totals for max */
2032
2900
  const totals = data.map(d => keys.reduce((sum, k) => sum + (+(d[k] ?? 0)), 0));
2033
- const ticks = niceScale(0, Math.max(...totals), 5);
2901
+ const ticks = niceScale(...this.#yBounds(totals), 5);
2034
2902
  const maxVal = ticks[ticks.length - 1];
2903
+ const minVal = ticks[0];
2035
2904
 
2036
2905
  const dims = this.#dims();
2906
+ const ratio = dims.ratioResolved;
2907
+
2908
+ this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
2909
+
2910
+ // REQ-R-003 (stacked-bar, 2:3) — "bars rotate to horizontal": same
2911
+ // orientation-swap rationale as bar's own 2:3 cell — a tall, narrow
2912
+ // box has width for a value axis but not for N vertical category
2913
+ // columns each carrying a multi-segment stack.
2914
+ if (ratio === '2:3') {
2915
+ return this.#renderStackedBarHorizontal(dims, labels, keys, data, maxVal, ticks);
2916
+ }
2917
+
2037
2918
  const { width, height, pad } = dims;
2038
2919
  const plotH = height - pad.top - pad.bottom;
2039
2920
  const plotW = width - pad.left - pad.right;
@@ -2041,20 +2922,36 @@ export class UIChart extends UIElement {
2041
2922
  const barInner = barW * 0.6;
2042
2923
  const barGap = (barW - barInner) / 2;
2043
2924
 
2044
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
2925
+ // REQ-R-003 (stacked-bar, 1:1 "square"): abbreviate category labels
2926
+ // same truncation treatment as bar@1:1's own square-container cell.
2927
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
2928
+ abbreviateLabels: ratio === '1:1',
2929
+ });
2930
+
2931
+ // Baseline (value-0 line) shifts up when a pinned yMin lands below zero
2932
+ // — segments still stack additively by raw value, only the pixel scale
2933
+ // and the zero-line's position account for the pinned domain.
2934
+ const stackScale = (maxVal - minVal) ? plotH / (maxVal - minVal) : 0;
2935
+ // gh#1693 review — a pinned yMax lower than the actual stacked total
2936
+ // must not let the cumulative stack burst above the plot's top edge
2937
+ // (svg overflow:visible bleeds an unclamped span into page content).
2938
+ // The baseline itself is clamped defensively too (an extreme yMin
2939
+ // could otherwise place it above pad.top).
2940
+ const stackBaselineY = this.#clampCoord(pad.top + plotH + minVal * stackScale, pad.top, pad.top + plotH);
2045
2941
 
2046
2942
  for (let i = 0; i < data.length; i++) {
2047
- let stackY = pad.top + plotH;
2943
+ let stackY = stackBaselineY;
2048
2944
  const segCount = keys.length;
2049
2945
  for (let k = 0; k < segCount; k++) {
2050
2946
  if (this.#isSeriesHidden(keys[k])) continue;
2051
2947
  const v = +(data[i][keys[k]] ?? 0);
2052
- const segH = maxVal ? (v / maxVal) * plotH : 0;
2053
- if (segH <= 0) { stackY -= segH; continue; }
2054
- stackY -= segH;
2948
+ const rawSegH = v * stackScale;
2949
+ if (rawSegH <= 0) { stackY = this.#clampCoord(stackY - rawSegH, pad.top, pad.top + plotH); continue; }
2950
+ const clampedTop = Math.max(stackY - rawSegH, pad.top);
2951
+ const bh = stackY - clampedTop;
2055
2952
  const bx = pad.left + barW * i + barGap;
2056
- const by = stackY;
2057
- const bh = segH;
2953
+ const by = clampedTop;
2954
+ stackY = clampedTop;
2058
2955
  const isTop = k === segCount - 1;
2059
2956
  const r = this.#resolveRadius();
2060
2957
 
@@ -2073,7 +2970,77 @@ export class UIChart extends UIElement {
2073
2970
  }
2074
2971
  }
2075
2972
 
2076
- this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
2973
+ return { svg, viewBox: `0 0 ${width} ${height}` };
2974
+ }
2975
+
2976
+ /* REQ-R-003 (stacked-bar, 2:3) — horizontal mirror of the vertical
2977
+ renderer above: categories run top-to-bottom on the Y axis, each
2978
+ category's segments stack LEFT-TO-RIGHT (still additively, same
2979
+ stacking semantics — only the axis swaps, per bar's own #renderBar
2980
+ ↔ #renderBarHorizontal precedent). */
2981
+ #renderStackedBarHorizontal(dims, labels, keys, data, maxVal, ticks) {
2982
+ const { width, height } = dims;
2983
+ const fs = dims.fontSize;
2984
+ const ls = dims.labelSize;
2985
+ const noGrid = this.noGrid;
2986
+
2987
+ const maxChars = labels.reduce((m, l) => Math.max(m, String(l).length), 1);
2988
+ const leftGutter = noGrid ? 2 : Math.min(width * 0.42, Math.max(fs * 3, maxChars * ls * 0.55 + ls));
2989
+ const pad = {
2990
+ top: fs * 0.8,
2991
+ right: fs * 1.8,
2992
+ bottom: noGrid ? 2 : fs * 1.8,
2993
+ left: leftGutter,
2994
+ };
2995
+ const plotW = width - pad.left - pad.right;
2996
+ const plotH = height - pad.top - pad.bottom;
2997
+ const barH = plotH / labels.length;
2998
+ const barInner = barH * 0.6;
2999
+ const barGap = (barH - barInner) / 2;
3000
+ const minVal = ticks[0];
3001
+ const stackScale = (maxVal - minVal) ? plotW / (maxVal - minVal) : 0;
3002
+ // gh#1693 review — mirrors the vertical stacked-bar clamp: a pinned
3003
+ // yMax lower than the actual stacked total must not let the
3004
+ // cumulative stack burst past the plot's right edge.
3005
+ const stackBaselineX = this.#clampCoord(pad.left - minVal * stackScale, pad.left, pad.left + plotW);
3006
+
3007
+ let svg = '';
3008
+ if (!noGrid) {
3009
+ const tickRange = (ticks[ticks.length - 1] - ticks[0]) || 1;
3010
+ for (const t of ticks) {
3011
+ const gx = pad.left + plotW * ((t - ticks[0]) / tickRange);
3012
+ svg += `<line data-chart-grid x1="${gx}" y1="${pad.top}" x2="${gx}" y2="${height - pad.bottom}"/>`;
3013
+ svg += `<text data-y-label x="${gx}" y="${height - pad.bottom + fs * 1.3}" text-anchor="middle" font-size="${fs}">${this.#fmtValue(t)}</text>`;
3014
+ }
3015
+ }
3016
+
3017
+ for (let i = 0; i < labels.length; i++) {
3018
+ const by = pad.top + barH * i + barGap;
3019
+ svg += `<text data-x-label x="${pad.left - 6}" y="${by + barInner / 2 + fs * 0.35}" text-anchor="end" font-size="${ls}">${esc(labels[i])}</text>`;
3020
+
3021
+ let stackX = stackBaselineX;
3022
+ const segCount = keys.length;
3023
+ for (let k = 0; k < segCount; k++) {
3024
+ if (this.#isSeriesHidden(keys[k])) continue;
3025
+ const v = +(data[i][keys[k]] ?? 0);
3026
+ const rawSegW = v * stackScale;
3027
+ if (rawSegW <= 0) continue;
3028
+ const bx = stackX;
3029
+ const clampedEnd = Math.min(stackX + rawSegW, pad.left + plotW);
3030
+ const segW = clampedEnd - bx;
3031
+ stackX = clampedEnd;
3032
+ const isEnd = k === segCount - 1;
3033
+ const attrs = `${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })}`;
3034
+
3035
+ if (isEnd) {
3036
+ // End segment (or single segment) — round the value-end corners
3037
+ // only, mirroring #renderStackedBar's own top-corner rounding.
3038
+ svg += `<path${attrs} d="${rightRoundedBarPath(bx, by, segW, barInner, this.#resolveRadius())}"/>`;
3039
+ } else {
3040
+ svg += `<rect${attrs} x="${bx}" y="${by}" width="${segW}" height="${barInner}"/>`;
3041
+ }
3042
+ }
3043
+ }
2077
3044
 
2078
3045
  return { svg, viewBox: `0 0 ${width} ${height}` };
2079
3046
  }
@@ -2086,10 +3053,25 @@ export class UIChart extends UIElement {
2086
3053
  const labels = data.map(d => d[this.x] ?? '');
2087
3054
 
2088
3055
  const allVals = data.flatMap(d => keys.map(k => +(d[k] ?? 0)));
2089
- const ticks = niceScale(0, Math.max(...allVals), 5);
3056
+ const ticks = niceScale(...this.#yBounds(allVals), 5);
2090
3057
  const maxVal = ticks[ticks.length - 1];
3058
+ const minVal = ticks[0];
2091
3059
 
2092
3060
  const dims = this.#dims();
3061
+ const ratio = dims.ratioResolved;
3062
+
3063
+ this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
3064
+
3065
+ // REQ-R-003 (grouped-bar, 2:3) — "groups rotate to horizontal bars
3066
+ // stacked by category": the group transposes the same way bar/
3067
+ // stacked-bar do at 2:3 — each category becomes a ROW, and within
3068
+ // that row every series still gets its own sub-bar (grouped, not
3069
+ // additive) — just arranged top-to-bottom in the row instead of
3070
+ // side-by-side in a column.
3071
+ if (ratio === '2:3') {
3072
+ return this.#renderGroupedBarHorizontal(dims, labels, keys, data, maxVal, ticks);
3073
+ }
3074
+
2093
3075
  const { width, height, pad } = dims;
2094
3076
  const plotH = height - pad.top - pad.bottom;
2095
3077
  const plotW = width - pad.left - pad.right;
@@ -2099,13 +3081,20 @@ export class UIChart extends UIElement {
2099
3081
  const subBarW = (totalBarSpace - barGap * (keys.length - 1)) / keys.length;
2100
3082
  const groupPad = (groupW - totalBarSpace) / 2;
2101
3083
 
2102
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
3084
+ // REQ-R-003 (grouped-bar, 1:1) "groups compress (bar width narrows
3085
+ // before category count drops)": category labels abbreviate first
3086
+ // (same treatment as bar/stacked-bar's own 1:1 cells), freeing width
3087
+ // for the bars themselves rather than ever dropping a category.
3088
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
3089
+ abbreviateLabels: ratio === '1:1',
3090
+ });
2103
3091
 
2104
3092
  for (let i = 0; i < data.length; i++) {
2105
3093
  for (let k = 0; k < keys.length; k++) {
2106
3094
  if (this.#isSeriesHidden(keys[k])) continue;
2107
3095
  const v = +(data[i][keys[k]] ?? 0);
2108
- const barH = maxVal ? (v / maxVal) * plotH : 0;
3096
+ const rawH = (maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotH : 0;
3097
+ const barH = this.#clampSpan(rawH, plotH);
2109
3098
  const bx = pad.left + groupW * i + groupPad + (subBarW + barGap) * k;
2110
3099
  const by = pad.top + plotH - barH;
2111
3100
  svg += `<path${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })} d="${topRoundedBarPath(bx, by, subBarW, barH, this.#resolveRadius())}"/>`;
@@ -2116,7 +3105,65 @@ export class UIChart extends UIElement {
2116
3105
  }
2117
3106
  }
2118
3107
 
2119
- this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
3108
+ return { svg, viewBox: `0 0 ${width} ${height}` };
3109
+ }
3110
+
3111
+ /* REQ-R-003 (grouped-bar, 2:3) — horizontal mirror of the vertical
3112
+ renderer above: categories run top-to-bottom on the Y axis, and each
3113
+ category's own K series get their own sub-bar ROW within that
3114
+ category's band (never summed — grouped semantics preserved, only
3115
+ the axis transposes, same as #renderStackedBar's additive stack
3116
+ staying additive across its own horizontal mirror). */
3117
+ #renderGroupedBarHorizontal(dims, labels, keys, data, maxVal, ticks) {
3118
+ const { width, height } = dims;
3119
+ const fs = dims.fontSize;
3120
+ const ls = dims.labelSize;
3121
+ const noGrid = this.noGrid;
3122
+
3123
+ const maxChars = labels.reduce((m, l) => Math.max(m, String(l).length), 1);
3124
+ const leftGutter = noGrid ? 2 : Math.min(width * 0.42, Math.max(fs * 3, maxChars * ls * 0.55 + ls));
3125
+ const pad = {
3126
+ top: fs * 0.8,
3127
+ right: fs * 1.8,
3128
+ bottom: noGrid ? 2 : fs * 1.8,
3129
+ left: leftGutter,
3130
+ };
3131
+ const plotW = width - pad.left - pad.right;
3132
+ const plotH = height - pad.top - pad.bottom;
3133
+ const groupH = plotH / labels.length;
3134
+ const barGap = 3;
3135
+ const totalBarSpace = groupH * 0.7;
3136
+ const subBarH = (totalBarSpace - barGap * (keys.length - 1)) / keys.length;
3137
+ const groupPad = (groupH - totalBarSpace) / 2;
3138
+ const minVal = ticks[0];
3139
+
3140
+ let svg = '';
3141
+ if (!noGrid) {
3142
+ const tickRange = (ticks[ticks.length - 1] - ticks[0]) || 1;
3143
+ for (const t of ticks) {
3144
+ const gx = pad.left + plotW * ((t - ticks[0]) / tickRange);
3145
+ svg += `<line data-chart-grid x1="${gx}" y1="${pad.top}" x2="${gx}" y2="${height - pad.bottom}"/>`;
3146
+ svg += `<text data-y-label x="${gx}" y="${height - pad.bottom + fs * 1.3}" text-anchor="middle" font-size="${fs}">${this.#fmtValue(t)}</text>`;
3147
+ }
3148
+ }
3149
+
3150
+ for (let i = 0; i < labels.length; i++) {
3151
+ const groupY = pad.top + groupH * i;
3152
+ svg += `<text data-x-label x="${pad.left - 6}" y="${groupY + groupH / 2 + fs * 0.35}" text-anchor="end" font-size="${ls}">${esc(labels[i])}</text>`;
3153
+
3154
+ for (let k = 0; k < keys.length; k++) {
3155
+ if (this.#isSeriesHidden(keys[k])) continue;
3156
+ const v = +(data[i][keys[k]] ?? 0);
3157
+ const rawW = (maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotW : 0;
3158
+ const barW = this.#clampSpan(rawW, plotW);
3159
+ const by = groupY + groupPad + (subBarH + barGap) * k;
3160
+ svg += `<path${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })} d="${rightRoundedBarPath(pad.left, by, barW, subBarH, this.#resolveRadius())}"/>`;
3161
+
3162
+ if (!this.noValues) {
3163
+ svg += `<text data-value x="${pad.left + barW + 4}" y="${by + subBarH / 2 + fs * 0.35}" text-anchor="start" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
3164
+ }
3165
+ }
3166
+ }
2120
3167
 
2121
3168
  return { svg, viewBox: `0 0 ${width} ${height}` };
2122
3169
  }
@@ -2129,23 +3176,42 @@ export class UIChart extends UIElement {
2129
3176
  const labels = data.map(d => d[this.x] ?? '');
2130
3177
 
2131
3178
  const allVals = data.flatMap(d => keys.map(k => +(d[k] ?? 0)));
2132
- const ticks = niceScale(0, Math.max(...allVals), 5);
3179
+ const ticks = niceScale(...this.#yBounds(allVals), 5);
2133
3180
  const maxVal = ticks[ticks.length - 1];
3181
+ const minVal = ticks[0];
2134
3182
 
2135
3183
  const dims = this.#dims();
3184
+ const ratio = dims.ratioResolved;
2136
3185
  const { width, height, pad } = dims;
2137
3186
  const plotH = height - pad.top - pad.bottom;
2138
3187
  const plotW = width - pad.left - pad.right;
2139
3188
  const step = plotW / Math.max(data.length - 1, 1);
2140
3189
 
2141
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
3190
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, { pointAligned: true });
3191
+
3192
+ // REQ-R-003 (multi-line) — a progressive de-emphasis across the three
3193
+ // ratios rather than one fixed identification mechanism:
3194
+ // 3:2 "all series labeled inline at line end (no legend needed)" —
3195
+ // each series' terminal point gets its own key label; the
3196
+ // ordinary below-chart legend is suppressed (chart.css) since
3197
+ // these end-labels already serve that job.
3198
+ // 1:1 "inline end-labels replaced by a compact legend below" — end
3199
+ // labels drop, the ordinary legend (unsuppressed at this ratio)
3200
+ // takes over.
3201
+ // 2:3 "end-labels dropped entirely, legend is the only series key" —
3202
+ // same as 1:1's own legend-only fallback; named as its own cell
3203
+ // in the spec because the OTHER identification path (inline
3204
+ // labels) narrows away first at 1:1 and there's nothing left to
3205
+ // drop further at 2:3 — the legend was already the sole path.
3206
+ const inlineEndLabels = ratio === '3:2';
2142
3207
 
2143
3208
  for (let k = 0; k < keys.length; k++) {
2144
3209
  if (this.#isSeriesHidden(keys[k])) continue;
2145
3210
  const vals = data.map(d => +(d[keys[k]] ?? 0));
2146
3211
  const points = vals.map((v, i) => {
2147
3212
  const px = pad.left + step * i;
2148
- const py = pad.top + plotH - (maxVal ? (v / maxVal) * plotH : 0);
3213
+ const rawY = pad.top + plotH - ((maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotH : 0);
3214
+ const py = this.#clampCoord(rawY, pad.top, pad.top + plotH);
2149
3215
  return { x: px, y: py, v, label: labels[i] };
2150
3216
  });
2151
3217
 
@@ -2178,6 +3244,11 @@ export class UIChart extends UIElement {
2178
3244
  }
2179
3245
  svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: keys[k] })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
2180
3246
  });
3247
+
3248
+ if (inlineEndLabels && points.length) {
3249
+ const last = points[points.length - 1];
3250
+ svg += `<text data-series-end-label${this.#seriesFill(k % 10, keys[k])} x="${last.x + 6}" y="${last.y}" text-anchor="start" dominant-baseline="central" font-size="${dims.labelSize}">${esc(keys[k])}</text>`;
3251
+ }
2181
3252
  }
2182
3253
 
2183
3254
  this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
@@ -2211,7 +3282,18 @@ export class UIChart extends UIElement {
2211
3282
  const dot = document.createElement('span');
2212
3283
  dot.setAttribute('data-legend-dot', '');
2213
3284
  dot.setAttribute('data-slice', String(item.slot));
2214
- if (item.key) dot.style.background = `var(--color-${item.key}, var(--chart-${item.slot}))`;
3285
+ // CodeRabbit (PR #1662) a de-emphasized series (composed@1:1's
3286
+ // secondary line, REQ-R-007) draws in the muted neutral token, not
3287
+ // its categorical palette slot; the legend dot must match what was
3288
+ // actually drawn, and skip the --color-{key} override hook here
3289
+ // too (a consumer's override is for the CATEGORICAL reading, which
3290
+ // this ratio deliberately isn't showing).
3291
+ if (item.deemphasized) {
3292
+ dot.setAttribute('data-deemphasized-tint', '');
3293
+ dot.style.background = 'var(--md-sys-color-neutral-on-surface-variant)';
3294
+ } else if (item.key) {
3295
+ dot.style.background = `var(--color-${item.key}, var(--chart-${item.slot}))`;
3296
+ }
2215
3297
  el.appendChild(dot);
2216
3298
 
2217
3299
  const text = document.createElement('span');