@adia-ai/web-components 0.8.45 → 0.8.47
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 +18 -0
- package/components/card/card.css +12 -23
- package/components/card/card.yaml +11 -0
- package/components/chart/chart.a2ui.json +16 -1
- package/components/chart/chart.class.js +611 -41
- package/components/chart/chart.css +174 -0
- package/components/chart/chart.d.ts +5 -1
- package/components/chart/chart.yaml +45 -1
- package/components/field/field.css +24 -2
- package/components/index.js +1 -0
- package/components/input/input.css +7 -0
- package/components/nav/nav.css +7 -3
- package/components/nav-group/nav-group.css +38 -41
- package/components/nav-item/nav-item.css +11 -5
- package/components/preview/preview.class.js +12 -2
- package/components/table/table.a2ui.json +19 -4
- package/components/table/table.class.js +163 -12
- package/components/table/table.d.ts +9 -3
- package/components/table/table.yaml +109 -8
- package/components/table-footer/table-footer.a2ui.json +150 -0
- package/components/table-footer/table-footer.class.js +391 -0
- package/components/table-footer/table-footer.css +64 -0
- package/components/table-footer/table-footer.d.ts +39 -0
- package/components/table-footer/table-footer.examples.md +46 -0
- package/components/table-footer/table-footer.js +17 -0
- package/components/table-footer/table-footer.yaml +219 -0
- package/components/table-toolbar/table-toolbar.a2ui.json +10 -0
- package/components/table-toolbar/table-toolbar.class.js +70 -7
- package/components/table-toolbar/table-toolbar.css +100 -3
- package/components/table-toolbar/table-toolbar.d.ts +2 -0
- package/components/table-toolbar/table-toolbar.examples.md +41 -41
- package/components/table-toolbar/table-toolbar.yaml +31 -6
- package/core/anchor.js +21 -1
- package/custom-elements.json +145 -4
- package/dist/host.min.css +1 -1
- package/dist/host.sheet.js +1 -1
- package/dist/theme-provider.min.js +2 -2
- package/dist/web-components.min.css +1 -1
- package/dist/web-components.min.js +87 -87
- package/dist/web-components.sheet.js +1 -1
- package/package.json +1 -1
- package/patterns/chart-in-card/chart-in-card.examples.html +36 -9
- package/patterns/new-enrollments/new-enrollments.examples.html +140 -0
- package/patterns/new-enrollments/new-enrollments.html +54 -0
- package/patterns/table-in-card/table-in-card.examples.html +168 -0
- package/patterns/table-in-card/table-in-card.examples.js +139 -0
- package/patterns/table-in-card/table-in-card.html +86 -0
- package/styles/colors/material-static.css +276 -234
- package/styles/components.css +1 -0
|
@@ -330,6 +330,19 @@ export class UIChart extends UIElement {
|
|
|
330
330
|
read a shared linear value axis. */
|
|
331
331
|
yMin: { type: Number, default: null, reflect: true, attribute: 'y-min' },
|
|
332
332
|
yMax: { type: Number, default: null, reflect: true, attribute: 'y-max' },
|
|
333
|
+
/* ADR-0081 (Charts 2.0 foundations, gh#1804) — chip-label mode. Unset
|
|
334
|
+
('') resolves to 'outside' unconditionally — explicit opt-in only via
|
|
335
|
+
labels="chip" (design amendment, 2026-08-21: see #resolveLabelsMode's
|
|
336
|
+
own doc comment for why auto-detecting bleed ancestry was dropped).
|
|
337
|
+
#dims()/#resolveLabelsMode() reflects the RESOLVED value separately
|
|
338
|
+
as data-labels-resolved (mirrors data-ratio-resolved's pattern) —
|
|
339
|
+
this property reflects only the raw attribute, same relationship
|
|
340
|
+
`ratio` has to data-ratio-resolved. */
|
|
341
|
+
labels: { type: String, default: '', reflect: true },
|
|
342
|
+
/* ADR-0081 — names the x-value that is "today" (REQ-F-008). Free
|
|
343
|
+
string, loose-stringify-compared against each datum's x-key value in
|
|
344
|
+
#todayIndex() below — never clock-derived. */
|
|
345
|
+
today: { type: String, default: '', reflect: true },
|
|
333
346
|
};
|
|
334
347
|
|
|
335
348
|
static template = () => null;
|
|
@@ -392,6 +405,68 @@ export class UIChart extends UIElement {
|
|
|
392
405
|
return Math.min(Math.max(coord, lo), hi);
|
|
393
406
|
}
|
|
394
407
|
|
|
408
|
+
/** Reads a CSS custom property expected to resolve to a px length (or a
|
|
409
|
+
* bare number) off the host's computed style, falling back to a JS
|
|
410
|
+
* constant when unset/unresolved. happy-dom's test environment doesn't
|
|
411
|
+
* resolve calc()/var() (svg-authoring.md §5's documented gap), so every
|
|
412
|
+
* token this file reads through here needs a faithful fallback matching
|
|
413
|
+
* the CSS default's real px value at density=1 — tests override via
|
|
414
|
+
* `el.style.setProperty(varName, ...)`, which computed style DOES pick
|
|
415
|
+
* up without a stylesheet.
|
|
416
|
+
*
|
|
417
|
+
* A REAL browser has the identical gap for a different reason:
|
|
418
|
+
* `getComputedStyle().getPropertyValue()` on a CUSTOM property never
|
|
419
|
+
* resolves nested `var()`/`calc()`/`clamp()` — it returns the cascaded
|
|
420
|
+
* value verbatim (e.g. `--card-radius` resolving to the literal string
|
|
421
|
+
* `"clamp(0.375rem, calc(1 * 0.875rem * 1.000), 0.875rem)"` once
|
|
422
|
+
* REQ-F-014's density scale landed), so `parseFloat` on it is NaN and
|
|
423
|
+
* every caller silently got the density=1 FALLBACK regardless of the
|
|
424
|
+
* live density or the ancestor card's real radius — found chasing a
|
|
425
|
+
* real corner-clipping repro (gh#1814-adjacent) where `cornerClearance`
|
|
426
|
+
* always collapsed to `chromeInset` because `--card-radius` never
|
|
427
|
+
* parsed. The browser DOES resolve that same value when it's consumed
|
|
428
|
+
* by an actual layout property, so an unparseable raw value is routed
|
|
429
|
+
* through a detached probe assigning it to `width` (inheriting this
|
|
430
|
+
* host's own cascade — density scope included — by probing as a
|
|
431
|
+
* temporary child) and reading the result back. */
|
|
432
|
+
#readPxToken(varName, fallback) {
|
|
433
|
+
const raw = getComputedStyle(this).getPropertyValue(varName).trim();
|
|
434
|
+
if (!raw) return fallback;
|
|
435
|
+
// A bare number (or plain `Npx`) is already what the fallback
|
|
436
|
+
// constants are compared against — use it directly, and stay on this
|
|
437
|
+
// branch for happy-dom's test env (style.setProperty literals), which
|
|
438
|
+
// doesn't implement layout for the probe below. Anything else (rem/
|
|
439
|
+
// em/%/calc()/clamp()/var()) needs real resolution — see comment above.
|
|
440
|
+
if (/^-?\d*\.?\d+(px)?$/.test(raw)) {
|
|
441
|
+
const n = parseFloat(raw);
|
|
442
|
+
return Number.isFinite(n) ? n : fallback;
|
|
443
|
+
}
|
|
444
|
+
const probe = document.createElement('span');
|
|
445
|
+
probe.style.cssText = 'position:absolute; visibility:hidden; pointer-events:none; height:0; width:' + raw;
|
|
446
|
+
this.appendChild(probe);
|
|
447
|
+
const resolved = parseFloat(getComputedStyle(probe).width);
|
|
448
|
+
probe.remove();
|
|
449
|
+
return Number.isFinite(resolved) ? resolved : fallback;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** ADR-0081 — resolves the `labels` attribute's tri-state grammar.
|
|
453
|
+
* DESIGN AMENDMENT (2026-08-21, recorded in the build's Findings and
|
|
454
|
+
* ADR-0081's own "Amendment" section): unset ("") resolves to
|
|
455
|
+
* `outside` UNCONDITIONALLY — it does NOT auto-detect full-bleed
|
|
456
|
+
* ancestry. The originally-drafted auto-detection (chip-by-default
|
|
457
|
+
* inside a `section[bleed]`/`card-ui[padding="none"]` ancestor) is
|
|
458
|
+
* exactly what REQ-F-004 asks for in principle, but it silently
|
|
459
|
+
* flips the rendering of every ALREADY-SHIPPED full-bleed composition
|
|
460
|
+
* the moment this ships — concretely, 2 of the 60-fixture Charts
|
|
461
|
+
* floor's own `comp-chart-in-card-n-*` fixtures (pre-dating this
|
|
462
|
+
* ticket) regressed under it, and this ticket's own dispatch holds
|
|
463
|
+
* the 60-fixture floor as a hard preserve-not-regress constraint.
|
|
464
|
+
* Chip mode is therefore explicit-opt-in ONLY (`labels="chip"`); nothing
|
|
465
|
+
* currently in the tree opts in, so nothing currently rendered changes. */
|
|
466
|
+
#resolveLabelsMode() {
|
|
467
|
+
return this.labels === 'chip' ? 'chip' : 'outside';
|
|
468
|
+
}
|
|
469
|
+
|
|
395
470
|
set data(arr) {
|
|
396
471
|
// FEEDBACK-24: a non-array — typically the Chart.js `{labels, datasets}`
|
|
397
472
|
// envelope — is silently coerced to [] below, producing a blank chart
|
|
@@ -588,6 +663,51 @@ export class UIChart extends UIElement {
|
|
|
588
663
|
const labelSize = fontSize;
|
|
589
664
|
const valueSize = sizeClass === 'sm' ? 8 : sizeClass === 'md' ? 9 : 10;
|
|
590
665
|
|
|
666
|
+
// ADR-0081 / REQ-F-004 — chip-label mode resolution + reflection
|
|
667
|
+
// (mirrors data-ratio-resolved's own reflect-only-on-change pattern).
|
|
668
|
+
const labelsResolved = this.#resolveLabelsMode();
|
|
669
|
+
if (this.getAttribute('data-labels-resolved') !== labelsResolved) {
|
|
670
|
+
this.setAttribute('data-labels-resolved', labelsResolved);
|
|
671
|
+
}
|
|
672
|
+
const chipMode = labelsResolved === 'chip';
|
|
673
|
+
|
|
674
|
+
// REQ-F-009/010 — chrome-layer clearance + the zero-line→chip-band gap,
|
|
675
|
+
// both --a-space-*-aliased tokens (chart.css); JS fallbacks match the
|
|
676
|
+
// CSS defaults' real px value (--a-space-2 = 8px, --a-space-1 = 4px)
|
|
677
|
+
// for the happy-dom test environment, which doesn't resolve var()/calc()
|
|
678
|
+
// (svg-authoring.md §5).
|
|
679
|
+
const chromeInset = this.#readPxToken('--chart-chrome-inset', 8);
|
|
680
|
+
const bandGap = this.#readPxToken('--chart-band-gap', 4);
|
|
681
|
+
// REQ-F-006(3) — corner-adjacent chips clear at least the composed
|
|
682
|
+
// card's --card-radius (a real ancestor-inherited custom property once
|
|
683
|
+
// chart-ui sits inside card-ui); falls back to chromeInset itself when
|
|
684
|
+
// no card ancestor sets one (no radius to clear beyond the ordinary
|
|
685
|
+
// edge inset).
|
|
686
|
+
const cornerClearance = Math.max(chromeInset, this.#readPxToken('--card-radius', chromeInset));
|
|
687
|
+
// REQ-F-005 chip token contract — geometry-relevant subset (bg/fg/
|
|
688
|
+
// radius are CSS-only, consumed by [data-chip]/[data-chip-label] rules
|
|
689
|
+
// in chart.css, never read here).
|
|
690
|
+
const chipFontSize = this.#readPxToken('--chart-chip-font-size', fontSize);
|
|
691
|
+
const chipPadX = this.#readPxToken('--chart-chip-pad-x', 6);
|
|
692
|
+
const chipPadY = this.#readPxToken('--chart-chip-pad-y', 4);
|
|
693
|
+
|
|
694
|
+
// REQ-F-013 / OPEN-4 (resolved in this build, see chart.css) — the
|
|
695
|
+
// plot's own pixel height against the two degradation floors. Order is
|
|
696
|
+
// normative: full → drop x-category CHIPS → drop gridlines + y-CHIPS
|
|
697
|
+
// (bare-marks). Scoped to chip mode deliberately — REQ-F-013's own
|
|
698
|
+
// language ("drop x-category chips") only has literal chips to drop in
|
|
699
|
+
// that mode; `outside` mode already has its own pre-existing small-size
|
|
700
|
+
// handling (sizeClass 'sm' thinning etc.) and MUST stay height-floor-
|
|
701
|
+
// free to hold AC-F-004's byte-identical regression proof (a short
|
|
702
|
+
// normal-padding-card chart that rendered x-labels yesterday can't
|
|
703
|
+
// start dropping them today just because this build shipped).
|
|
704
|
+
const minPlotHeightChips = this.#readPxToken('--chart-min-plot-height-chips', 180);
|
|
705
|
+
const minPlotHeightGrid = this.#readPxToken('--chart-min-plot-height-grid', 120);
|
|
706
|
+
const degradeStage = !chipMode ? 'full'
|
|
707
|
+
: height < minPlotHeightGrid ? 'bare'
|
|
708
|
+
: height < minPlotHeightChips ? 'no-x-chrome'
|
|
709
|
+
: 'full';
|
|
710
|
+
|
|
591
711
|
// Padding scales with font size and axis visibility.
|
|
592
712
|
// When the grid is hidden (in-card sparkline-like use) we can zero
|
|
593
713
|
// out the Y-axis label gutter entirely — otherwise the plot area
|
|
@@ -598,21 +718,34 @@ export class UIChart extends UIElement {
|
|
|
598
718
|
// least WIDTH budget to spare; 3:2 keeps the full gutter since width is
|
|
599
719
|
// abundant there. A flat 0.8 trim on the y-label gutter is enough to
|
|
600
720
|
// free real plot width without crowding the tick text against the axis.
|
|
721
|
+
// REQ-F-001 (foundations, amends REQ-R-004) — chip mode has NO residual
|
|
722
|
+
// gutter at all: the plot spans zero-inset to every edge and labels
|
|
723
|
+
// inset WITHIN it instead of pushing it inward. `outside` mode (the
|
|
724
|
+
// pre-foundations default) keeps the exact formula below unchanged —
|
|
725
|
+
// AC-F-004's regression proof depends on this branch being byte-for-
|
|
726
|
+
// byte identical to pre-2.0 output.
|
|
601
727
|
const marginTrim = resolvedRatio === '3:2' ? 1 : 0.8;
|
|
602
728
|
const yLabelW = noGrid ? 0 : fontSize * 3.2 * marginTrim;
|
|
603
729
|
const hasXLabels = !!this.x;
|
|
604
|
-
const pad =
|
|
605
|
-
top:
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
730
|
+
const pad = chipMode
|
|
731
|
+
? { top: 0, right: 0, bottom: 0, left: 0 }
|
|
732
|
+
: {
|
|
733
|
+
top: noGrid ? 2 : fontSize * 1.6,
|
|
734
|
+
right: noGrid ? 2 : fontSize * 1.2,
|
|
735
|
+
bottom: !hasXLabels || noGrid ? 2 : fontSize * 2.2 * marginTrim,
|
|
736
|
+
left: yLabelW,
|
|
737
|
+
};
|
|
610
738
|
|
|
611
739
|
// Bar width adapts to data count + container
|
|
612
740
|
const plotW = width - pad.left - pad.right;
|
|
613
741
|
const barMinW = Math.max(4, plotW / n * 0.6);
|
|
614
742
|
|
|
615
|
-
return {
|
|
743
|
+
return {
|
|
744
|
+
width, height, pad, fontSize, labelSize, valueSize, barMinW, plotW, n, sizeClass,
|
|
745
|
+
ratioResolved: resolvedRatio, labelsResolved, chipMode, chromeInset, bandGap,
|
|
746
|
+
cornerClearance, chipFontSize, chipPadX, chipPadY, minPlotHeightChips,
|
|
747
|
+
minPlotHeightGrid, degradeStage,
|
|
748
|
+
};
|
|
616
749
|
}
|
|
617
750
|
|
|
618
751
|
/* ── Main render ──────────────────────────────────────────────── */
|
|
@@ -1215,11 +1348,23 @@ export class UIChart extends UIElement {
|
|
|
1215
1348
|
* the band-centered default, correct for their own
|
|
1216
1349
|
* categorical geometry.
|
|
1217
1350
|
*/
|
|
1351
|
+
/** Returns `{ svg, chipSvg }` rather than one flat string — SVG paints in
|
|
1352
|
+
* document order, so a chip/chrome overlay concatenated INTO the same
|
|
1353
|
+
* string as gridlines (both emitted before any caller's marks) painted
|
|
1354
|
+
* UNDER those marks once appended, exactly backwards from REQ-F-002's
|
|
1355
|
+
* two-layer contract (chrome floats independently on top of the plot).
|
|
1356
|
+
* `outside`-mode axis TEXT is unaffected — it renders in the padding
|
|
1357
|
+
* gutter marks never reach into, and stays folded into `svg` untouched
|
|
1358
|
+
* (AC-F-004 byte-identity). Only the two chip-mode branches (Y/X chips)
|
|
1359
|
+
* route into `chipSvg`; every caller appends it AFTER its marks. */
|
|
1218
1360
|
#gridAndAxes(width, height, ticks, labels, pad, dims, opts = {}) {
|
|
1219
1361
|
const p = pad;
|
|
1220
1362
|
const fs = dims?.fontSize || 10;
|
|
1221
1363
|
const ls = dims?.labelSize || fs;
|
|
1364
|
+
const chip = !!dims?.chipMode;
|
|
1365
|
+
const stage = dims?.degradeStage || 'full';
|
|
1222
1366
|
let s = '';
|
|
1367
|
+
let chipSvg = '';
|
|
1223
1368
|
|
|
1224
1369
|
// Reduce tick count at small sizes, or when a ratio-matrix caller asks
|
|
1225
1370
|
// for denser-bucket thinning (REQ-R-003: line/area "gridlines thinned").
|
|
@@ -1231,8 +1376,9 @@ export class UIChart extends UIElement {
|
|
|
1231
1376
|
// noGrid suppresses both gridlines AND axis labels — callers who
|
|
1232
1377
|
// want labels without gridlines can omit no-grid and rely on token
|
|
1233
1378
|
// overrides to make gridlines transparent. This keeps compact in-card
|
|
1234
|
-
// charts visually clean.
|
|
1235
|
-
|
|
1379
|
+
// charts visually clean. REQ-F-013's 'bare' stage (chip mode only,
|
|
1380
|
+
// below the grid floor) is the same all-chrome-dropped shape.
|
|
1381
|
+
if (!this.noGrid && stage !== 'bare') {
|
|
1236
1382
|
const tickRange = ticks[ticks.length - 1] - ticks[0];
|
|
1237
1383
|
const safeRange = tickRange || 1; // Prevent division by zero
|
|
1238
1384
|
|
|
@@ -1266,15 +1412,28 @@ export class UIChart extends UIElement {
|
|
|
1266
1412
|
}
|
|
1267
1413
|
}
|
|
1268
1414
|
|
|
1269
|
-
/* Y-axis labels
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1415
|
+
/* Y-axis labels / y-value chips (REQ-F-004/005/006) — chip mode
|
|
1416
|
+
floats pill-treated overlays at the same gridline Y positions
|
|
1417
|
+
instead of pushing text outside the plot; `outside` mode (below)
|
|
1418
|
+
is untouched byte-for-byte (AC-F-004). */
|
|
1419
|
+
if (chip) {
|
|
1420
|
+
chipSvg += this.#renderYChips(displayTicks, ticks, safeRange, width, height, p, dims);
|
|
1421
|
+
} else {
|
|
1422
|
+
for (const t of displayTicks) {
|
|
1423
|
+
const gy = p.top + (height - p.top - p.bottom) * (1 - (t - ticks[0]) / safeRange);
|
|
1424
|
+
s += `<text data-y-label x="${p.left - 4}" y="${gy + fs * 0.35}" text-anchor="end" font-size="${fs}">${this.#fmtValue(t)}</text>`;
|
|
1425
|
+
}
|
|
1273
1426
|
}
|
|
1274
1427
|
|
|
1275
|
-
/* X-axis labels — stride based on label width so
|
|
1276
|
-
unless a ratio-matrix mode forces a different
|
|
1277
|
-
|
|
1428
|
+
/* X-axis labels / x-category chips — stride based on label width so
|
|
1429
|
+
they never overlap, unless a ratio-matrix mode forces a different
|
|
1430
|
+
selection. REQ-F-013's 'no-x-chrome' stage (chip mode only, below
|
|
1431
|
+
the chips floor) drops this whole block while keeping gridlines +
|
|
1432
|
+
y-chips. */
|
|
1433
|
+
if (labels && stage !== 'no-x-chrome') {
|
|
1434
|
+
if (chip) {
|
|
1435
|
+
chipSvg += this.#renderXChips(labels, width, height, p, dims, opts);
|
|
1436
|
+
} else {
|
|
1278
1437
|
const plotW = width - p.left - p.right;
|
|
1279
1438
|
const step = plotW / labels.length;
|
|
1280
1439
|
const last = labels.length - 1;
|
|
@@ -1313,9 +1472,299 @@ export class UIChart extends UIElement {
|
|
|
1313
1472
|
const text = esc(opts.abbreviateLabels ? truncateLabel(labels[i], 4) : labels[i]);
|
|
1314
1473
|
s += `<text data-x-label x="${lx}" y="${height - fs * 0.5}" text-anchor="middle" font-size="${ls}">${text}</text>`;
|
|
1315
1474
|
}
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
return { svg: s, chipSvg };
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/* REQ-F-004/005/006 — y-value chips inset along the plot's start (left)
|
|
1483
|
+
* edge at each gridline Y, floating chrome over the zero-inset plot
|
|
1484
|
+
* (REQ-F-001/002). Clamp (1): the top/bottom-most chip's vertical
|
|
1485
|
+
* center clamps inward by chromeInset + half its own height rather
|
|
1486
|
+
* than overflow the box. Corner clearance (3): a chip landing within
|
|
1487
|
+
* --card-radius's worth of the top/bottom-left corner clears that
|
|
1488
|
+
* corner distance instead of the ordinary chrome inset.
|
|
1489
|
+
*
|
|
1490
|
+
* Thin (2), Y-axis (measured, gh#1814 OPEN-4 pass): a real-browser
|
|
1491
|
+
* geometry sweep found the domain-min/-max chip's own clamp (above)
|
|
1492
|
+
* can pull it far enough toward the plot's center to overlap its
|
|
1493
|
+
* immediate unclamped neighbor once ticks sit closely spaced in a
|
|
1494
|
+
* short plot (observed at 7 nice-number ticks in a ~150-180px plot,
|
|
1495
|
+
* well inside the pre-existing 'no-x-chrome'/'full' range — not a
|
|
1496
|
+
* REQ-F-013 floor-value problem, a missing collision guard). Generalized
|
|
1497
|
+
* as a full iterative thin (code-checker finding, 2026-08-21: a
|
|
1498
|
+
* single-neighbor-per-edge check doesn't hold under consumer-overridden
|
|
1499
|
+
* `--chart-chip-font-size`/`--chart-chrome-inset`/`--a-density`, or an
|
|
1500
|
+
* unusually dense `niceScale` tick count) — mirrors #renderXChips' own
|
|
1501
|
+
* iterative `kept` loop and its two-protected-ends fallback exactly,
|
|
1502
|
+
* applied to cy instead of cx: walk in array (tick-ascending) order,
|
|
1503
|
+
* keep the first item, drop any intermediate whose box would overlap
|
|
1504
|
+
* the last KEPT item, force-keep the last item (never drop either end —
|
|
1505
|
+
* it alone carries the domain min/max), let a forced-last collision
|
|
1506
|
+
* evict the intermediate before it, and if only the two protected ends
|
|
1507
|
+
* remain and still overlap, push them apart from their shared midpoint
|
|
1508
|
+
* rather than dropping either. */
|
|
1509
|
+
#renderYChips(displayTicks, ticks, safeRange, width, height, p, dims) {
|
|
1510
|
+
const { chromeInset, chipFontSize, chipPadX, chipPadY, cornerClearance } = dims;
|
|
1511
|
+
const halfH = chipFontSize * 0.6 + chipPadY;
|
|
1512
|
+
const rh = chipFontSize * 1.2 + chipPadY * 2;
|
|
1513
|
+
|
|
1514
|
+
const all = displayTicks.map((t) => {
|
|
1515
|
+
const rawY = p.top + (height - p.top - p.bottom) * (1 - (t - ticks[0]) / safeRange);
|
|
1516
|
+
const cy = Math.min(Math.max(rawY, chromeInset + halfH), height - chromeInset - halfH);
|
|
1517
|
+
const nearCorner = (cy - halfH <= cornerClearance) || (cy + halfH >= height - cornerClearance);
|
|
1518
|
+
const cx = nearCorner ? cornerClearance : chromeInset;
|
|
1519
|
+
const text = this.#fmtValue(t);
|
|
1520
|
+
const rw = String(text).length * chipFontSize * 0.6 + chipPadX * 2;
|
|
1521
|
+
return { cy, cx, rw, text };
|
|
1522
|
+
});
|
|
1523
|
+
if (!all.length) return '';
|
|
1524
|
+
|
|
1525
|
+
const last = all.length - 1;
|
|
1526
|
+
const kept = [all[0]];
|
|
1527
|
+
for (let i = 1; i < last; i++) {
|
|
1528
|
+
const it = all[i];
|
|
1529
|
+
const prev = kept[kept.length - 1];
|
|
1530
|
+
if (Math.abs(it.cy - prev.cy) >= rh) kept.push(it);
|
|
1531
|
+
}
|
|
1532
|
+
if (all.length > 1) kept.push(all[last]);
|
|
1533
|
+
// A forced-last collision evicts the intermediate before it — never the
|
|
1534
|
+
// ends themselves (REQ-F-006(1)'s clamp-not-drop treatment).
|
|
1535
|
+
while (kept.length > 2) {
|
|
1536
|
+
const a = kept[kept.length - 2], b = kept[kept.length - 1];
|
|
1537
|
+
if (Math.abs(b.cy - a.cy) < rh) kept.splice(kept.length - 2, 1);
|
|
1538
|
+
else break;
|
|
1539
|
+
}
|
|
1540
|
+
// Only the two protected ends left and they still overlap: push both
|
|
1541
|
+
// away from their shared midpoint just far enough to touch, not
|
|
1542
|
+
// overlap (mirrors #renderXChips' own AC-F-003 extreme-case fallback).
|
|
1543
|
+
if (kept.length === 2) {
|
|
1544
|
+
const [a, b] = kept;
|
|
1545
|
+
if (Math.abs(a.cy - b.cy) < rh) {
|
|
1546
|
+
const mid = (a.cy + b.cy) / 2;
|
|
1547
|
+
const [top, bottom] = a.cy <= b.cy ? [a, b] : [b, a];
|
|
1548
|
+
top.cy = Math.min(top.cy, mid - rh / 2);
|
|
1549
|
+
bottom.cy = Math.max(bottom.cy, mid + rh / 2);
|
|
1316
1550
|
}
|
|
1317
1551
|
}
|
|
1552
|
+
const items = kept;
|
|
1318
1553
|
|
|
1554
|
+
let s = '';
|
|
1555
|
+
for (const it of items) {
|
|
1556
|
+
s += `<rect data-chip data-chip-axis="y" x="${it.cx}" y="${it.cy - rh / 2}" width="${it.rw}" height="${rh}"/>`;
|
|
1557
|
+
s += `<text data-chip-label data-chip-axis="y" x="${it.cx + chipPadX}" y="${it.cy}" dominant-baseline="central" font-size="${chipFontSize}">${it.text}</text>`;
|
|
1558
|
+
}
|
|
1559
|
+
return s;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/* REQ-F-004/006/010(1) — x-category chips in a band along the plot's
|
|
1563
|
+
* bottom edge, inset within the zero-inset plot rather than pushed
|
|
1564
|
+
* below it. Clamp (1): first/last chip shift inward rather than
|
|
1565
|
+
* overflow. Thin (2): adjacent-overlapping chips drop (never the first
|
|
1566
|
+
* or last, and never below --chart-chip-font-size). Corner clearance
|
|
1567
|
+
* (3): the end chips clear --card-radius at the bottom corners. */
|
|
1568
|
+
#renderXChips(labels, width, height, p, dims, opts) {
|
|
1569
|
+
const { chromeInset, bandGap, chipFontSize, chipPadX, chipPadY, cornerClearance } = dims;
|
|
1570
|
+
const plotW = width - p.left - p.right;
|
|
1571
|
+
const last = labels.length - 1;
|
|
1572
|
+
const step = opts.pointAligned ? plotW / Math.max(last, 1) : plotW / labels.length;
|
|
1573
|
+
const rh = chipFontSize * 1.2 + chipPadY * 2;
|
|
1574
|
+
// REQ-F-010(1) — the band's own clearance from the bottom edge is at
|
|
1575
|
+
// least chromeInset (REQ-F-002 edge safety); bandGap is the named,
|
|
1576
|
+
// separately-tunable gap from the zero-line specifically. chromeInset
|
|
1577
|
+
// dominates by default (8px vs 4px) but bandGap stays live for callers
|
|
1578
|
+
// that retune it independently of the general edge inset.
|
|
1579
|
+
const cy = height - Math.max(chromeInset, bandGap) - rh / 2;
|
|
1580
|
+
|
|
1581
|
+
const items = labels.map((label, i) => {
|
|
1582
|
+
const cx0 = opts.pointAligned ? p.left + step * i : p.left + step * i + step / 2;
|
|
1583
|
+
// Width from the DISPLAYED text, not the raw label (code-checker
|
|
1584
|
+
// finding, 2026-08-21) — `truncateLabel` shortens what's rendered
|
|
1585
|
+
// when `abbreviateLabels` is on; sizing the chip off the untruncated
|
|
1586
|
+
// label reserved more room than the glyphs actually need.
|
|
1587
|
+
const displayText = opts.abbreviateLabels ? truncateLabel(label, 4) : label;
|
|
1588
|
+
const text = esc(displayText);
|
|
1589
|
+
const rw = String(displayText).length * chipFontSize * 0.6 + chipPadX * 2;
|
|
1590
|
+
return { cx0, rw, text };
|
|
1591
|
+
});
|
|
1592
|
+
if (!items.length) return '';
|
|
1593
|
+
|
|
1594
|
+
for (const it of items) {
|
|
1595
|
+
it.cx = Math.min(Math.max(it.cx0, chromeInset + it.rw / 2), width - chromeInset - it.rw / 2);
|
|
1596
|
+
}
|
|
1597
|
+
// Corner clearance for the true end chips.
|
|
1598
|
+
items[0].cx = Math.max(items[0].cx, cornerClearance + items[0].rw / 2);
|
|
1599
|
+
items[last].cx = Math.min(items[last].cx, width - cornerClearance - items[last].rw / 2);
|
|
1600
|
+
|
|
1601
|
+
// REQ-F-006(2) — thin adjacent-overlapping chips, always keeping the
|
|
1602
|
+
// first/last; drop intermediates whose box would intersect the last
|
|
1603
|
+
// KEPT box.
|
|
1604
|
+
const kept = [items[0]];
|
|
1605
|
+
for (let i = 1; i < items.length - 1; i++) {
|
|
1606
|
+
const it = items[i];
|
|
1607
|
+
const prev = kept[kept.length - 1];
|
|
1608
|
+
if (it.cx - it.rw / 2 >= prev.cx + prev.rw / 2) kept.push(it);
|
|
1609
|
+
}
|
|
1610
|
+
if (items.length > 1) kept.push(items[last]);
|
|
1611
|
+
// If the forced-last item still collides with the item just before it,
|
|
1612
|
+
// that intermediate loses the tie (edges win) — never drop the edges
|
|
1613
|
+
// themselves, per REQ-F-006(1)'s clamp-not-drop treatment for ends.
|
|
1614
|
+
while (kept.length > 2) {
|
|
1615
|
+
const a = kept[kept.length - 2], b = kept[kept.length - 1];
|
|
1616
|
+
if (b.cx - b.rw / 2 < a.cx + a.rw / 2) kept.splice(kept.length - 2, 1);
|
|
1617
|
+
else break;
|
|
1618
|
+
}
|
|
1619
|
+
// AC-F-003 ("no two chip boxes intersect") extreme case (code-checker
|
|
1620
|
+
// finding, 2026-08-21): with only the two protected end chips left,
|
|
1621
|
+
// the thinning loop above never runs (it starts at length > 2), so a
|
|
1622
|
+
// sufficiently narrow chart could still overlap them. Neither may be
|
|
1623
|
+
// DROPPED (REQ-F-006(1) clamp-not-drop for ends), so push both away
|
|
1624
|
+
// from their shared midpoint just far enough to touch, not overlap.
|
|
1625
|
+
if (kept.length === 2) {
|
|
1626
|
+
const [a, b] = kept;
|
|
1627
|
+
if (a.cx + a.rw / 2 > b.cx - b.rw / 2) {
|
|
1628
|
+
const boundary = (a.cx + b.cx) / 2;
|
|
1629
|
+
a.cx = Math.min(a.cx, boundary - a.rw / 2);
|
|
1630
|
+
b.cx = Math.max(b.cx, boundary + b.rw / 2);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
let s = '';
|
|
1635
|
+
for (const it of kept) {
|
|
1636
|
+
s += `<rect data-chip data-chip-axis="x" x="${it.cx - it.rw / 2}" y="${cy - rh / 2}" width="${it.rw}" height="${rh}"/>`;
|
|
1637
|
+
s += `<text data-chip-label data-chip-axis="x" x="${it.cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" font-size="${chipFontSize}">${it.text}</text>`;
|
|
1638
|
+
}
|
|
1639
|
+
return s;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/* ── Today-marker (REQ-F-008, ADR-0081) ──────────────────────────
|
|
1643
|
+
* Resolves which datum (if any) is "today": the first row whose
|
|
1644
|
+
* x-axis value string-equals the `today` attribute
|
|
1645
|
+
* (String(datum[x]) === String(today) — the same loose stringify
|
|
1646
|
+
* #fmtValue()/tip() already use elsewhere), or — the ADR's documented
|
|
1647
|
+
* FALLBACK, not the primary mechanism — a row that already carries a
|
|
1648
|
+
* truthy `today` key of its own. Returns -1 when neither identifies a
|
|
1649
|
+
* datum (additive no-op). Cartesian-only by construction: only the 8
|
|
1650
|
+
* cartesian renderers (bar, line, area, scatter, multi-line,
|
|
1651
|
+
* stacked-bar, grouped-bar, composed — gh#1690's list, restated
|
|
1652
|
+
* verbatim here per its own "cannot drift against a live upstream"
|
|
1653
|
+
* posture) ever call this; every radial/part-to-whole type and
|
|
1654
|
+
* sparkline never do, which is what makes REQ-F-016's N/A a structural
|
|
1655
|
+
* fact rather than a runtime check. */
|
|
1656
|
+
#todayIndex() {
|
|
1657
|
+
const xKey = this.x;
|
|
1658
|
+
const data = this.#data;
|
|
1659
|
+
if (this.today) {
|
|
1660
|
+
for (let i = 0; i < data.length; i++) {
|
|
1661
|
+
if (String(data[i]?.[xKey] ?? '') === String(this.today)) return i;
|
|
1662
|
+
}
|
|
1663
|
+
return -1;
|
|
1664
|
+
}
|
|
1665
|
+
return data.findIndex(d => d && d.today);
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/* Baseline dot AT the plot's zero-line at the today-datum's X position,
|
|
1669
|
+
* plus a SHORT tick — never a full-height rule (REQ-F-008). In
|
|
1670
|
+
* `outside` label mode the tick descends into the existing label
|
|
1671
|
+
* gutter below the baseline (unchanged visual budget); in `chip` mode
|
|
1672
|
+
* there is no gutter below the baseline (pad is zero-inset, REQ-F-001),
|
|
1673
|
+
* so the tick runs the opposite direction, up into the x-chip band that
|
|
1674
|
+
* floats just above that same edge — same short-tick anatomy, direction
|
|
1675
|
+
* mirrored because the label chrome sits on the other side of the
|
|
1676
|
+
* baseline in that mode. This is a judgment call the SPEC/ADR don't
|
|
1677
|
+
* pin explicitly — see the build's Findings.
|
|
1678
|
+
*
|
|
1679
|
+
* Chip-mode clamp (code-checker finding, 2026-08-21): `baselineY` in
|
|
1680
|
+
* chip mode IS the chart box's bottom edge (pad is zero-inset,
|
|
1681
|
+
* REQ-F-001) — an unclamped dot centered there has its lower half
|
|
1682
|
+
* clipped by a full-bleed card's corner-rounded `overflow: hidden`
|
|
1683
|
+
* (REQ-F-002/AC-F-001's own edge-safety clause; SVG `overflow: visible`
|
|
1684
|
+
* doesn't escape the ANCESTOR card's clip). Clamped inward by the same
|
|
1685
|
+
* `chromeInset` every other chip-mode chrome element already respects,
|
|
1686
|
+
* exactly like `#renderYChips`'s own vertical clamp. `outside` mode is
|
|
1687
|
+
* untouched (chromeInset is unread there, same AC-F-004 byte-identity
|
|
1688
|
+
* this build holds everywhere else). */
|
|
1689
|
+
#todayMarkerVertical(x, baselineY, dims) {
|
|
1690
|
+
const r = dims.sizeClass === 'sm' ? 2.5 : 3.5;
|
|
1691
|
+
const tickLen = Math.max(6, dims.fontSize);
|
|
1692
|
+
const dir = dims.chipMode ? -1 : 1;
|
|
1693
|
+
const cy = dims.chipMode ? baselineY - Math.max(dims.chromeInset ?? 0, r) : baselineY;
|
|
1694
|
+
return `<circle data-today-dot cx="${x}" cy="${cy}" r="${r}"/>` +
|
|
1695
|
+
`<line data-today-tick x1="${x}" y1="${cy}" x2="${x}" y2="${cy + dir * tickLen}"/>`;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/* REQ-F-008's own mirror clause — "under an orientation where the
|
|
1699
|
+
* category axis runs vertically" (the 2:3 horizontal-bar/stacked-bar/
|
|
1700
|
+
* grouped-bar rows, REQ-R-003) the anatomy mirrors onto that axis: the
|
|
1701
|
+
* dot sits on the VALUE baseline (pad.left, where every horizontal bar
|
|
1702
|
+
* starts) and the tick runs LEFTWARD into the category-label gutter —
|
|
1703
|
+
* there is no x-label band on this transposed axis to speak of, so this
|
|
1704
|
+
* direction is this build's own judgment call, documented here rather
|
|
1705
|
+
* than left implicit. */
|
|
1706
|
+
#todayMarkerHorizontal(y, baselineX, dims) {
|
|
1707
|
+
const r = dims.sizeClass === 'sm' ? 2.5 : 3.5;
|
|
1708
|
+
const tickLen = Math.max(6, dims.fontSize);
|
|
1709
|
+
return `<circle data-today-dot cx="${baselineX}" cy="${y}" r="${r}"/>` +
|
|
1710
|
+
`<line data-today-tick x1="${baselineX}" y1="${y}" x2="${baselineX - tickLen}" y2="${y}"/>`;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/* ── Provisional datum (REQ-F-015/016, ADR-0081 §3) ──────────────
|
|
1714
|
+
* Per-datum `.provisional` truthy flag — NOT a chart-ui attribute.
|
|
1715
|
+
* Uniform across the cartesian list only (gh#1690's list, restated
|
|
1716
|
+
* verbatim, same posture as #todayIndex() above): bar, line, area,
|
|
1717
|
+
* scatter, multi-line, stacked-bar, grouped-bar, composed. Bar-family
|
|
1718
|
+
* treatment (hollow bar, dashed outline) and scatter's hollow-ring dot
|
|
1719
|
+
* are pure CSS ([data-provisional] rules in chart.css) driven by this
|
|
1720
|
+
* one attribute string helper; line/area's dashed-span + faded-area
|
|
1721
|
+
* treatment needs its own geometry (#lineProvisionalOverlay below)
|
|
1722
|
+
* since a single continuous path can't dash just one sub-span of
|
|
1723
|
+
* itself via CSS alone. */
|
|
1724
|
+
#provisionalAttr(datum) {
|
|
1725
|
+
return datum && datum.provisional ? ' data-provisional' : '';
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/* Finds contiguous runs of provisional-flagged points (by index into
|
|
1729
|
+
* `points`) and returns dashed-line + faded-area overlay SVG for each
|
|
1730
|
+
* run — REQ-F-015's "dashed stroke over the provisional span... area
|
|
1731
|
+
* fill suppressed or faded there". The run includes the immediately
|
|
1732
|
+
* preceding point (when one exists) so the dashed overlay visually
|
|
1733
|
+
* connects to the solid line rather than floating detached. The main
|
|
1734
|
+
* `data-line`/`data-area` paths this pairs with are left fully
|
|
1735
|
+
* unchanged (still drawn for the WHOLE series) — this only adds an
|
|
1736
|
+
* overlay on top, so a fixture with no provisional data renders
|
|
1737
|
+
* byte-identically to before this capability existed. `smoothTFactor`
|
|
1738
|
+
* MUST match the caller's own clamped `this.smooth` (code-checker
|
|
1739
|
+
* finding, 2026-08-21) — the overlay's dashed span sits ON TOP of the
|
|
1740
|
+
* solid line/area path, and every caller smooths that path with the
|
|
1741
|
+
* same clamped `t`; passing 0 here drew the overlay as a straight
|
|
1742
|
+
* chord diverging visibly from a curved line whenever `smooth !== 0`. */
|
|
1743
|
+
#lineProvisionalOverlay(points, data, baselineY, dims, smoothTFactor) {
|
|
1744
|
+
let s = '';
|
|
1745
|
+
let runStart = -1;
|
|
1746
|
+
const flush = (endIdxExclusive) => {
|
|
1747
|
+
if (runStart < 0) return;
|
|
1748
|
+
const from = Math.max(runStart - 1, 0);
|
|
1749
|
+
const runPts = points.slice(from, endIdxExclusive);
|
|
1750
|
+
if (runPts.length >= 2) {
|
|
1751
|
+
// Fade rect emitted BEFORE the dashed path (code-checker finding,
|
|
1752
|
+
// 2026-08-21) so the scrim sits underneath, not on top of, the
|
|
1753
|
+
// dashed stroke it's meant to fade — reversed order previously
|
|
1754
|
+
// painted the scrim over the dash's lower half.
|
|
1755
|
+
const minX = runPts[0].x, maxX = runPts[runPts.length - 1].x;
|
|
1756
|
+
const top = Math.min(...runPts.map(p => p.y));
|
|
1757
|
+
s += `<rect data-area-provisional-fade x="${minX}" y="${top}" width="${Math.max(maxX - minX, 0)}" height="${Math.max(baselineY - top, 0)}"/>`;
|
|
1758
|
+
s += `<path data-line-provisional d="${smoothPath(runPts, smoothTFactor)}"/>`;
|
|
1759
|
+
}
|
|
1760
|
+
runStart = -1;
|
|
1761
|
+
};
|
|
1762
|
+
for (let i = 0; i < points.length; i++) {
|
|
1763
|
+
const flagged = !!(data[i] && data[i].provisional);
|
|
1764
|
+
if (flagged && runStart < 0) runStart = i;
|
|
1765
|
+
else if (!flagged && runStart >= 0) flush(i);
|
|
1766
|
+
}
|
|
1767
|
+
flush(points.length);
|
|
1319
1768
|
return s;
|
|
1320
1769
|
}
|
|
1321
1770
|
|
|
@@ -1337,7 +1786,14 @@ export class UIChart extends UIElement {
|
|
|
1337
1786
|
// for the scroll-vs-aggregate reading). Runs before `ticks` is derived
|
|
1338
1787
|
// so the scale reflects the (possibly aggregated) rendered set, not
|
|
1339
1788
|
// the full original one.
|
|
1340
|
-
|
|
1789
|
+
// ADR-0081/REQ-F-008/015 — today-marker + provisional-datum both key
|
|
1790
|
+
// off the ORIGINAL per-datum index; the "+N more" synthetic tail
|
|
1791
|
+
// bucket aggregateTail may produce below has no single x-value or
|
|
1792
|
+
// provisional flag of its own, so both capabilities are scoped out
|
|
1793
|
+
// when aggregation actually occurred (named here, not silently
|
|
1794
|
+
// dropped — see the build's Findings).
|
|
1795
|
+
const aggregated = dims.ratioResolved === '1:1' && vals.length > BAR_1_1_VISIBLE_CAP;
|
|
1796
|
+
if (aggregated) {
|
|
1341
1797
|
({ labels, vals } = aggregateTail(labels, vals, BAR_1_1_VISIBLE_CAP, false));
|
|
1342
1798
|
}
|
|
1343
1799
|
|
|
@@ -1351,7 +1807,7 @@ export class UIChart extends UIElement {
|
|
|
1351
1807
|
// it, but a full column of height for one row per category. This is a
|
|
1352
1808
|
// genuine orientation swap, not the same layout rescaled.
|
|
1353
1809
|
if (dims.ratioResolved === '2:3') {
|
|
1354
|
-
return this.#renderBarHorizontal(dims, labels, vals, ticks, maxVal);
|
|
1810
|
+
return this.#renderBarHorizontal(dims, labels, vals, ticks, maxVal, aggregated ? [] : data);
|
|
1355
1811
|
}
|
|
1356
1812
|
|
|
1357
1813
|
const { width, height, pad } = dims;
|
|
@@ -1364,7 +1820,7 @@ export class UIChart extends UIElement {
|
|
|
1364
1820
|
// REQ-R-003 (bar, 1:1 "square"): abbreviate category labels — a square
|
|
1365
1821
|
// has less width per category than 3:2. The synthetic "+N more" label
|
|
1366
1822
|
// (already short) truncates harmlessly under the same budget.
|
|
1367
|
-
let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
|
|
1823
|
+
let { svg, chipSvg } = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
|
|
1368
1824
|
abbreviateLabels: dims.ratioResolved === '1:1',
|
|
1369
1825
|
});
|
|
1370
1826
|
|
|
@@ -1378,8 +1834,9 @@ export class UIChart extends UIElement {
|
|
|
1378
1834
|
const barH = this.#clampSpan(rawH, plotH);
|
|
1379
1835
|
const bx = pad.left + barW * i + barGap;
|
|
1380
1836
|
const by = pad.top + plotH - barH;
|
|
1837
|
+
const provisional = !aggregated ? this.#provisionalAttr(data[i]) : '';
|
|
1381
1838
|
|
|
1382
|
-
svg += `<path data-bar${tip({ label: labels[i], value: v })} d="${topRoundedBarPath(bx, by, barInner, barH, this.#resolveRadius())}"/>`;
|
|
1839
|
+
svg += `<path data-bar${provisional}${tip({ label: labels[i], value: v })} d="${topRoundedBarPath(bx, by, barInner, barH, this.#resolveRadius())}"/>`;
|
|
1383
1840
|
|
|
1384
1841
|
if (showValues) {
|
|
1385
1842
|
svg += `<text data-value x="${bx + barInner / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
|
|
@@ -1396,6 +1853,14 @@ export class UIChart extends UIElement {
|
|
|
1396
1853
|
svg += `<line data-hit${tip({ label: 'Average', value: avg })} x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}" stroke="transparent" stroke-width="12"/>`;
|
|
1397
1854
|
}
|
|
1398
1855
|
|
|
1856
|
+
if (!aggregated) {
|
|
1857
|
+
const ti = this.#todayIndex();
|
|
1858
|
+
if (ti > -1 && ti < labels.length) {
|
|
1859
|
+
svg += this.#todayMarkerVertical(pad.left + barW * ti + barGap + barInner / 2, pad.top + plotH, dims);
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
svg += chipSvg;
|
|
1399
1864
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
1400
1865
|
}
|
|
1401
1866
|
|
|
@@ -1404,8 +1869,13 @@ export class UIChart extends UIElement {
|
|
|
1404
1869
|
render path rather than a transposed reuse of the vertical one — the
|
|
1405
1870
|
gutter sizing (driven by label WIDTH, not a fixed font multiple), the
|
|
1406
1871
|
grid direction, and the bar path rounding (right end, not top end) all
|
|
1407
|
-
differ structurally, not just by a coordinate swap.
|
|
1408
|
-
|
|
1872
|
+
differ structurally, not just by a coordinate swap. `rows` is the
|
|
1873
|
+
ORIGINAL per-datum objects (empty when the 1:1 aggregate-tail bucket
|
|
1874
|
+
ran — see #renderBar) driving today-marker + provisional lookup;
|
|
1875
|
+
chip-mode label rendering is deliberately NOT mirrored here — this
|
|
1876
|
+
renderer keeps its own always-outside inline axis code unconditionally
|
|
1877
|
+
(named scope-down, see the build's Findings). */
|
|
1878
|
+
#renderBarHorizontal(dims, labels, vals, ticks, maxVal, rows = []) {
|
|
1409
1879
|
const { width, height } = dims;
|
|
1410
1880
|
const fs = dims.fontSize;
|
|
1411
1881
|
const ls = dims.labelSize;
|
|
@@ -1445,9 +1915,10 @@ export class UIChart extends UIElement {
|
|
|
1445
1915
|
const barW = this.#clampSpan(rawW, plotW);
|
|
1446
1916
|
const by = pad.top + barH * i + barGap;
|
|
1447
1917
|
const bx = pad.left;
|
|
1918
|
+
const provisional = this.#provisionalAttr(rows[i]);
|
|
1448
1919
|
|
|
1449
1920
|
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())}"/>`;
|
|
1921
|
+
svg += `<path data-bar${provisional}${tip({ label: labels[i], value: v })} d="${rightRoundedBarPath(bx, by, barW, barInner, this.#resolveRadius())}"/>`;
|
|
1451
1922
|
if (showValues) {
|
|
1452
1923
|
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
1924
|
}
|
|
@@ -1461,6 +1932,12 @@ export class UIChart extends UIElement {
|
|
|
1461
1932
|
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
1933
|
}
|
|
1463
1934
|
|
|
1935
|
+
/* REQ-F-008 mirror — see #todayMarkerHorizontal's own doc comment. */
|
|
1936
|
+
const ti = this.#todayIndex();
|
|
1937
|
+
if (ti > -1 && ti < labels.length && rows.length) {
|
|
1938
|
+
svg += this.#todayMarkerHorizontal(pad.top + barH * ti + barGap + barInner / 2, pad.left, dims);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1464
1941
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
1465
1942
|
}
|
|
1466
1943
|
|
|
@@ -1492,7 +1969,7 @@ export class UIChart extends UIElement {
|
|
|
1492
1969
|
// density") — two DISTINCT treatments, not one mode reused for both.
|
|
1493
1970
|
const isArea = this.type === 'area';
|
|
1494
1971
|
const ratio = dims.ratioResolved;
|
|
1495
|
-
let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
|
|
1972
|
+
let { svg, chipSvg } = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
|
|
1496
1973
|
thinGrid: ratio !== '3:2',
|
|
1497
1974
|
vertical: isArea && ratio === '2:3',
|
|
1498
1975
|
xTickMode: ratio === '2:3' ? 'sparse' : ratio === '1:1' ? 'everyNth' : undefined,
|
|
@@ -1514,6 +1991,10 @@ export class UIChart extends UIElement {
|
|
|
1514
1991
|
const t = Math.max(0, Math.min(1, this.smooth));
|
|
1515
1992
|
svg += `<path data-area d="${smoothAreaPath(points, baseline, t)}"/>`;
|
|
1516
1993
|
svg += `<path data-line d="${smoothPath(points, t)}"/>`;
|
|
1994
|
+
// REQ-F-015 (line/area) — dashed stroke + faded area over any
|
|
1995
|
+
// provisional span; the main paths above are unchanged, this overlay
|
|
1996
|
+
// ADDS on top so a fixture with no provisional data is byte-identical.
|
|
1997
|
+
svg += this.#lineProvisionalOverlay(points, data, baseline, dims, t);
|
|
1517
1998
|
|
|
1518
1999
|
// Density tuning for sm: smaller dots, no value labels.
|
|
1519
2000
|
const isSm = dims.sizeClass === 'sm';
|
|
@@ -1523,8 +2004,9 @@ export class UIChart extends UIElement {
|
|
|
1523
2004
|
const showAverage = !this.noAverage && vals.length > 1 && !isSm && !this.noGrid;
|
|
1524
2005
|
|
|
1525
2006
|
points.forEach((p, i) => {
|
|
2007
|
+
const provisional = this.#provisionalAttr(data[i]);
|
|
1526
2008
|
if (this.#shouldRenderDot(i, points.length)) {
|
|
1527
|
-
svg += `<circle data-dot cx="${p.x}" cy="${p.y}" r="${dotR}"/>`;
|
|
2009
|
+
svg += `<circle data-dot${provisional} cx="${p.x}" cy="${p.y}" r="${dotR}"/>`;
|
|
1528
2010
|
}
|
|
1529
2011
|
svg += `<circle data-hit${tip({ label: p.label, value: p.v })} cx="${p.x}" cy="${p.y}" r="${hitR}" fill="transparent"/>`;
|
|
1530
2012
|
if (showValues) {
|
|
@@ -1542,6 +2024,12 @@ export class UIChart extends UIElement {
|
|
|
1542
2024
|
svg += `<line data-hit${tip({ label: 'Average', value: avg })} x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}" stroke="transparent" stroke-width="12"/>`;
|
|
1543
2025
|
}
|
|
1544
2026
|
|
|
2027
|
+
const ti = this.#todayIndex();
|
|
2028
|
+
if (ti > -1 && ti < points.length) {
|
|
2029
|
+
svg += this.#todayMarkerVertical(points[ti].x, baseline, dims);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
svg += chipSvg;
|
|
1545
2033
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
1546
2034
|
}
|
|
1547
2035
|
|
|
@@ -1926,7 +2414,7 @@ export class UIChart extends UIElement {
|
|
|
1926
2414
|
|
|
1927
2415
|
// REQ-R-003 (scatter, 1:1): "axis labels abbreviated" — same
|
|
1928
2416
|
// truncation treatment as bar@1:1's own square-container cell.
|
|
1929
|
-
let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
|
|
2417
|
+
let { svg, chipSvg } = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
|
|
1930
2418
|
abbreviateLabels: ratio === '1:1',
|
|
1931
2419
|
});
|
|
1932
2420
|
|
|
@@ -1941,10 +2429,20 @@ export class UIChart extends UIElement {
|
|
|
1941
2429
|
const px = pad.left + step * i;
|
|
1942
2430
|
const rawY = pad.top + plotH - ((maxVal - minVal) ? ((vals[i] - minVal) / (maxVal - minVal)) * plotH : 0);
|
|
1943
2431
|
const py = this.#clampCoord(rawY, pad.top, pad.top + plotH);
|
|
1944
|
-
|
|
2432
|
+
// REQ-F-015 (scatter) — hollow ring dot for a provisional datum;
|
|
2433
|
+
// CSS ([data-dot][data-provisional]) swaps the fill for a stroke-only
|
|
2434
|
+
// ring rather than touching this render loop's own geometry.
|
|
2435
|
+
const provisional = this.#provisionalAttr(data[i]);
|
|
2436
|
+
svg += `<circle data-dot data-scatter${provisional} cx="${px}" cy="${py}" r="${dotR}"/>`;
|
|
1945
2437
|
svg += `<circle data-hit${tip({ label: labels[i], value: vals[i] })} cx="${px}" cy="${py}" r="${hitR}" fill="transparent"/>`;
|
|
1946
2438
|
}
|
|
1947
2439
|
|
|
2440
|
+
const ti = this.#todayIndex();
|
|
2441
|
+
if (ti > -1 && ti < vals.length) {
|
|
2442
|
+
svg += this.#todayMarkerVertical(pad.left + step * ti, pad.top + plotH, dims);
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
svg += chipSvg;
|
|
1948
2446
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
1949
2447
|
}
|
|
1950
2448
|
|
|
@@ -2642,7 +3140,7 @@ export class UIChart extends UIElement {
|
|
|
2642
3140
|
const barInner = barW * 0.6;
|
|
2643
3141
|
const barGap = (barW - barInner) / 2;
|
|
2644
3142
|
|
|
2645
|
-
let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
|
|
3143
|
+
let { svg, chipSvg } = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
|
|
2646
3144
|
|
|
2647
3145
|
/* Bar series (slot 0) — "primary series only fully rendered" (2:3) is
|
|
2648
3146
|
this same unconditional render at every ratio; the primary axis
|
|
@@ -2654,7 +3152,11 @@ export class UIChart extends UIElement {
|
|
|
2654
3152
|
const barH = this.#clampSpan(rawH, plotH);
|
|
2655
3153
|
const bx = pad.left + barW * i + barGap;
|
|
2656
3154
|
const by = pad.top + plotH - barH;
|
|
2657
|
-
|
|
3155
|
+
// REQ-F-015 (composed) — "both treatments on their respective
|
|
3156
|
+
// series": the bar series gets bar-family's hollow/dashed
|
|
3157
|
+
// treatment for a provisional row.
|
|
3158
|
+
const provisional = this.#provisionalAttr(data[i]);
|
|
3159
|
+
svg += `<path${provisional}${this.#seriesFill(0, barKey)}${tip({ label: labels[i], value: v, series: barKey })} d="${topRoundedBarPath(bx, by, barInner, barH, this.#resolveRadius())}"/>`;
|
|
2658
3160
|
}
|
|
2659
3161
|
}
|
|
2660
3162
|
|
|
@@ -2666,7 +3168,14 @@ export class UIChart extends UIElement {
|
|
|
2666
3168
|
the same axis, so the line renders as a small self-scaled
|
|
2667
3169
|
sparkline (own min/max, no shared axis) tucked into the plot's
|
|
2668
3170
|
top-right corner instead — the same "inset trend" reading a
|
|
2669
|
-
stat-tile's own corner sparkline uses elsewhere in this file.
|
|
3171
|
+
stat-tile's own corner sparkline uses elsewhere in this file.
|
|
3172
|
+
REQ-F-008/015 — today-marker + the line series' provisional
|
|
3173
|
+
treatment are deliberately NOT mirrored into this inset: it has
|
|
3174
|
+
no shared axis/baseline of its own (self-scaled min/max), the
|
|
3175
|
+
same structural reason REQ-F-016 already treats sparkline as
|
|
3176
|
+
N/A — named scope-down, see the build's Findings. The BAR
|
|
3177
|
+
series above still gets both capabilities unconditionally at
|
|
3178
|
+
every ratio including 2:3. */
|
|
2670
3179
|
const insetW = plotW * 0.34;
|
|
2671
3180
|
const insetH = Math.min(plotH * 0.26, insetW * 0.6);
|
|
2672
3181
|
const insetX = pad.left + plotW - insetW - 2;
|
|
@@ -2714,13 +3223,27 @@ export class UIChart extends UIElement {
|
|
|
2714
3223
|
? ` data-slice="1" data-deemphasized-tint style="fill: var(--md-sys-color-neutral-on-surface-variant)"`
|
|
2715
3224
|
: this.#seriesFill(1, lineKey);
|
|
2716
3225
|
svg += `<path data-line${lineAttrs} d="${smoothPath(points, t)}"/>`;
|
|
2717
|
-
|
|
2718
|
-
|
|
3226
|
+
// REQ-F-015 (composed) — "both treatments on their respective
|
|
3227
|
+
// series": the line series gets line/area's own dashed-span
|
|
3228
|
+
// overlay for the same provisional rows the bar series above
|
|
3229
|
+
// renders hollow/dashed for.
|
|
3230
|
+
svg += this.#lineProvisionalOverlay(points, data, pad.top + plotH, dims, t);
|
|
3231
|
+
points.forEach((p, pi) => {
|
|
3232
|
+
const provisional = this.#provisionalAttr(data[pi]);
|
|
3233
|
+
svg += `<circle data-dot${dotAttrs}${provisional} cx="${p.x}" cy="${p.y}" r="3"/>`;
|
|
2719
3234
|
svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: lineKey })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
|
|
2720
|
-
}
|
|
3235
|
+
});
|
|
2721
3236
|
}
|
|
2722
3237
|
}
|
|
2723
3238
|
|
|
3239
|
+
// REQ-F-008 — one today-marker per category (not per series), keyed
|
|
3240
|
+
// off the bar series' own coordinate system (unaffected by the 2:3
|
|
3241
|
+
// inset-sparkline branch above, which reuses the same barW/pad).
|
|
3242
|
+
const ti = this.#todayIndex();
|
|
3243
|
+
if (ti > -1 && ti < data.length) {
|
|
3244
|
+
svg += this.#todayMarkerVertical(pad.left + barW * ti + barW / 2, pad.top + plotH, dims);
|
|
3245
|
+
}
|
|
3246
|
+
|
|
2724
3247
|
this.#legendData = [
|
|
2725
3248
|
{ label: barKey, key: barKey, slot: 0 },
|
|
2726
3249
|
// CodeRabbit (PR #1662) — the 1:1 de-emphasis above swaps the
|
|
@@ -2732,6 +3255,7 @@ export class UIChart extends UIElement {
|
|
|
2732
3255
|
{ label: lineKey, key: lineKey, slot: 1, deemphasized: ratio === '1:1' },
|
|
2733
3256
|
];
|
|
2734
3257
|
|
|
3258
|
+
svg += chipSvg;
|
|
2735
3259
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
2736
3260
|
}
|
|
2737
3261
|
|
|
@@ -2924,7 +3448,7 @@ export class UIChart extends UIElement {
|
|
|
2924
3448
|
|
|
2925
3449
|
// REQ-R-003 (stacked-bar, 1:1 "square"): abbreviate category labels —
|
|
2926
3450
|
// same truncation treatment as bar@1:1's own square-container cell.
|
|
2927
|
-
let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
|
|
3451
|
+
let { svg, chipSvg } = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
|
|
2928
3452
|
abbreviateLabels: ratio === '1:1',
|
|
2929
3453
|
});
|
|
2930
3454
|
|
|
@@ -2955,7 +3479,12 @@ export class UIChart extends UIElement {
|
|
|
2955
3479
|
const isTop = k === segCount - 1;
|
|
2956
3480
|
const r = this.#resolveRadius();
|
|
2957
3481
|
|
|
2958
|
-
|
|
3482
|
+
// REQ-F-015 (bar family) — provisional state is a per-ROW flag:
|
|
3483
|
+
// every segment in that category's stack renders the hollow/
|
|
3484
|
+
// dashed treatment together (the whole period is incomplete, not
|
|
3485
|
+
// one series within it).
|
|
3486
|
+
const provisional = this.#provisionalAttr(data[i]);
|
|
3487
|
+
const attrs = `${provisional}${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })}`;
|
|
2959
3488
|
|
|
2960
3489
|
if (isTop) {
|
|
2961
3490
|
// Top segment (or single segment) — round top corners only.
|
|
@@ -2970,6 +3499,12 @@ export class UIChart extends UIElement {
|
|
|
2970
3499
|
}
|
|
2971
3500
|
}
|
|
2972
3501
|
|
|
3502
|
+
const ti = this.#todayIndex();
|
|
3503
|
+
if (ti > -1 && ti < data.length) {
|
|
3504
|
+
svg += this.#todayMarkerVertical(pad.left + barW * ti + barW / 2, pad.top + plotH, dims);
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
svg += chipSvg;
|
|
2973
3508
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
2974
3509
|
}
|
|
2975
3510
|
|
|
@@ -3030,7 +3565,8 @@ export class UIChart extends UIElement {
|
|
|
3030
3565
|
const segW = clampedEnd - bx;
|
|
3031
3566
|
stackX = clampedEnd;
|
|
3032
3567
|
const isEnd = k === segCount - 1;
|
|
3033
|
-
const
|
|
3568
|
+
const provisional = this.#provisionalAttr(data[i]);
|
|
3569
|
+
const attrs = `${provisional}${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })}`;
|
|
3034
3570
|
|
|
3035
3571
|
if (isEnd) {
|
|
3036
3572
|
// End segment (or single segment) — round the value-end corners
|
|
@@ -3042,6 +3578,12 @@ export class UIChart extends UIElement {
|
|
|
3042
3578
|
}
|
|
3043
3579
|
}
|
|
3044
3580
|
|
|
3581
|
+
/* REQ-F-008 mirror — see #todayMarkerHorizontal's own doc comment. */
|
|
3582
|
+
const ti = this.#todayIndex();
|
|
3583
|
+
if (ti > -1 && ti < labels.length) {
|
|
3584
|
+
svg += this.#todayMarkerHorizontal(pad.top + barH * ti + barH / 2, pad.left, dims);
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3045
3587
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
3046
3588
|
}
|
|
3047
3589
|
|
|
@@ -3085,11 +3627,14 @@ export class UIChart extends UIElement {
|
|
|
3085
3627
|
// before category count drops)": category labels abbreviate first
|
|
3086
3628
|
// (same treatment as bar/stacked-bar's own 1:1 cells), freeing width
|
|
3087
3629
|
// for the bars themselves rather than ever dropping a category.
|
|
3088
|
-
let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
|
|
3630
|
+
let { svg, chipSvg } = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
|
|
3089
3631
|
abbreviateLabels: ratio === '1:1',
|
|
3090
3632
|
});
|
|
3091
3633
|
|
|
3092
3634
|
for (let i = 0; i < data.length; i++) {
|
|
3635
|
+
// REQ-F-015 (bar family) — per-row provisional flag applies to every
|
|
3636
|
+
// sub-bar in that category's group together.
|
|
3637
|
+
const provisional = this.#provisionalAttr(data[i]);
|
|
3093
3638
|
for (let k = 0; k < keys.length; k++) {
|
|
3094
3639
|
if (this.#isSeriesHidden(keys[k])) continue;
|
|
3095
3640
|
const v = +(data[i][keys[k]] ?? 0);
|
|
@@ -3097,7 +3642,7 @@ export class UIChart extends UIElement {
|
|
|
3097
3642
|
const barH = this.#clampSpan(rawH, plotH);
|
|
3098
3643
|
const bx = pad.left + groupW * i + groupPad + (subBarW + barGap) * k;
|
|
3099
3644
|
const by = pad.top + plotH - barH;
|
|
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())}"/>`;
|
|
3645
|
+
svg += `<path${provisional}${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })} d="${topRoundedBarPath(bx, by, subBarW, barH, this.#resolveRadius())}"/>`;
|
|
3101
3646
|
|
|
3102
3647
|
if (!this.noValues) {
|
|
3103
3648
|
svg += `<text data-value x="${bx + subBarW / 2}" y="${by - 4}" text-anchor="middle" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
|
|
@@ -3105,6 +3650,12 @@ export class UIChart extends UIElement {
|
|
|
3105
3650
|
}
|
|
3106
3651
|
}
|
|
3107
3652
|
|
|
3653
|
+
const ti = this.#todayIndex();
|
|
3654
|
+
if (ti > -1 && ti < data.length) {
|
|
3655
|
+
svg += this.#todayMarkerVertical(pad.left + groupW * ti + groupW / 2, pad.top + plotH, dims);
|
|
3656
|
+
}
|
|
3657
|
+
|
|
3658
|
+
svg += chipSvg;
|
|
3108
3659
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
3109
3660
|
}
|
|
3110
3661
|
|
|
@@ -3149,6 +3700,7 @@ export class UIChart extends UIElement {
|
|
|
3149
3700
|
|
|
3150
3701
|
for (let i = 0; i < labels.length; i++) {
|
|
3151
3702
|
const groupY = pad.top + groupH * i;
|
|
3703
|
+
const provisional = this.#provisionalAttr(data[i]);
|
|
3152
3704
|
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
3705
|
|
|
3154
3706
|
for (let k = 0; k < keys.length; k++) {
|
|
@@ -3157,7 +3709,7 @@ export class UIChart extends UIElement {
|
|
|
3157
3709
|
const rawW = (maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotW : 0;
|
|
3158
3710
|
const barW = this.#clampSpan(rawW, plotW);
|
|
3159
3711
|
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())}"/>`;
|
|
3712
|
+
svg += `<path${provisional}${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
3713
|
|
|
3162
3714
|
if (!this.noValues) {
|
|
3163
3715
|
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>`;
|
|
@@ -3165,6 +3717,12 @@ export class UIChart extends UIElement {
|
|
|
3165
3717
|
}
|
|
3166
3718
|
}
|
|
3167
3719
|
|
|
3720
|
+
/* REQ-F-008 mirror — see #todayMarkerHorizontal's own doc comment. */
|
|
3721
|
+
const ti = this.#todayIndex();
|
|
3722
|
+
if (ti > -1 && ti < labels.length) {
|
|
3723
|
+
svg += this.#todayMarkerHorizontal(pad.top + groupH * ti + groupH / 2, pad.left, dims);
|
|
3724
|
+
}
|
|
3725
|
+
|
|
3168
3726
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
3169
3727
|
}
|
|
3170
3728
|
|
|
@@ -3187,7 +3745,7 @@ export class UIChart extends UIElement {
|
|
|
3187
3745
|
const plotW = width - pad.left - pad.right;
|
|
3188
3746
|
const step = plotW / Math.max(data.length - 1, 1);
|
|
3189
3747
|
|
|
3190
|
-
let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, { pointAligned: true });
|
|
3748
|
+
let { svg, chipSvg } = this.#gridAndAxes(width, height, ticks, labels, pad, dims, { pointAligned: true });
|
|
3191
3749
|
|
|
3192
3750
|
// REQ-R-003 (multi-line) — a progressive de-emphasis across the three
|
|
3193
3751
|
// ratios rather than one fixed identification mechanism:
|
|
@@ -3235,12 +3793,17 @@ export class UIChart extends UIElement {
|
|
|
3235
3793
|
|
|
3236
3794
|
/* Line */
|
|
3237
3795
|
svg += `<path data-line${this.#seriesStroke(k % 10, keys[k])}${deemphAttr} d="${smoothPath(points, t)}"/>`;
|
|
3796
|
+
// REQ-F-015 (line/area, per-series) — the row-level provisional flag
|
|
3797
|
+
// applies to every series at that x-column; each series draws its
|
|
3798
|
+
// own dashed/faded overlay.
|
|
3799
|
+
svg += this.#lineProvisionalOverlay(points, data, baseline, dims, t);
|
|
3238
3800
|
|
|
3239
3801
|
/* Dots + hit targets. Hit circles deliberately omit data-slice so
|
|
3240
3802
|
they aren't caught by the circle[data-slice] fill rule in CSS. */
|
|
3241
3803
|
points.forEach((p, i) => {
|
|
3804
|
+
const provisional = this.#provisionalAttr(data[i]);
|
|
3242
3805
|
if (this.#shouldRenderDot(i, points.length)) {
|
|
3243
|
-
svg += `<circle data-dot${this.#seriesFill(k % 10, keys[k])}${deemphAttr} cx="${p.x}" cy="${p.y}" r="3"/>`;
|
|
3806
|
+
svg += `<circle data-dot${this.#seriesFill(k % 10, keys[k])}${deemphAttr}${provisional} cx="${p.x}" cy="${p.y}" r="3"/>`;
|
|
3244
3807
|
}
|
|
3245
3808
|
svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: keys[k] })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
|
|
3246
3809
|
});
|
|
@@ -3251,8 +3814,15 @@ export class UIChart extends UIElement {
|
|
|
3251
3814
|
}
|
|
3252
3815
|
}
|
|
3253
3816
|
|
|
3817
|
+
// REQ-F-008 — one today-marker per category (not per series).
|
|
3818
|
+
const ti = this.#todayIndex();
|
|
3819
|
+
if (ti > -1 && ti < data.length) {
|
|
3820
|
+
svg += this.#todayMarkerVertical(pad.left + step * ti, pad.top + plotH, dims);
|
|
3821
|
+
}
|
|
3822
|
+
|
|
3254
3823
|
this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
|
|
3255
3824
|
|
|
3825
|
+
svg += chipSvg;
|
|
3256
3826
|
return { svg, viewBox: `0 0 ${width} ${height}` };
|
|
3257
3827
|
}
|
|
3258
3828
|
|