@adia-ai/web-components 0.8.42 → 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 +44 -0
- package/MIGRATION.md +130 -80
- package/USAGE.md +1 -1
- package/color/index.js +1 -1
- package/components/action-list/action-item.a2ui.json +3 -8
- package/components/action-list/action-item.yaml +10 -16
- package/components/action-list/action-list.class.js +5 -61
- package/components/action-list/action-list.d.ts +1 -3
- package/components/avatar/avatar.class.js +12 -1
- package/components/button/button.d.ts +4 -0
- package/components/button/button.yaml +1 -1
- package/components/calendar-picker/calendar-picker.a2ui.json +1 -1
- package/components/calendar-picker/calendar-picker.class.js +2 -3
- package/components/calendar-picker/calendar-picker.css +16 -18
- package/components/calendar-picker/calendar-picker.yaml +2 -2
- package/components/card/card.css +3 -3
- package/components/chart/chart.a2ui.json +29 -18
- package/components/chart/chart.class.js +1308 -266
- package/components/chart/chart.css +151 -1
- package/components/chart/chart.d.ts +9 -9
- package/components/chart/chart.yaml +63 -31
- package/components/chart-legend/chart-legend.a2ui.json +20 -14
- package/components/chart-legend/chart-legend.class.js +79 -58
- package/components/chart-legend/chart-legend.css +65 -1
- package/components/chart-legend/chart-legend.d.ts +5 -5
- package/components/chart-legend/chart-legend.examples.md +4 -4
- package/components/chart-legend/chart-legend.yaml +50 -20
- package/components/check/check.css +9 -0
- package/components/color-area/color-area.a2ui.json +1 -1
- package/components/color-area/color-area.class.js +2 -2
- package/components/color-area/color-area.css +4 -7
- package/components/color-area/color-area.d.ts +1 -1
- package/components/color-area/color-area.yaml +3 -3
- package/components/color-input/color-input.a2ui.json +1 -1
- package/components/color-input/color-input.class.js +1 -1
- package/components/color-input/color-input.yaml +2 -2
- 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/drilldown/drilldown.a2ui.json +3 -0
- package/components/drilldown/drilldown.css +8 -1
- package/components/drilldown/drilldown.yaml +2 -0
- package/components/field/field.class.js +5 -5
- 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/index.js +0 -4
- package/components/input/input.a2ui.json +4 -4
- package/components/input/input.class.js +20 -7
- package/components/input/input.css +6 -0
- package/components/input/input.yaml +15 -11
- package/components/integration-card/integration-card.d.ts +2 -0
- package/components/menu/menu-item.a2ui.json +3 -8
- package/components/menu/menu-item.yaml +13 -21
- package/components/menu/menu.class.js +5 -27
- package/components/menu/menu.d.ts +1 -3
- 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/pane/pane.a2ui.json +2 -12
- package/components/pane/pane.class.js +5 -30
- package/components/pane/pane.d.ts +3 -5
- package/components/pane/pane.yaml +7 -19
- package/components/popover/popover.d.ts +2 -0
- package/components/progress/progress.a2ui.json +1 -1
- package/components/progress/progress.class.js +13 -8
- package/components/progress/progress.d.ts +1 -1
- package/components/progress/progress.yaml +2 -2
- package/components/radio/radio.css +7 -0
- package/components/range/range.class.js +9 -1
- package/components/search/search.css +10 -1
- package/components/segment/segment.a2ui.json +1 -2
- package/components/segment/segment.css +10 -0
- package/components/segment/segment.yaml +1 -2
- package/components/segmented/segmented.a2ui.json +1 -3
- package/components/segmented/segmented.class.js +2 -2
- package/components/segmented/segmented.yaml +2 -4
- package/components/select/select.a2ui.json +5 -1
- package/components/select/select.class.js +14 -14
- package/components/select/select.css +20 -1
- package/components/select/select.yaml +7 -1
- package/components/step-progress/step-progress.yaml +1 -1
- package/components/stream/stream.d.ts +2 -0
- package/components/switch/switch.css +8 -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 +67 -2
- package/components/table-toolbar/table-toolbar.class.js +492 -17
- package/components/table-toolbar/table-toolbar.css +311 -0
- package/components/table-toolbar/table-toolbar.d.ts +17 -1
- package/components/table-toolbar/table-toolbar.yaml +220 -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/textarea/textarea.css +4 -0
- package/components/time-picker/time-picker.yaml +3 -2
- package/components/tour/tour.d.ts +10 -0
- package/components/tree/tree.yaml +1 -1
- package/components/upload/upload.class.js +16 -0
- package/components/upload/upload.d.ts +7 -0
- package/components/upload/upload.yaml +7 -3
- 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/core/icons.js +2 -5
- package/custom-elements.json +142 -594
- 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 +122 -119
- package/dist/web-components.sheet.js +1 -1
- package/index.d.ts +0 -15
- 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 +124 -10
- 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/conversion-funnel/conversion-funnel.html +2 -2
- 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/editor-shell/editor-shell.examples.html +3 -3
- 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/colors/semantics/features.css +8 -1
- package/styles/components.css +0 -4
- package/styles/type/roles.css +29 -9
- package/traits/arrow-grid-nav/arrow-grid-nav.js +1 -1
- package/traits/resize-observer/resize-observer.js +1 -1
- package/components/color-picker/color-picker.a2ui.json +0 -148
- package/components/color-picker/color-picker.class.js +0 -46
- package/components/color-picker/color-picker.css +0 -14
- package/components/color-picker/color-picker.d.ts +0 -99
- package/components/color-picker/color-picker.examples.md +0 -19
- package/components/color-picker/color-picker.js +0 -17
- package/components/color-picker/color-picker.yaml +0 -175
- package/components/datetime-picker/datetime-picker.a2ui.json +0 -256
- package/components/datetime-picker/datetime-picker.class.js +0 -58
- package/components/datetime-picker/datetime-picker.css +0 -11
- package/components/datetime-picker/datetime-picker.d.ts +0 -87
- package/components/datetime-picker/datetime-picker.examples.md +0 -25
- package/components/datetime-picker/datetime-picker.js +0 -17
- package/components/datetime-picker/datetime-picker.yaml +0 -254
- package/components/progress-row/progress-row.a2ui.json +0 -107
- package/components/progress-row/progress-row.class.js +0 -118
- package/components/progress-row/progress-row.css +0 -60
- package/components/progress-row/progress-row.d.ts +0 -24
- package/components/progress-row/progress-row.examples.md +0 -7
- package/components/progress-row/progress-row.js +0 -17
- package/components/progress-row/progress-row.yaml +0 -85
- package/components/toggle-group/toggle-group.a2ui.json +0 -148
- package/components/toggle-group/toggle-group.class.js +0 -195
- package/components/toggle-group/toggle-group.css +0 -127
- package/components/toggle-group/toggle-group.d.ts +0 -50
- package/components/toggle-group/toggle-group.examples.md +0 -33
- package/components/toggle-group/toggle-group.js +0 -18
- package/components/toggle-group/toggle-group.yaml +0 -135
- package/components/toggle-group/toggle-option.a2ui.json +0 -86
- package/components/toggle-group/toggle-option.yaml +0 -75
|
@@ -22,14 +22,21 @@
|
|
|
22
22
|
* x — key for X-axis data
|
|
23
23
|
* y — key(s) for Y-axis, comma-separated for multi-series
|
|
24
24
|
* no-average — suppress the overlaid average line on bar/line (default: false — line shown).
|
|
25
|
-
* `hide-average` is a deprecated dual-read alias (ADR-0063, gh#1563).
|
|
26
25
|
* color — accent/success/warning/danger/info
|
|
27
|
-
* no-grid — hide gridlines.
|
|
28
|
-
* no-values — hide value labels.
|
|
26
|
+
* no-grid — hide gridlines.
|
|
27
|
+
* no-values — hide value labels.
|
|
29
28
|
* corner-radius — bar corner radius in px (default: null, falls back to --a-radius)
|
|
30
29
|
* dots — line/multi-line marker visibility: all|none|hover|last (default: all)
|
|
31
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).
|
|
32
38
|
*
|
|
39
|
+
|
|
33
40
|
* JS API:
|
|
34
41
|
* .data = [{...}, ...] — array of data objects
|
|
35
42
|
*/
|
|
@@ -65,6 +72,15 @@ function esc(s) {
|
|
|
65
72
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
66
73
|
}
|
|
67
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
|
+
|
|
68
84
|
/* Emit tooltip data-attributes for a datum shape. Consumed by the pointer
|
|
69
85
|
delegate below. All fields optional — unused ones are skipped. */
|
|
70
86
|
function tip({ label, value, pct, series }) {
|
|
@@ -202,6 +218,59 @@ function topRoundedBarPath(x, y, w, h, r = 0) {
|
|
|
202
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`;
|
|
203
219
|
}
|
|
204
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
|
+
|
|
205
274
|
/* ── Aspect ratios ─────────────────────────────────────────────── */
|
|
206
275
|
|
|
207
276
|
// Implicit aspect ratio — a chart derives its height from width ÷ this ratio when
|
|
@@ -209,6 +278,15 @@ function topRoundedBarPath(x, y, w, h, r = 0) {
|
|
|
209
278
|
// was retired (OD-CHART-02); parents size charts directly now.
|
|
210
279
|
const ASPECTS = { std: { ratio: 4 / 3 } };
|
|
211
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
|
+
|
|
212
290
|
/* ── Component ──────────────────────────────────────────────────── */
|
|
213
291
|
|
|
214
292
|
export class UIChart extends UIElement {
|
|
@@ -216,22 +294,19 @@ export class UIChart extends UIElement {
|
|
|
216
294
|
type: { type: String, default: 'bar', reflect: true },
|
|
217
295
|
x: { type: String, default: '', reflect: true },
|
|
218
296
|
y: { type: String, default: '', reflect: true },
|
|
219
|
-
// ADR-0063 (gh#1563) — `no-*` is the ratified negation prefix;
|
|
220
|
-
//
|
|
221
|
-
// read compat, 0.8.42) so old markup keeps reflecting; `#resolveNoAttr()`
|
|
222
|
-
// (called from connected()) mirrors an old-only value onto the new prop
|
|
223
|
-
// + warns once per attribute. Every internal render usage reads the
|
|
224
|
-
// canonical `noAverage`/`noGrid`/`noValues` below.
|
|
225
|
-
hideAverage: { type: Boolean, default: false, reflect: true, attribute: 'hide-average' },
|
|
297
|
+
// ADR-0063 (gh#1563) — `no-*` is the ratified negation prefix; the
|
|
298
|
+
// deprecated `hide-*` dual-read shim was cut in 0.8.43 (gh#1617).
|
|
226
299
|
noAverage: { type: Boolean, default: false, reflect: true, attribute: 'no-average' },
|
|
227
300
|
color: { type: String, default: '', reflect: true },
|
|
228
|
-
hideGrid: { type: Boolean, default: false, reflect: true, attribute: 'hide-grid' },
|
|
229
301
|
noGrid: { type: Boolean, default: false, reflect: true, attribute: 'no-grid' },
|
|
230
|
-
hideValues: { type: Boolean, default: false, reflect: true, attribute: 'hide-values' },
|
|
231
302
|
noValues: { type: Boolean, default: false, reflect: true, attribute: 'no-values' },
|
|
232
303
|
cornerRadius: { type: Number, default: null, reflect: true, attribute: 'corner-radius' },
|
|
233
304
|
smooth: { type: Number, default: 0.4, reflect: true },
|
|
234
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 },
|
|
235
310
|
format: { type: String, default: 'abbr', reflect: true },
|
|
236
311
|
loading: { type: Boolean, default: false, reflect: true },
|
|
237
312
|
/* gh#561 — dot/marker visibility for line + multi-line. `all` is the
|
|
@@ -247,36 +322,18 @@ export class UIChart extends UIElement {
|
|
|
247
322
|
(default) applies full treatment to every series — unchanged from
|
|
248
323
|
pre-561 behavior. */
|
|
249
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' },
|
|
250
333
|
};
|
|
251
334
|
|
|
252
335
|
static template = () => null;
|
|
253
336
|
|
|
254
|
-
// ADR-0063 (gh#1563) compat-shim warn-once flags — one per renamed
|
|
255
|
-
// attribute (not collapsed into one flag; each fires independently the
|
|
256
|
-
// first time its own old attribute resolves). Per-class, matching
|
|
257
|
-
// toggle-group.class.js's #deprecationWarned idiom.
|
|
258
|
-
static #warned = { hideAverage: false, hideGrid: false, hideValues: false };
|
|
259
|
-
|
|
260
|
-
/** Dual-read resolution for one `hide-*` → `no-*` rename (ADR-0063).
|
|
261
|
-
* New-name precedence: if `newAttr` is present, the old attribute is
|
|
262
|
-
* ignored outright (no mirror, no warn) — the new prop already reflects
|
|
263
|
-
* its own attribute normally. Otherwise, when only the old attribute is
|
|
264
|
-
* present, mirror its (always-true, boolean-presence) value onto the
|
|
265
|
-
* new prop and warn once per class. */
|
|
266
|
-
#resolveNoAttr(oldAttr, newAttr, newProp, warnKey) {
|
|
267
|
-
if (this.hasAttribute(newAttr)) return;
|
|
268
|
-
if (!this.hasAttribute(oldAttr)) return;
|
|
269
|
-
this[newProp] = true;
|
|
270
|
-
if (!UIChart.#warned[warnKey]) {
|
|
271
|
-
UIChart.#warned[warnKey] = true;
|
|
272
|
-
// eslint-disable-next-line no-console
|
|
273
|
-
console.warn(
|
|
274
|
-
`[AdiaUI] <chart-ui [${oldAttr}]> is deprecated — use [${newAttr}] ` +
|
|
275
|
-
`instead (ADR-0063, gh#1563). Kept working as-is; no behavior change.`,
|
|
276
|
-
);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
337
|
#data = [];
|
|
281
338
|
#shapeWarned = false; // FEEDBACK-24: one-shot wrong-.data-shape warning
|
|
282
339
|
#resizeObs = null;
|
|
@@ -301,6 +358,40 @@ export class UIChart extends UIElement {
|
|
|
301
358
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 6;
|
|
302
359
|
}
|
|
303
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
|
+
|
|
304
395
|
set data(arr) {
|
|
305
396
|
// FEEDBACK-24: a non-array — typically the Chart.js `{labels, datasets}`
|
|
306
397
|
// envelope — is silently coerced to [] below, producing a blank chart
|
|
@@ -353,12 +444,6 @@ export class UIChart extends UIElement {
|
|
|
353
444
|
}
|
|
354
445
|
|
|
355
446
|
connected() {
|
|
356
|
-
// ADR-0063 (gh#1563) compat shims — resolve BEFORE the first render so
|
|
357
|
-
// there's no wrong-then-right flash; see #resolveNoAttr() above.
|
|
358
|
-
this.#resolveNoAttr('hide-average', 'no-average', 'noAverage', 'hideAverage');
|
|
359
|
-
this.#resolveNoAttr('hide-grid', 'no-grid', 'noGrid', 'hideGrid');
|
|
360
|
-
this.#resolveNoAttr('hide-values', 'no-values', 'noValues', 'hideValues');
|
|
361
|
-
|
|
362
447
|
if (!this.hasAttribute('role')) this.setAttribute('role', 'img');
|
|
363
448
|
if (!this.hasAttribute('aria-label')) this.setAttribute('aria-label', `${this.type} chart`);
|
|
364
449
|
|
|
@@ -458,9 +543,16 @@ export class UIChart extends UIElement {
|
|
|
458
543
|
// use it. Otherwise derive from aspect ratio. Safe because resize sets
|
|
459
544
|
// overflow:auto which prevents content from pushing height.
|
|
460
545
|
const hasExplicitHeight = this.hasAttribute('resize') || this.style.height;
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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);
|
|
464
556
|
|
|
465
557
|
// Cap against --chart-max-height (CSS-resolved to px by getComputedStyle).
|
|
466
558
|
// Keeps wide-viewport renders from producing absurdly tall charts while
|
|
@@ -469,6 +561,25 @@ export class UIChart extends UIElement {
|
|
|
469
561
|
const maxH = parseFloat(getComputedStyle(this).maxHeight);
|
|
470
562
|
if (isFinite(maxH) && maxH > 0 && height > maxH) height = Math.round(maxH);
|
|
471
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
|
+
|
|
472
583
|
// Scale-aware font sizes: consistent regardless of chart dimensions
|
|
473
584
|
// Use sm/md/lg or auto-detect from smallest dimension
|
|
474
585
|
const minDim = Math.min(width, height);
|
|
@@ -482,12 +593,18 @@ export class UIChart extends UIElement {
|
|
|
482
593
|
// out the Y-axis label gutter entirely — otherwise the plot area
|
|
483
594
|
// collapses at small sizes.
|
|
484
595
|
const noGrid = this.noGrid;
|
|
485
|
-
|
|
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;
|
|
486
603
|
const hasXLabels = !!this.x;
|
|
487
604
|
const pad = {
|
|
488
605
|
top: noGrid ? 2 : fontSize * 1.6,
|
|
489
606
|
right: noGrid ? 2 : fontSize * 1.2,
|
|
490
|
-
bottom: !hasXLabels || noGrid ? 2 : fontSize * 2.2,
|
|
607
|
+
bottom: !hasXLabels || noGrid ? 2 : fontSize * 2.2 * marginTrim,
|
|
491
608
|
left: yLabelW,
|
|
492
609
|
};
|
|
493
610
|
|
|
@@ -495,7 +612,7 @@ export class UIChart extends UIElement {
|
|
|
495
612
|
const plotW = width - pad.left - pad.right;
|
|
496
613
|
const barMinW = Math.max(4, plotW / n * 0.6);
|
|
497
614
|
|
|
498
|
-
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 };
|
|
499
616
|
}
|
|
500
617
|
|
|
501
618
|
/* ── Main render ──────────────────────────────────────────────── */
|
|
@@ -1065,15 +1182,49 @@ export class UIChart extends UIElement {
|
|
|
1065
1182
|
|
|
1066
1183
|
/* ── Grid + axes helper ───────────────────────────────────────── */
|
|
1067
1184
|
|
|
1068
|
-
|
|
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 = {}) {
|
|
1069
1219
|
const p = pad;
|
|
1070
1220
|
const fs = dims?.fontSize || 10;
|
|
1071
1221
|
const ls = dims?.labelSize || fs;
|
|
1072
1222
|
let s = '';
|
|
1073
1223
|
|
|
1074
|
-
// 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").
|
|
1075
1226
|
let displayTicks = ticks;
|
|
1076
|
-
if (dims?.sizeClass === 'sm' && ticks.length > 4) {
|
|
1227
|
+
if ((dims?.sizeClass === 'sm' || opts.thinGrid) && ticks.length > 4) {
|
|
1077
1228
|
displayTicks = ticks.filter((_, i) => i % 2 === 0 || i === ticks.length - 1);
|
|
1078
1229
|
}
|
|
1079
1230
|
|
|
@@ -1084,9 +1235,35 @@ export class UIChart extends UIElement {
|
|
|
1084
1235
|
if (!this.noGrid) {
|
|
1085
1236
|
const tickRange = ticks[ticks.length - 1] - ticks[0];
|
|
1086
1237
|
const safeRange = tickRange || 1; // Prevent division by zero
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
+
}
|
|
1090
1267
|
}
|
|
1091
1268
|
|
|
1092
1269
|
/* Y-axis labels */
|
|
@@ -1095,19 +1272,46 @@ export class UIChart extends UIElement {
|
|
|
1095
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>`;
|
|
1096
1273
|
}
|
|
1097
1274
|
|
|
1098
|
-
/* 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. */
|
|
1099
1277
|
if (labels) {
|
|
1100
1278
|
const plotW = width - p.left - p.right;
|
|
1101
1279
|
const step = plotW / labels.length;
|
|
1102
|
-
const maxChars = labels.reduce((m, l) => Math.max(m, String(l).length), 1);
|
|
1103
|
-
const labelPx = maxChars * ls * 0.6 + ls * 0.75;
|
|
1104
|
-
const stride = Math.max(1, Math.ceil(labelPx / step));
|
|
1105
1280
|
const last = labels.length - 1;
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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>`;
|
|
1111
1315
|
}
|
|
1112
1316
|
}
|
|
1113
1317
|
}
|
|
@@ -1121,27 +1325,57 @@ export class UIChart extends UIElement {
|
|
|
1121
1325
|
const dims = this.#dims();
|
|
1122
1326
|
const data = this.#data;
|
|
1123
1327
|
const yKey = this.#yKeys()[0] || this.y;
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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);
|
|
1127
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
|
+
}
|
|
1128
1356
|
|
|
1129
1357
|
const { width, height, pad } = dims;
|
|
1130
1358
|
const plotH = height - pad.top - pad.bottom;
|
|
1131
1359
|
const plotW = width - pad.left - pad.right;
|
|
1132
|
-
const barW = plotW /
|
|
1360
|
+
const barW = plotW / labels.length;
|
|
1133
1361
|
const barInner = barW * 0.6;
|
|
1134
1362
|
const barGap = (barW - barInner) / 2;
|
|
1135
1363
|
|
|
1136
|
-
|
|
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
|
+
});
|
|
1137
1370
|
|
|
1138
1371
|
// At sm auto-hide value labels (they overlap at narrow widths).
|
|
1139
1372
|
const showValues = !this.noValues && dims.sizeClass !== 'sm';
|
|
1140
|
-
const showAverage = !this.noAverage &&
|
|
1373
|
+
const showAverage = !this.noAverage && origVals.length > 1 && dims.sizeClass !== 'sm' && !this.noGrid;
|
|
1141
1374
|
|
|
1142
|
-
for (let i = 0; i <
|
|
1375
|
+
for (let i = 0; i < labels.length; i++) {
|
|
1143
1376
|
const v = vals[i];
|
|
1144
|
-
const
|
|
1377
|
+
const rawH = (maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotH : 0;
|
|
1378
|
+
const barH = this.#clampSpan(rawH, plotH);
|
|
1145
1379
|
const bx = pad.left + barW * i + barGap;
|
|
1146
1380
|
const by = pad.top + plotH - barH;
|
|
1147
1381
|
|
|
@@ -1153,8 +1387,9 @@ export class UIChart extends UIElement {
|
|
|
1153
1387
|
}
|
|
1154
1388
|
|
|
1155
1389
|
if (showAverage) {
|
|
1156
|
-
const avg =
|
|
1157
|
-
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);
|
|
1158
1393
|
svg += `<line data-avg x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}"/>`;
|
|
1159
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>`;
|
|
1160
1395
|
/* Wider invisible hit target so the thin dashed line is hoverable */
|
|
@@ -1164,6 +1399,71 @@ export class UIChart extends UIElement {
|
|
|
1164
1399
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
1165
1400
|
}
|
|
1166
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
|
+
|
|
1167
1467
|
/* ── Line chart ───────────────────────────────────────────────── */
|
|
1168
1468
|
|
|
1169
1469
|
#renderLine() {
|
|
@@ -1172,19 +1472,41 @@ export class UIChart extends UIElement {
|
|
|
1172
1472
|
const yKey = this.#yKeys()[0] || this.y;
|
|
1173
1473
|
const vals = data.map(v => +(v[yKey] ?? 0));
|
|
1174
1474
|
const labels = data.map(v => v[this.x] ?? '');
|
|
1175
|
-
const ticks = niceScale(
|
|
1475
|
+
const ticks = niceScale(...this.#yBounds(vals), 5);
|
|
1176
1476
|
const maxVal = ticks[ticks.length - 1];
|
|
1477
|
+
const minVal = ticks[0];
|
|
1177
1478
|
|
|
1178
1479
|
const { width, height, pad } = dims;
|
|
1179
1480
|
const plotH = height - pad.top - pad.bottom;
|
|
1180
1481
|
const plotW = width - pad.left - pad.right;
|
|
1181
1482
|
const step = plotW / Math.max(data.length - 1, 1);
|
|
1182
1483
|
|
|
1183
|
-
|
|
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
|
+
});
|
|
1184
1505
|
|
|
1185
1506
|
const points = vals.map((v, i) => {
|
|
1186
1507
|
const px = pad.left + step * i;
|
|
1187
|
-
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);
|
|
1188
1510
|
return { x: px, y: py, v, label: labels[i] };
|
|
1189
1511
|
});
|
|
1190
1512
|
|
|
@@ -1212,7 +1534,8 @@ export class UIChart extends UIElement {
|
|
|
1212
1534
|
|
|
1213
1535
|
if (showAverage) {
|
|
1214
1536
|
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
|
|
1215
|
-
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);
|
|
1216
1539
|
svg += `<line data-avg x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}"/>`;
|
|
1217
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>`;
|
|
1218
1541
|
/* Wider invisible hit target so the thin dashed line is hoverable */
|
|
@@ -1234,10 +1557,64 @@ export class UIChart extends UIElement {
|
|
|
1234
1557
|
// Responsive viewBox so pie respects chart-ui's max-height and
|
|
1235
1558
|
// doesn't push the legend past the container bounds.
|
|
1236
1559
|
const dims = this.#dims();
|
|
1237
|
-
const { width, height } = dims;
|
|
1238
|
-
const
|
|
1239
|
-
|
|
1240
|
-
|
|
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';
|
|
1241
1618
|
|
|
1242
1619
|
let svg = '';
|
|
1243
1620
|
let angle = -Math.PI / 2;
|
|
@@ -1256,6 +1633,24 @@ export class UIChart extends UIElement {
|
|
|
1256
1633
|
} else {
|
|
1257
1634
|
svg += `<path${attrs} d="${arcPath(cx, cy, r, angle, end)}"/>`;
|
|
1258
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
|
+
|
|
1259
1654
|
angle = end;
|
|
1260
1655
|
}
|
|
1261
1656
|
|
|
@@ -1279,11 +1674,16 @@ export class UIChart extends UIElement {
|
|
|
1279
1674
|
const labels = data.map(d => d[this.x] ?? '');
|
|
1280
1675
|
|
|
1281
1676
|
const dims = this.#dims();
|
|
1282
|
-
const { width, height } = dims;
|
|
1283
|
-
const
|
|
1284
|
-
|
|
1285
|
-
|
|
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));
|
|
1286
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;
|
|
1287
1687
|
|
|
1288
1688
|
let svg = '';
|
|
1289
1689
|
let angle = -Math.PI / 2;
|
|
@@ -1336,12 +1736,33 @@ export class UIChart extends UIElement {
|
|
|
1336
1736
|
// font-size and stroke widths at their intended visual scale).
|
|
1337
1737
|
const dims = this.#dims();
|
|
1338
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;
|
|
1339
1763
|
const cx = width / 2;
|
|
1340
|
-
const cy = height / 2;
|
|
1341
|
-
|
|
1342
|
-
// never clip at wide sizes or crowd the polygon at small sizes.
|
|
1343
|
-
const labelPad = fontSize * 3.5;
|
|
1344
|
-
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);
|
|
1345
1766
|
|
|
1346
1767
|
let svg = '';
|
|
1347
1768
|
const angleStep = (Math.PI * 2) / n;
|
|
@@ -1389,14 +1810,20 @@ export class UIChart extends UIElement {
|
|
|
1389
1810
|
svg += `<circle data-hit${tip({ label: labels[i], value: vals[i] })} cx="${px}" cy="${py}" r="${hitR}" fill="transparent"/>`;
|
|
1390
1811
|
}
|
|
1391
1812
|
|
|
1392
|
-
/* Labels
|
|
1393
|
-
|
|
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;
|
|
1394
1817
|
for (let i = 0; i < n; i++) {
|
|
1395
1818
|
const a = -Math.PI / 2 + angleStep * i;
|
|
1396
1819
|
const lx = cx + (r + labelGap) * Math.cos(a);
|
|
1397
1820
|
const ly = cy + (r + labelGap) * Math.sin(a);
|
|
1398
1821
|
const anchor = Math.abs(Math.cos(a)) < 0.1 ? 'middle' : Math.cos(a) > 0 ? 'start' : 'end';
|
|
1399
|
-
|
|
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>`;
|
|
1400
1827
|
}
|
|
1401
1828
|
|
|
1402
1829
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
@@ -1404,6 +1831,16 @@ export class UIChart extends UIElement {
|
|
|
1404
1831
|
|
|
1405
1832
|
/* ── Sparkline ────────────────────────────────────────────────── */
|
|
1406
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). */
|
|
1407
1844
|
#renderSparkline() {
|
|
1408
1845
|
const data = this.#data;
|
|
1409
1846
|
const yKey = this.#yKeys()[0] || this.y;
|
|
@@ -1472,22 +1909,38 @@ export class UIChart extends UIElement {
|
|
|
1472
1909
|
const yKey = this.#yKeys()[0] || this.y;
|
|
1473
1910
|
const vals = data.map(v => +(v[yKey] ?? 0));
|
|
1474
1911
|
const labels = data.map(v => v[this.x] ?? '');
|
|
1475
|
-
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);
|
|
1476
1919
|
const maxVal = ticks[ticks.length - 1];
|
|
1920
|
+
const minVal = ticks[0];
|
|
1477
1921
|
|
|
1478
1922
|
const { width, height, pad } = dims;
|
|
1479
1923
|
const plotH = height - pad.top - pad.bottom;
|
|
1480
1924
|
const plotW = width - pad.left - pad.right;
|
|
1481
1925
|
const step = plotW / Math.max(data.length - 1, 1);
|
|
1482
1926
|
|
|
1483
|
-
|
|
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
|
+
});
|
|
1484
1932
|
|
|
1485
|
-
|
|
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;
|
|
1486
1938
|
const hitR = Math.max(dotR * 2, 10);
|
|
1487
1939
|
|
|
1488
1940
|
for (let i = 0; i < vals.length; i++) {
|
|
1489
1941
|
const px = pad.left + step * i;
|
|
1490
|
-
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);
|
|
1491
1944
|
svg += `<circle data-dot data-scatter cx="${px}" cy="${py}" r="${dotR}"/>`;
|
|
1492
1945
|
svg += `<circle data-hit${tip({ label: labels[i], value: vals[i] })} cx="${px}" cy="${py}" r="${hitR}" fill="transparent"/>`;
|
|
1493
1946
|
}
|
|
@@ -1510,7 +1963,8 @@ export class UIChart extends UIElement {
|
|
|
1510
1963
|
const maxVal = Math.max(...vals) || 1;
|
|
1511
1964
|
|
|
1512
1965
|
const dims = this.#dims();
|
|
1513
|
-
const { width, height } = dims;
|
|
1966
|
+
const { width, height, fontSize, valueSize } = dims;
|
|
1967
|
+
const ratio = dims.ratioResolved;
|
|
1514
1968
|
const cx = width / 2;
|
|
1515
1969
|
const cy = height / 2;
|
|
1516
1970
|
const outerR = Math.max(30, Math.min(width, height) * 0.45);
|
|
@@ -1519,13 +1973,32 @@ export class UIChart extends UIElement {
|
|
|
1519
1973
|
const bandW = (outerR - innerR) / ringCount;
|
|
1520
1974
|
const gap = Math.min(2, bandW * 0.15);
|
|
1521
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
|
+
|
|
1522
1995
|
let svg = '';
|
|
1523
1996
|
|
|
1524
1997
|
/* Backing tracks + filled arcs per datum, from inner to outer.
|
|
1525
1998
|
Uses the stroke-dasharray technique: each ring is a single <circle>,
|
|
1526
|
-
rotated so the dash starts at
|
|
1527
|
-
(filled, remainder). Avoids the "full circle" vs
|
|
1528
|
-
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. */
|
|
1529
2002
|
for (let i = 0; i < vals.length; i++) {
|
|
1530
2003
|
const r0 = innerR + bandW * i + gap / 2;
|
|
1531
2004
|
const r1 = innerR + bandW * (i + 1) - gap / 2;
|
|
@@ -1533,6 +2006,13 @@ export class UIChart extends UIElement {
|
|
|
1533
2006
|
const thickness = r1 - r0;
|
|
1534
2007
|
const circumference = 2 * Math.PI * mid;
|
|
1535
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;
|
|
1536
2016
|
|
|
1537
2017
|
/* Backing ring — full circle, faint stroke */
|
|
1538
2018
|
svg += `<circle data-radial-track cx="${cx}" cy="${cy}" r="${mid}" fill="none" stroke-width="${thickness}"/>`;
|
|
@@ -1543,17 +2023,35 @@ export class UIChart extends UIElement {
|
|
|
1543
2023
|
const tipAttrs = tip({ label: labels[i], value: vals[i], pct });
|
|
1544
2024
|
|
|
1545
2025
|
/* Filled arc — stroke-dasharray splits the circumference into
|
|
1546
|
-
(drawn, gap). Rotated
|
|
1547
|
-
|
|
1548
|
-
full rings (so the ends don't overlap
|
|
1549
|
-
"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. */
|
|
1550
2031
|
const isFull = Math.abs(filled - circumference) < 0.5;
|
|
1551
2032
|
const linecap = isFull ? 'butt' : 'round';
|
|
1552
2033
|
const dashArray = isFull
|
|
1553
2034
|
? `${circumference} 0`
|
|
1554
2035
|
: `${filled} ${circumference - filled}`;
|
|
1555
2036
|
|
|
1556
|
-
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
|
+
}
|
|
1557
2055
|
}
|
|
1558
2056
|
|
|
1559
2057
|
this.#legendData = data.map((d, i) => ({
|
|
@@ -1588,14 +2086,29 @@ export class UIChart extends UIElement {
|
|
|
1588
2086
|
const pct = Math.max(0, Math.min(1, primary / maxVal));
|
|
1589
2087
|
|
|
1590
2088
|
const dims = this.#dims();
|
|
1591
|
-
const { width, height } = dims;
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
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
|
+
));
|
|
1599
2112
|
const innerR = outerR * 0.72;
|
|
1600
2113
|
/* End-cap radius — fully-rounded pill ends matching the
|
|
1601
2114
|
progress-ui convention (--progress-radius: var(--a-radius-full)).
|
|
@@ -1604,6 +2117,9 @@ export class UIChart extends UIElement {
|
|
|
1604
2117
|
ends of the arc. */
|
|
1605
2118
|
const capR = (outerR - innerR) / 2;
|
|
1606
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
|
+
|
|
1607
2123
|
let svg = '';
|
|
1608
2124
|
|
|
1609
2125
|
/* Backing arc — full 180° upper semicircle, from 9 o'clock (angle π)
|
|
@@ -1620,13 +2136,27 @@ export class UIChart extends UIElement {
|
|
|
1620
2136
|
svg += `<path data-slice="0"${tipAttrs} data-gauge-fill d="${donutArcPath(cx, cy, outerR, innerR, Math.PI, fillEnd, capR)}"/>`;
|
|
1621
2137
|
}
|
|
1622
2138
|
|
|
1623
|
-
/*
|
|
2139
|
+
/* Readout label — position/anchor keyed off the ratio-specific layout
|
|
2140
|
+
decided above; font sizing stays keyed off outerR unconditionally. */
|
|
1624
2141
|
const totalFs = Math.max(18, Math.round(outerR * 0.42));
|
|
1625
2142
|
const labelFs = Math.max(10, Math.round(outerR * 0.2));
|
|
1626
|
-
|
|
1627
|
-
|
|
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>`;
|
|
1628
2158
|
if (maxVal !== primary) {
|
|
1629
|
-
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>`;
|
|
1630
2160
|
}
|
|
1631
2161
|
|
|
1632
2162
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
@@ -1655,16 +2185,79 @@ export class UIChart extends UIElement {
|
|
|
1655
2185
|
const dims = this.#dims();
|
|
1656
2186
|
const { width, height } = dims;
|
|
1657
2187
|
const fs = dims.fontSize;
|
|
1658
|
-
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);
|
|
1659
2254
|
const padY = fs * 0.8;
|
|
1660
|
-
const plotW = width -
|
|
2255
|
+
const plotW = width - padXLeft - padXRight;
|
|
1661
2256
|
const plotH = height - padY * 2;
|
|
1662
2257
|
const stageH = plotH / n;
|
|
1663
2258
|
const gap = Math.max(2, stageH * 0.08);
|
|
2259
|
+
const cx = padXLeft + plotW / 2;
|
|
1664
2260
|
|
|
1665
|
-
const cx = width / 2;
|
|
1666
|
-
|
|
1667
|
-
let svg = '';
|
|
1668
2261
|
for (let i = 0; i < n; i++) {
|
|
1669
2262
|
const vTop = vals[i];
|
|
1670
2263
|
const vBot = (i < n - 1) ? vals[i + 1] : vals[i];
|
|
@@ -1672,42 +2265,82 @@ export class UIChart extends UIElement {
|
|
|
1672
2265
|
const halfBot = (vBot / maxVal) * (plotW / 2);
|
|
1673
2266
|
const y0 = padY + stageH * i + gap / 2;
|
|
1674
2267
|
const y1 = padY + stageH * (i + 1) - gap / 2;
|
|
2268
|
+
const midY = y0 + stageH / 2 - gap / 2;
|
|
1675
2269
|
|
|
1676
2270
|
const pct = ((vTop / maxVal) * 100).toFixed(1);
|
|
1677
2271
|
const tipAttrs = tip({ label: labels[i], value: vTop, pct });
|
|
1678
2272
|
|
|
1679
|
-
/* Trapezoid path: top-left, top-right, bottom-right, bottom-left. */
|
|
1680
2273
|
const d = `M ${cx - halfTop} ${y0} L ${cx + halfTop} ${y0} L ${cx + halfBot} ${y1} L ${cx - halfBot} ${y1} Z`;
|
|
1681
2274
|
svg += `<path data-slice="${i % 10}" data-funnel-stage${tipAttrs} d="${d}"/>`;
|
|
1682
2275
|
|
|
1683
|
-
|
|
1684
|
-
|
|
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
|
+
}
|
|
1685
2296
|
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
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
|
+
}
|
|
1691
2310
|
}
|
|
1692
2311
|
}
|
|
1693
2312
|
|
|
1694
|
-
this.#legendData = data.map((d, i) => ({
|
|
1695
|
-
label: labels[i], value: vals[i], pct: ((vals[i] / maxVal) * 100).toFixed(1), slot: i % 10,
|
|
1696
|
-
}));
|
|
1697
|
-
|
|
1698
2313
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
1699
2314
|
}
|
|
1700
2315
|
|
|
1701
2316
|
/* ── Treemap (hierarchical rect tiling) ────────────────────────
|
|
1702
|
-
Flat
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
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. */
|
|
1711
2344
|
#renderTreemap() {
|
|
1712
2345
|
const data = this.#data;
|
|
1713
2346
|
const yKey = this.#yKeys()[0] || this.y;
|
|
@@ -1719,71 +2352,35 @@ export class UIChart extends UIElement {
|
|
|
1719
2352
|
const dims = this.#dims();
|
|
1720
2353
|
const { width, height, fontSize } = dims;
|
|
1721
2354
|
const total = vals.reduce((a, b) => a + b, 0) || 1;
|
|
2355
|
+
const ratio = dims.ratioResolved;
|
|
1722
2356
|
|
|
1723
|
-
/* Sort indices by value desc
|
|
1724
|
-
|
|
2357
|
+
/* Sort indices by value desc (largest tiles read first, top-left) —
|
|
2358
|
+
keep original indices for color/label/hit mapping. */
|
|
1725
2359
|
const order = Array.from({ length: n }, (_, i) => i).sort((a, b) => vals[b] - vals[a]);
|
|
1726
2360
|
|
|
1727
|
-
const
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
/* Squarified packer — iterative rows. */
|
|
1734
|
-
let x = 0, y = 0, w = width, h = height;
|
|
1735
|
-
let row = [];
|
|
1736
|
-
let rowStart = 0;
|
|
1737
|
-
|
|
1738
|
-
const worst = (row, width) => {
|
|
1739
|
-
if (row.length === 0) return Infinity;
|
|
1740
|
-
const sum = row.reduce((a, b) => a + b, 0);
|
|
1741
|
-
const max = Math.max(...row);
|
|
1742
|
-
const min = Math.min(...row);
|
|
1743
|
-
const wsq = width * width;
|
|
1744
|
-
const ssq = sum * sum;
|
|
1745
|
-
return Math.max((wsq * max) / ssq, ssq / (wsq * min));
|
|
1746
|
-
};
|
|
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)));
|
|
1747
2366
|
|
|
1748
|
-
const
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
const rowW = horizontal ? w : sum / h;
|
|
1753
|
-
|
|
1754
|
-
let offset = 0;
|
|
1755
|
-
for (let k = 0; k < row.length; k++) {
|
|
1756
|
-
const frac = row[k] / sum;
|
|
1757
|
-
const idx = order[rowStart + k];
|
|
1758
|
-
if (horizontal) {
|
|
1759
|
-
const segW = frac * w;
|
|
1760
|
-
rects.push({ i: idx, x: x + offset, y, w: segW, h: rowH });
|
|
1761
|
-
offset += segW;
|
|
1762
|
-
} else {
|
|
1763
|
-
const segH = frac * h;
|
|
1764
|
-
rects.push({ i: idx, x, y: y + offset, w: rowW, h: segH });
|
|
1765
|
-
offset += segH;
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
if (horizontal) return { x, y: y + rowH, w, h: h - rowH };
|
|
1769
|
-
return { x: x + rowW, y, w: w - rowW, h };
|
|
1770
|
-
};
|
|
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;
|
|
1771
2371
|
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
const
|
|
1777
|
-
let
|
|
1778
|
-
|
|
1779
|
-
const
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
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;
|
|
1783
2382
|
}
|
|
1784
|
-
|
|
1785
|
-
x = newRect.x; y = newRect.y; w = newRect.w; h = newRect.h;
|
|
1786
|
-
remaining = remaining.slice(row.length);
|
|
2383
|
+
y += rowH;
|
|
1787
2384
|
}
|
|
1788
2385
|
|
|
1789
2386
|
/* Emit rects + labels. */
|
|
@@ -1804,13 +2401,19 @@ export class UIChart extends UIElement {
|
|
|
1804
2401
|
const canShowAny = r.w > fontSize * 4;
|
|
1805
2402
|
const tall = canShowAny && r.h > fontSize * 2.5;
|
|
1806
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;
|
|
1807
2410
|
|
|
1808
2411
|
if (tall) {
|
|
1809
|
-
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>`;
|
|
1810
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>`;
|
|
1811
2414
|
} else if (short) {
|
|
1812
2415
|
const cy = r.y + r.h / 2;
|
|
1813
|
-
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>`;
|
|
1814
2417
|
}
|
|
1815
2418
|
}
|
|
1816
2419
|
|
|
@@ -1847,19 +2450,106 @@ export class UIChart extends UIElement {
|
|
|
1847
2450
|
|
|
1848
2451
|
const dims = this.#dims();
|
|
1849
2452
|
const { width, height, fontSize } = dims;
|
|
1850
|
-
const
|
|
1851
|
-
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;
|
|
1852
2458
|
|
|
1853
|
-
|
|
1854
|
-
const targetTotal = [...targetSet.values()].reduce((a, t) => a + t.flow, 0) || 1;
|
|
2459
|
+
let svg = '';
|
|
1855
2460
|
|
|
1856
|
-
|
|
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
|
+
}
|
|
1857
2500
|
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
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);
|
|
1863
2553
|
|
|
1864
2554
|
let y = 0;
|
|
1865
2555
|
for (const s of sources) {
|
|
@@ -1878,20 +2568,20 @@ export class UIChart extends UIElement {
|
|
|
1878
2568
|
y += h + nodeGap;
|
|
1879
2569
|
}
|
|
1880
2570
|
|
|
1881
|
-
let svg = '';
|
|
1882
|
-
|
|
1883
2571
|
const nodeRx = Math.min(this.#resolveRadius(), nodeW / 2);
|
|
1884
2572
|
|
|
1885
2573
|
/* Source nodes — left column */
|
|
1886
2574
|
sources.forEach((s, i) => {
|
|
1887
|
-
|
|
1888
|
-
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>`;
|
|
1889
2578
|
});
|
|
1890
2579
|
|
|
1891
2580
|
/* Target nodes — right column */
|
|
1892
2581
|
targets.forEach((t, i) => {
|
|
1893
|
-
|
|
1894
|
-
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>`;
|
|
1895
2585
|
});
|
|
1896
2586
|
|
|
1897
2587
|
/* Links — bezier bands */
|
|
@@ -1933,6 +2623,7 @@ export class UIChart extends UIElement {
|
|
|
1933
2623
|
#renderComposed() {
|
|
1934
2624
|
const dims = this.#dims();
|
|
1935
2625
|
const data = this.#data;
|
|
2626
|
+
const ratio = dims.ratioResolved;
|
|
1936
2627
|
const keys = this.#yKeys();
|
|
1937
2628
|
const barKey = keys[0] || 'bar';
|
|
1938
2629
|
const lineKey = keys[1] || 'line';
|
|
@@ -1940,8 +2631,9 @@ export class UIChart extends UIElement {
|
|
|
1940
2631
|
const barVals = data.map(d => +(d[barKey] ?? 0));
|
|
1941
2632
|
const lineVals = data.map(d => +(d[lineKey] ?? 0));
|
|
1942
2633
|
const allVals = [...barVals, ...lineVals];
|
|
1943
|
-
const ticks = niceScale(
|
|
2634
|
+
const ticks = niceScale(...this.#yBounds(allVals), 5);
|
|
1944
2635
|
const maxVal = ticks[ticks.length - 1];
|
|
2636
|
+
const minVal = ticks[0];
|
|
1945
2637
|
|
|
1946
2638
|
const { width, height, pad } = dims;
|
|
1947
2639
|
const plotH = height - pad.top - pad.bottom;
|
|
@@ -1949,39 +2641,95 @@ export class UIChart extends UIElement {
|
|
|
1949
2641
|
const barW = plotW / data.length;
|
|
1950
2642
|
const barInner = barW * 0.6;
|
|
1951
2643
|
const barGap = (barW - barInner) / 2;
|
|
1952
|
-
const step = plotW / Math.max(data.length - 1, 1);
|
|
1953
2644
|
|
|
1954
2645
|
let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
|
|
1955
2646
|
|
|
1956
|
-
/* 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. */
|
|
1957
2650
|
if (!this.#isSeriesHidden(barKey)) {
|
|
1958
2651
|
for (let i = 0; i < data.length; i++) {
|
|
1959
2652
|
const v = barVals[i];
|
|
1960
|
-
const
|
|
2653
|
+
const rawH = (maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotH : 0;
|
|
2654
|
+
const barH = this.#clampSpan(rawH, plotH);
|
|
1961
2655
|
const bx = pad.left + barW * i + barGap;
|
|
1962
2656
|
const by = pad.top + plotH - barH;
|
|
1963
2657
|
svg += `<path${this.#seriesFill(0, barKey)}${tip({ label: labels[i], value: v, series: barKey })} d="${topRoundedBarPath(bx, by, barInner, barH, this.#resolveRadius())}"/>`;
|
|
1964
2658
|
}
|
|
1965
2659
|
}
|
|
1966
2660
|
|
|
1967
|
-
/* Line series (slot 1) — anchored at bar center X positions */
|
|
1968
2661
|
if (!this.#isSeriesHidden(lineKey)) {
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
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
|
+
}
|
|
1979
2721
|
}
|
|
1980
2722
|
}
|
|
1981
2723
|
|
|
1982
2724
|
this.#legendData = [
|
|
1983
2725
|
{ label: barKey, key: barKey, slot: 0 },
|
|
1984
|
-
|
|
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' },
|
|
1985
2733
|
];
|
|
1986
2734
|
|
|
1987
2735
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
@@ -1994,9 +2742,43 @@ export class UIChart extends UIElement {
|
|
|
1994
2742
|
#renderSegments() {
|
|
1995
2743
|
const data = this.#data;
|
|
1996
2744
|
const yKey = this.#yKeys()[0] || this.y;
|
|
1997
|
-
|
|
2745
|
+
let vals = data.map(d => +(d[yKey] ?? 0));
|
|
2746
|
+
let labels = data.map(d => d[this.x] ?? '');
|
|
1998
2747
|
const total = vals.reduce((a, b) => a + b, 0) || 1;
|
|
1999
|
-
|
|
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
|
+
}
|
|
2000
2782
|
|
|
2001
2783
|
// ViewBox matches actual render width so there's no horizontal
|
|
2002
2784
|
// stretching — keeps rounded corners from flattening into ellipses
|
|
@@ -2050,17 +2832,63 @@ export class UIChart extends UIElement {
|
|
|
2050
2832
|
}
|
|
2051
2833
|
}
|
|
2052
2834
|
|
|
2053
|
-
// Legend data (same shape pie/donut use).
|
|
2054
|
-
this.#legendData = data.map((d, i) => ({
|
|
2055
|
-
label: labels[i],
|
|
2056
|
-
value: vals[i],
|
|
2057
|
-
pct: ((vals[i] / total) * 100).toFixed(1),
|
|
2058
|
-
slot: i % 10,
|
|
2059
|
-
}));
|
|
2060
|
-
|
|
2061
2835
|
return { svg, viewBox: `0 0 ${w} ${h}` };
|
|
2062
2836
|
}
|
|
2063
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
|
+
|
|
2064
2892
|
/* ── Stacked bar ──────────────────────────────────────────────── */
|
|
2065
2893
|
|
|
2066
2894
|
#renderStackedBar() {
|
|
@@ -2070,10 +2898,23 @@ export class UIChart extends UIElement {
|
|
|
2070
2898
|
|
|
2071
2899
|
/* Get stacked totals for max */
|
|
2072
2900
|
const totals = data.map(d => keys.reduce((sum, k) => sum + (+(d[k] ?? 0)), 0));
|
|
2073
|
-
const ticks = niceScale(
|
|
2901
|
+
const ticks = niceScale(...this.#yBounds(totals), 5);
|
|
2074
2902
|
const maxVal = ticks[ticks.length - 1];
|
|
2903
|
+
const minVal = ticks[0];
|
|
2075
2904
|
|
|
2076
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
|
+
|
|
2077
2918
|
const { width, height, pad } = dims;
|
|
2078
2919
|
const plotH = height - pad.top - pad.bottom;
|
|
2079
2920
|
const plotW = width - pad.left - pad.right;
|
|
@@ -2081,20 +2922,36 @@ export class UIChart extends UIElement {
|
|
|
2081
2922
|
const barInner = barW * 0.6;
|
|
2082
2923
|
const barGap = (barW - barInner) / 2;
|
|
2083
2924
|
|
|
2084
|
-
|
|
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);
|
|
2085
2941
|
|
|
2086
2942
|
for (let i = 0; i < data.length; i++) {
|
|
2087
|
-
let stackY =
|
|
2943
|
+
let stackY = stackBaselineY;
|
|
2088
2944
|
const segCount = keys.length;
|
|
2089
2945
|
for (let k = 0; k < segCount; k++) {
|
|
2090
2946
|
if (this.#isSeriesHidden(keys[k])) continue;
|
|
2091
2947
|
const v = +(data[i][keys[k]] ?? 0);
|
|
2092
|
-
const
|
|
2093
|
-
if (
|
|
2094
|
-
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;
|
|
2095
2952
|
const bx = pad.left + barW * i + barGap;
|
|
2096
|
-
const by =
|
|
2097
|
-
|
|
2953
|
+
const by = clampedTop;
|
|
2954
|
+
stackY = clampedTop;
|
|
2098
2955
|
const isTop = k === segCount - 1;
|
|
2099
2956
|
const r = this.#resolveRadius();
|
|
2100
2957
|
|
|
@@ -2113,7 +2970,77 @@ export class UIChart extends UIElement {
|
|
|
2113
2970
|
}
|
|
2114
2971
|
}
|
|
2115
2972
|
|
|
2116
|
-
|
|
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
|
+
}
|
|
2117
3044
|
|
|
2118
3045
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
2119
3046
|
}
|
|
@@ -2126,10 +3053,25 @@ export class UIChart extends UIElement {
|
|
|
2126
3053
|
const labels = data.map(d => d[this.x] ?? '');
|
|
2127
3054
|
|
|
2128
3055
|
const allVals = data.flatMap(d => keys.map(k => +(d[k] ?? 0)));
|
|
2129
|
-
const ticks = niceScale(
|
|
3056
|
+
const ticks = niceScale(...this.#yBounds(allVals), 5);
|
|
2130
3057
|
const maxVal = ticks[ticks.length - 1];
|
|
3058
|
+
const minVal = ticks[0];
|
|
2131
3059
|
|
|
2132
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
|
+
|
|
2133
3075
|
const { width, height, pad } = dims;
|
|
2134
3076
|
const plotH = height - pad.top - pad.bottom;
|
|
2135
3077
|
const plotW = width - pad.left - pad.right;
|
|
@@ -2139,13 +3081,20 @@ export class UIChart extends UIElement {
|
|
|
2139
3081
|
const subBarW = (totalBarSpace - barGap * (keys.length - 1)) / keys.length;
|
|
2140
3082
|
const groupPad = (groupW - totalBarSpace) / 2;
|
|
2141
3083
|
|
|
2142
|
-
|
|
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
|
+
});
|
|
2143
3091
|
|
|
2144
3092
|
for (let i = 0; i < data.length; i++) {
|
|
2145
3093
|
for (let k = 0; k < keys.length; k++) {
|
|
2146
3094
|
if (this.#isSeriesHidden(keys[k])) continue;
|
|
2147
3095
|
const v = +(data[i][keys[k]] ?? 0);
|
|
2148
|
-
const
|
|
3096
|
+
const rawH = (maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotH : 0;
|
|
3097
|
+
const barH = this.#clampSpan(rawH, plotH);
|
|
2149
3098
|
const bx = pad.left + groupW * i + groupPad + (subBarW + barGap) * k;
|
|
2150
3099
|
const by = pad.top + plotH - barH;
|
|
2151
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())}"/>`;
|
|
@@ -2156,7 +3105,65 @@ export class UIChart extends UIElement {
|
|
|
2156
3105
|
}
|
|
2157
3106
|
}
|
|
2158
3107
|
|
|
2159
|
-
|
|
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
|
+
}
|
|
2160
3167
|
|
|
2161
3168
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
2162
3169
|
}
|
|
@@ -2169,23 +3176,42 @@ export class UIChart extends UIElement {
|
|
|
2169
3176
|
const labels = data.map(d => d[this.x] ?? '');
|
|
2170
3177
|
|
|
2171
3178
|
const allVals = data.flatMap(d => keys.map(k => +(d[k] ?? 0)));
|
|
2172
|
-
const ticks = niceScale(
|
|
3179
|
+
const ticks = niceScale(...this.#yBounds(allVals), 5);
|
|
2173
3180
|
const maxVal = ticks[ticks.length - 1];
|
|
3181
|
+
const minVal = ticks[0];
|
|
2174
3182
|
|
|
2175
3183
|
const dims = this.#dims();
|
|
3184
|
+
const ratio = dims.ratioResolved;
|
|
2176
3185
|
const { width, height, pad } = dims;
|
|
2177
3186
|
const plotH = height - pad.top - pad.bottom;
|
|
2178
3187
|
const plotW = width - pad.left - pad.right;
|
|
2179
3188
|
const step = plotW / Math.max(data.length - 1, 1);
|
|
2180
3189
|
|
|
2181
|
-
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';
|
|
2182
3207
|
|
|
2183
3208
|
for (let k = 0; k < keys.length; k++) {
|
|
2184
3209
|
if (this.#isSeriesHidden(keys[k])) continue;
|
|
2185
3210
|
const vals = data.map(d => +(d[keys[k]] ?? 0));
|
|
2186
3211
|
const points = vals.map((v, i) => {
|
|
2187
3212
|
const px = pad.left + step * i;
|
|
2188
|
-
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);
|
|
2189
3215
|
return { x: px, y: py, v, label: labels[i] };
|
|
2190
3216
|
});
|
|
2191
3217
|
|
|
@@ -2218,6 +3244,11 @@ export class UIChart extends UIElement {
|
|
|
2218
3244
|
}
|
|
2219
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"/>`;
|
|
2220
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
|
+
}
|
|
2221
3252
|
}
|
|
2222
3253
|
|
|
2223
3254
|
this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
|
|
@@ -2251,7 +3282,18 @@ export class UIChart extends UIElement {
|
|
|
2251
3282
|
const dot = document.createElement('span');
|
|
2252
3283
|
dot.setAttribute('data-legend-dot', '');
|
|
2253
3284
|
dot.setAttribute('data-slice', String(item.slot));
|
|
2254
|
-
|
|
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
|
+
}
|
|
2255
3297
|
el.appendChild(dot);
|
|
2256
3298
|
|
|
2257
3299
|
const text = document.createElement('span');
|