@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.
- package/CHANGELOG.md +30 -0
- package/components/avatar/avatar.class.js +12 -1
- package/components/button/button.d.ts +4 -0
- package/components/chart/chart.a2ui.json +26 -0
- package/components/chart/chart.class.js +1304 -222
- package/components/chart/chart.css +151 -1
- package/components/chart/chart.d.ts +6 -0
- package/components/chart/chart.yaml +57 -0
- package/components/chart-legend/chart-legend.a2ui.json +17 -2
- package/components/chart-legend/chart-legend.class.js +69 -4
- package/components/chart-legend/chart-legend.css +65 -1
- package/components/chart-legend/chart-legend.d.ts +4 -2
- package/components/chart-legend/chart-legend.examples.md +4 -4
- package/components/chart-legend/chart-legend.yaml +43 -3
- package/components/combobox/combobox.a2ui.json +3 -0
- package/components/combobox/combobox.class.js +9 -8
- package/components/combobox/combobox.css +25 -4
- package/components/combobox/combobox.yaml +6 -0
- package/components/command/command.css +17 -1
- package/components/context-menu/context-menu.d.ts +2 -0
- package/components/heatmap/heatmap.a2ui.json +5 -0
- package/components/heatmap/heatmap.d.ts +2 -0
- package/components/heatmap/heatmap.yaml +8 -0
- package/components/input/input.a2ui.json +2 -2
- package/components/input/input.class.js +12 -7
- package/components/input/input.yaml +6 -5
- package/components/integration-card/integration-card.d.ts +2 -0
- package/components/noodles/noodles.a2ui.json +0 -15
- package/components/noodles/noodles.yaml +0 -12
- package/components/number-format/number-format.class.js +10 -0
- package/components/pagination/pagination.class.js +88 -4
- package/components/popover/popover.d.ts +2 -0
- package/components/progress/progress.class.js +11 -6
- package/components/range/range.class.js +9 -1
- package/components/select/select.a2ui.json +5 -1
- package/components/select/select.class.js +14 -14
- package/components/select/select.css +14 -1
- package/components/select/select.yaml +7 -1
- package/components/stream/stream.d.ts +2 -0
- package/components/table/table.a2ui.json +6 -1
- package/components/table/table.class.js +18 -1
- package/components/table/table.css +31 -6
- package/components/table/table.d.ts +3 -1
- package/components/table/table.examples.md +2 -2
- package/components/table/table.yaml +9 -1
- package/components/table-toolbar/table-toolbar.a2ui.json +28 -4
- package/components/table-toolbar/table-toolbar.class.js +346 -11
- package/components/table-toolbar/table-toolbar.css +265 -0
- package/components/table-toolbar/table-toolbar.d.ts +5 -1
- package/components/table-toolbar/table-toolbar.yaml +130 -15
- package/components/tag/tag.class.js +13 -1
- package/components/text/text.a2ui.json +3 -3
- package/components/text/text.d.ts +5 -3
- package/components/text/text.yaml +3 -3
- package/components/textarea/textarea.class.js +9 -1
- package/components/tour/tour.d.ts +10 -0
- package/core/a11y.d.ts +22 -0
- package/core/a11y.js +64 -0
- package/core/anchor.js +9 -5
- package/core/element.js +1 -1
- package/custom-elements.json +73 -29
- package/dist/host.min.css +1 -1
- package/dist/host.sheet.js +1 -1
- package/dist/theme-provider.min.js +1 -1
- package/dist/web-components.min.css +1 -1
- package/dist/web-components.min.js +119 -119
- package/dist/web-components.sheet.js +1 -1
- package/package.json +1 -1
- package/patterns/access-requests/access-requests.examples.html +1 -1
- package/patterns/admin-keys-and-export/admin-keys-and-export.examples.html +7 -7
- package/patterns/agent-cost/agent-cost.examples.html +6 -6
- package/patterns/agent-memory/agent-memory.examples.html +3 -3
- package/patterns/agent-prompt-library/agent-prompt-library.examples.html +8 -8
- package/patterns/agent-tool-call/agent-tool-call.examples.html +1 -1
- package/patterns/approvals/approvals.examples.html +6 -6
- package/patterns/audit-log/audit-log.examples.html +1 -1
- package/patterns/bulk-action-toolbar/bulk-action-toolbar.examples.html +4 -4
- package/patterns/chart-in-card/chart-in-card.examples.html +114 -0
- package/patterns/chart-in-card/chart-in-card.examples.js +37 -0
- package/patterns/comments-and-collaboration/comments-and-collaboration.examples.html +5 -5
- package/patterns/data-tables-inline-edit-and-tree/data-tables-inline-edit-and-tree.examples.html +2 -2
- package/patterns/diff-review/diff-review.examples.html +6 -6
- package/patterns/export-flow/export-flow.examples.html +2 -2
- package/patterns/filter-bar/filter-bar.examples.html +2 -2
- package/patterns/forms-conditional-and-autocomplete/forms-conditional-and-autocomplete.examples.html +2 -2
- package/patterns/inline-dialog/inline-dialog.examples.html +2 -2
- package/patterns/marketing-engagement/marketing-engagement.examples.html +4 -4
- package/patterns/notifications-bell-and-digest/notifications-bell-and-digest.examples.html +1 -1
- package/patterns/permissions-matrix/permissions-matrix.examples.html +1 -1
- package/patterns/permissions-sharing/permissions-sharing.examples.html +2 -2
- package/patterns/profile-public-and-verification/profile-public-and-verification.examples.html +4 -4
- package/patterns/search-discovery/search-discovery.examples.html +2 -2
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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 /
|
|
1360
|
+
const barW = plotW / labels.length;
|
|
1093
1361
|
const barInner = barW * 0.6;
|
|
1094
1362
|
const barGap = (barW - barInner) / 2;
|
|
1095
1363
|
|
|
1096
|
-
|
|
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 &&
|
|
1373
|
+
const showAverage = !this.noAverage && origVals.length > 1 && dims.sizeClass !== 'sm' && !this.noGrid;
|
|
1101
1374
|
|
|
1102
|
-
for (let i = 0; i <
|
|
1375
|
+
for (let i = 0; i < labels.length; i++) {
|
|
1103
1376
|
const v = vals[i];
|
|
1104
|
-
const
|
|
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 =
|
|
1117
|
-
const
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1199
|
-
|
|
1200
|
-
|
|
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
|
|
1244
|
-
|
|
1245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1487
|
-
(filled, remainder). Avoids the "full circle" vs
|
|
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
|
|
1507
|
-
|
|
1508
|
-
full rings (so the ends don't overlap
|
|
1509
|
-
"round" for partial arcs so ends read as
|
|
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(
|
|
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
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
1587
|
-
|
|
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="${
|
|
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
|
|
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 -
|
|
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
|
-
|
|
1644
|
-
|
|
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
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
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
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
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
|
|
1684
|
-
|
|
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
|
|
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
|
-
|
|
1690
|
-
|
|
1691
|
-
const
|
|
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
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
const rowH =
|
|
1712
|
-
const
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
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
|
-
|
|
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
|
|
1811
|
-
const
|
|
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
|
-
|
|
1814
|
-
const targetTotal = [...targetSet.values()].reduce((a, t) => a + t.flow, 0) || 1;
|
|
2459
|
+
let svg = '';
|
|
1815
2460
|
|
|
1816
|
-
|
|
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
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
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
|
-
|
|
1848
|
-
svg += `<
|
|
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
|
-
|
|
1854
|
-
svg += `<
|
|
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(
|
|
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
|
|
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
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
|
2053
|
-
if (
|
|
2054
|
-
stackY
|
|
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 =
|
|
2057
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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');
|