@bongos/core 1.19.511 → 1.19.513
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/.bongos-core.json +85 -50
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/config/branding.neutral.json +3 -2
- package/docs/adr/0238-the-join-door-is-a-provisioning-settings-policy-key.md +57 -0
- package/docs/adr/0239-a-recommendation-is-a-table-that-carries-its-own-reason.md +177 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +40 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +158 -151
- package/docs/copy-registry.json +313 -229
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/hall-kit.js +309 -0
- package/modules/hall-ui/public/hall-render.js +10 -38
- package/modules/hall-ui/public/profile-charts.js +58 -65
- package/modules/hall-ui/public/profile.css +9 -18
- package/modules/hall-ui/public/style.css +101 -15
- package/modules/hall-ui/public/watch.css +6 -14
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/watch.js +139 -54
- package/modules/provisioning/provisioning.js +27 -1
- package/modules/provisioning/recommendations.js +260 -0
- package/modules/provisioning/routes/provisioning.js +29 -0
- package/modules/provisioning/tests/provisioning.mjs +17 -14
- package/modules/public-landing/public/projects.html +125 -0
- package/modules/ui-design/kit/serve.js +4 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/branding.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/currency_label.mjs +5 -4
- package/tests/hall_audit.mjs +84 -0
- package/tests/hall_kit_charts.mjs +328 -0
- package/tests/hall_profile_world.mjs +27 -6
- package/tests/helpers.mjs +161 -0
- package/tests/joinability_invite_only.mjs +10 -2
- package/tests/project_door_ui.mjs +267 -0
- package/tests/projects_hub.mjs +73 -1
- package/tests/projects_hub_pre_uat.mjs +4 -1
- package/tests/provision_settings_apply.mjs +8 -7
- package/tests/provisioning_recommendations.mjs +448 -0
- package/tests/provisioning_settings.mjs +65 -6
- package/tests/provisioning_settings_apply.mjs +3 -3
- package/tests/provisioning_settings_env.mjs +10 -8
- package/tests/task_detail_ui.mjs +1 -153
- package/tests/watch_applications_queue.mjs +381 -0
|
@@ -1459,5 +1459,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1459
1459
|
landed since 1.19.509 with no explicit bump. run 33791237400. (task 1002620)
|
|
1460
1460
|
1.19.511 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1461
1461
|
landed since 1.19.510 with no explicit bump. run 33792868990. (task 1002620)
|
|
1462
|
+
1.19.512 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1463
|
+
landed since 1.19.511 with no explicit bump. run 33793208383. (task 1002620)
|
|
1464
|
+
1.19.513 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1465
|
+
landed since 1.19.512 with no explicit bump. run 33795977371. (task 1002620)
|
|
1462
1466
|
---------------------------------------------------------------------------
|
|
1463
1467
|
```
|
|
@@ -668,6 +668,8 @@ function createClient(opts = {}) {
|
|
|
668
668
|
postProvisioningInstancesIdTeardown: (args) => request("POST", "/provisioning/instances/{id}/teardown", { hasBody: true }, args),
|
|
669
669
|
// GET /provisioning/onboard-plan — rank: public — GET /provisioning/onboard-plan
|
|
670
670
|
getProvisioningOnboardPlan: (args) => request("GET", "/provisioning/onboard-plan", { hasBody: false }, args),
|
|
671
|
+
// GET /provisioning/recommendations — rank: public — GET /provisioning/recommendations
|
|
672
|
+
getProvisioningRecommendations: (args) => request("GET", "/provisioning/recommendations", { hasBody: false }, args),
|
|
671
673
|
// GET /provisioning/slug-available — rank: any-builder — GET /provisioning/slug-available
|
|
672
674
|
getProvisioningSlugAvailable: (args) => request("GET", "/provisioning/slug-available", { hasBody: false }, args),
|
|
673
675
|
// GET /provisioning/starter-bundles — rank: public — GET /provisioning/starter-bundles
|
|
@@ -607,6 +607,306 @@
|
|
|
607
607
|
`<img class="ship-visual-thumb" src="${esc(url)}" alt="${esc(alt)}" loading="lazy"></a>`;
|
|
608
608
|
}
|
|
609
609
|
|
|
610
|
+
// ---- charts (task 1003445) ------------------------------------------------
|
|
611
|
+
//
|
|
612
|
+
// Charts were the one primitive the kit never got, which is exactly why the
|
|
613
|
+
// hall grew FOUR drawing sites that did not know about each other. Three of
|
|
614
|
+
// them draw the SAME horizontal bar: the Curve's "kinds of work shipped"
|
|
615
|
+
// (hall-render.js), Watch's Review-quality trend (watch.js), and the
|
|
616
|
+
// profile's craft mix (profile-charts.js `mixChart`). The fourth is the
|
|
617
|
+
// Curve's line chart, `sparkline()`. The module's hard rule names
|
|
618
|
+
// pager/filter/tab/rail and stops, so nothing failed when a fifth copy was
|
|
619
|
+
// one paste away.
|
|
620
|
+
//
|
|
621
|
+
// Two shapes cover every charted surface the hall has: a LINE (with an
|
|
622
|
+
// optional running total, which is what a treasury or "works by now" series
|
|
623
|
+
// wants) and a horizontal BAR SERIES. Pure string builders like the rest of
|
|
624
|
+
// the kit — no state, no render loop, no measuring pass.
|
|
625
|
+
//
|
|
626
|
+
// Colour is the caller's on a line, because the Curve spends four different
|
|
627
|
+
// accents across its four charts; `color` therefore stays a CSS value rather
|
|
628
|
+
// than becoming an enum. Bars take a `tone` instead, since the three call
|
|
629
|
+
// sites differed only in tone (accent vs ink) and geometry — and geometry
|
|
630
|
+
// rides component custom properties, so a fifth chart needs no new CSS and
|
|
631
|
+
// no new drawing code.
|
|
632
|
+
|
|
633
|
+
const CHART_W = 260;
|
|
634
|
+
const CHART_H = 46;
|
|
635
|
+
const CHART_PAD = 3;
|
|
636
|
+
|
|
637
|
+
// A CSS length, for a value that lands in a style attribute rather than in
|
|
638
|
+
// text — so it is WHITELISTED, not escaped: escaping would leave a ';' free
|
|
639
|
+
// to end the declaration and start another.
|
|
640
|
+
//
|
|
641
|
+
// Both halves are load-bearing. The positive charset admits letters and
|
|
642
|
+
// parentheses (it has to: `minmax(72px, 110px)`, `calc(100% - 4px)`), which
|
|
643
|
+
// means `url(...)` is spellable within it — so the negative test is what
|
|
644
|
+
// actually stops a track pulling a remote asset, and is not the redundancy
|
|
645
|
+
// it looks like. Hoisted to module scope: these ran per option, per render.
|
|
646
|
+
const CSS_LEN_OK = /^[0-9a-z%.,()\s+*/-]+$/i;
|
|
647
|
+
const CSS_LEN_BAD = /url|var\s*\(|[;{}:]/i;
|
|
648
|
+
const CSS_LEN = (v) => {
|
|
649
|
+
const s = String(v);
|
|
650
|
+
return CSS_LEN_OK.test(s) && !CSS_LEN_BAD.test(s) ? s : '';
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
// Coerce to finite numbers. A gap-filled feed sends nulls, and a null must
|
|
654
|
+
// read as zero rather than poisoning a min/max or a running sum.
|
|
655
|
+
function chartNums(values) {
|
|
656
|
+
return (values || []).map((v) => (Number.isFinite(Number(v)) ? Number(v) : 0));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Running total, for a series whose question is "how much by now" rather
|
|
660
|
+
// than "how much that day".
|
|
661
|
+
function cumulate(values) {
|
|
662
|
+
let run = 0;
|
|
663
|
+
return chartNums(values).map((v) => (run += v));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// The line geometry, separated from the markup so a chart that needs the
|
|
667
|
+
// maths without this SVG can take just this. Returns { points, x, y, nums }.
|
|
668
|
+
//
|
|
669
|
+
// There is deliberately no `cumulative` option: `cumulate` is exported, so a
|
|
670
|
+
// running-total line is `sparklineHtml(OTBKit.cumulate(values))` — one way to
|
|
671
|
+
// do it rather than two, and no option whose only exercise is its own test.
|
|
672
|
+
function linePoints(values, opts = {}) {
|
|
673
|
+
const W = opts.width || CHART_W;
|
|
674
|
+
const H = opts.height || CHART_H;
|
|
675
|
+
const pad = opts.pad === undefined ? CHART_PAD : opts.pad;
|
|
676
|
+
const nums = chartNums(values);
|
|
677
|
+
const n = nums.length;
|
|
678
|
+
// A flat series has span 0 and would divide by zero; 1 puts it mid-box.
|
|
679
|
+
const min = n ? Math.min(...nums) : 0;
|
|
680
|
+
const span = (n ? Math.max(...nums) : 0) - min || 1;
|
|
681
|
+
const x = (i) => (n === 1 ? W / 2 : pad + (i * (W - 2 * pad)) / (n - 1));
|
|
682
|
+
const y = (v) => H - pad - ((v - min) / span) * (H - 2 * pad);
|
|
683
|
+
return {
|
|
684
|
+
nums, x, y, width: W, height: H, pad,
|
|
685
|
+
points: nums.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' '),
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// A line + filled area. opts: { width, height, color, ariaLabel }
|
|
690
|
+
function sparklineHtml(values, opts = {}) {
|
|
691
|
+
const g = linePoints(values, opts);
|
|
692
|
+
const color = opts.color || 'var(--accent)';
|
|
693
|
+
if (!g.nums.length) {
|
|
694
|
+
return `<svg class="kit-spark" viewBox="0 0 ${g.width} ${g.height}" preserveAspectRatio="none"`
|
|
695
|
+
+ ' role="img" aria-label="no data"></svg>';
|
|
696
|
+
}
|
|
697
|
+
const last = g.nums[g.nums.length - 1];
|
|
698
|
+
const area = `${g.pad},${g.height - g.pad} ${g.points} ${g.width - g.pad},${g.height - g.pad}`;
|
|
699
|
+
const label = opts.ariaLabel || `trend, latest ${last}`;
|
|
700
|
+
return `<svg class="kit-spark" viewBox="0 0 ${g.width} ${g.height}" preserveAspectRatio="none"`
|
|
701
|
+
+ ` role="img" aria-label="${esc(label)}">`
|
|
702
|
+
+ `<polyline class="kit-spark__area" points="${area}" fill="${esc(color)}" />`
|
|
703
|
+
+ `<polyline class="kit-spark__line" points="${g.points}" fill="none" stroke="${esc(color)}" />`
|
|
704
|
+
// One point draws no line, so it gets a dot or the chart reads as empty.
|
|
705
|
+
+ (g.nums.length === 1
|
|
706
|
+
? `<circle cx="${g.x(0).toFixed(1)}" cy="${g.y(last).toFixed(1)}" r="2.4" fill="${esc(color)}" />`
|
|
707
|
+
: '')
|
|
708
|
+
+ '</svg>';
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Horizontal labelled bars. rows: [{ label, value, valueLabel }].
|
|
712
|
+
// opts: { max, tone: 'accent'|'neutral', labelWidth, valueWidth, barHeight,
|
|
713
|
+
// pill, ariaLabel, list }
|
|
714
|
+
//
|
|
715
|
+
// `max` is the caller's when several series must share one scale; otherwise
|
|
716
|
+
// the largest value here. It floors at 1 so an all-zero set draws empty
|
|
717
|
+
// tracks instead of dividing by zero.
|
|
718
|
+
function barSeriesHtml(rows, opts = {}) {
|
|
719
|
+
const list = (rows || []).filter((r) => r && r.label !== undefined && r.label !== null);
|
|
720
|
+
if (!list.length) return '';
|
|
721
|
+
const vals = list.map((r) => (Number.isFinite(Number(r.value)) ? Number(r.value) : 0));
|
|
722
|
+
const givenMax = Number(opts.max);
|
|
723
|
+
const max = Number.isFinite(givenMax) && givenMax > 0 ? givenMax : Math.max(1, ...vals);
|
|
724
|
+
// Geometry and tone ride custom properties, so a caller differs from the
|
|
725
|
+
// others by an OPTION and never by a second class family. Values are
|
|
726
|
+
// CSS-literal, so they are whitelisted rather than escaped — an arbitrary
|
|
727
|
+
// string in a style attribute is how a caller smuggles in a declaration.
|
|
728
|
+
const cssLen = CSS_LEN;
|
|
729
|
+
const radius = opts.pill ? 'var(--radius-pill)' : cssLen(opts.radius);
|
|
730
|
+
const style = [
|
|
731
|
+
opts.labelWidth ? `--kit-bars-label-w:${cssLen(opts.labelWidth)}` : '',
|
|
732
|
+
opts.valueWidth ? `--kit-bars-val-w:${cssLen(opts.valueWidth)}` : '',
|
|
733
|
+
opts.barHeight ? `--kit-bars-h:${cssLen(opts.barHeight)}` : '',
|
|
734
|
+
radius ? `--kit-bars-radius:${radius}` : '',
|
|
735
|
+
opts.track === 'soft' ? '--kit-bars-track:var(--rule-soft)' : '',
|
|
736
|
+
].filter((d) => d && !d.endsWith(':')).join(';');
|
|
737
|
+
// `list` opts in to list semantics — the profile's mix is announced as a
|
|
738
|
+
// list; the Curve's kinds are decoration beside a figure that already
|
|
739
|
+
// names them. The track is aria-hidden either way: it carries no
|
|
740
|
+
// information the value cell does not already say in words.
|
|
741
|
+
const roleAttr = opts.list ? ' role="list"' : '';
|
|
742
|
+
const rowRole = opts.list ? ' role="listitem"' : '';
|
|
743
|
+
return `<div class="kit-bars${opts.tone === 'neutral' ? ' kit-bars--neutral' : ''}"`
|
|
744
|
+
+ (style ? ` style="${esc(style)}"` : '')
|
|
745
|
+
+ roleAttr
|
|
746
|
+
+ (opts.ariaLabel ? ` aria-label="${esc(opts.ariaLabel)}"` : '')
|
|
747
|
+
+ '>'
|
|
748
|
+
+ list.map((r, i) => {
|
|
749
|
+
const w = Math.round((vals[i] / max) * 100);
|
|
750
|
+
const val = r.valueLabel === undefined || r.valueLabel === null
|
|
751
|
+
? String(vals[i])
|
|
752
|
+
: String(r.valueLabel);
|
|
753
|
+
return `<div class="kit-bars__row"${rowRole}>`
|
|
754
|
+
+ `<span class="kit-bars__label">${esc(r.label)}</span>`
|
|
755
|
+
+ `<span class="kit-bars__track" aria-hidden="true">`
|
|
756
|
+
+ `<span class="kit-bars__fill" style="width:${w}%"></span></span>`
|
|
757
|
+
+ `<span class="kit-bars__val">${esc(val)}</span>`
|
|
758
|
+
+ '</div>';
|
|
759
|
+
}).join('')
|
|
760
|
+
+ '</div>';
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// ---- plotted charts: the axis frame both of them shared ------------------
|
|
764
|
+
//
|
|
765
|
+
// A step LINE over a time axis and a COLUMN series are different shapes from
|
|
766
|
+
// an index-scaled sparkline, so they are not sparklineHtml with an option.
|
|
767
|
+
// What they ARE is the same plot: identical viewBox, identical axis rule,
|
|
768
|
+
// identical hidden crosshair, identical pair of end ticks, identical
|
|
769
|
+
// role/tabindex/aria contract. That frame was written twice in
|
|
770
|
+
// profile-charts.js, and the goal's next three charts (the treasury line on
|
|
771
|
+
// /drachmae, repo-health's trend on /watch) each want it again — which is
|
|
772
|
+
// what makes it a primitive rather than a shared local.
|
|
773
|
+
//
|
|
774
|
+
// The seam is the same one the bars use: the kit draws the SERIES and the
|
|
775
|
+
// plot, the page owns the SURFACE (figure, caption, live readout, the
|
|
776
|
+
// <details> table twin) and the wiring. Date semantics stay with the caller
|
|
777
|
+
// too — it passes x0/x1/yMax already resolved, so the kit never learns what
|
|
778
|
+
// a week is.
|
|
779
|
+
|
|
780
|
+
const PLOT_W = 320;
|
|
781
|
+
const PLOT_H = 132;
|
|
782
|
+
// t: the end label's room. b: the axis band.
|
|
783
|
+
const PLOT_PAD = { l: 6, r: 8, t: 20, b: 24 };
|
|
784
|
+
|
|
785
|
+
function plotBox(opts = {}) {
|
|
786
|
+
const width = opts.width || PLOT_W;
|
|
787
|
+
const height = opts.height || PLOT_H;
|
|
788
|
+
const pad = { ...PLOT_PAD, ...(opts.pad || {}) };
|
|
789
|
+
return { width, height, pad, base: height - pad.b };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// A STEP line over a time axis: the value holds until the next recorded
|
|
793
|
+
// point, then rises — which is what a cumulative "how much by now" series
|
|
794
|
+
// means, and why this is not a straight polyline between samples.
|
|
795
|
+
// points: [{ x, value }] in data space, sorted. Returns { d, area, xs }.
|
|
796
|
+
function timeStepGeometry(points, opts = {}) {
|
|
797
|
+
const { width, height, pad, base } = plotBox(opts);
|
|
798
|
+
const list = points || [];
|
|
799
|
+
if (!list.length) return { d: '', area: '', xs: [], base };
|
|
800
|
+
const x0 = Number(opts.x0 ?? list[0].x);
|
|
801
|
+
const x1 = Number(opts.x1 ?? list[list.length - 1].x);
|
|
802
|
+
const last = list[list.length - 1];
|
|
803
|
+
// A span of 0 (one point, no window) would divide by zero; yMax 0 likewise.
|
|
804
|
+
const spanX = (x1 - x0) || 1;
|
|
805
|
+
const yMax = Number(opts.yMax ?? last.value) || 1;
|
|
806
|
+
const px = (t) => pad.l + ((t - x0) / spanX) * (width - pad.l - pad.r);
|
|
807
|
+
const py = (v) => height - pad.b - (v / yMax) * (height - pad.t - pad.b);
|
|
808
|
+
let d = `M${px(list[0].x).toFixed(1)},${py(list[0].value).toFixed(1)}`;
|
|
809
|
+
list.forEach((p, i) => {
|
|
810
|
+
if (i === 0) return;
|
|
811
|
+
// across at the PREVIOUS height, then up: the step.
|
|
812
|
+
d += ` L${px(p.x).toFixed(1)},${py(list[i - 1].value).toFixed(1)}`
|
|
813
|
+
+ ` L${px(p.x).toFixed(1)},${py(p.value).toFixed(1)}`;
|
|
814
|
+
});
|
|
815
|
+
d += ` L${px(x1).toFixed(1)},${py(last.value).toFixed(1)}`;
|
|
816
|
+
const area = `${d} L${px(x1).toFixed(1)},${base} L${px(list[0].x).toFixed(1)},${base} Z`;
|
|
817
|
+
return {
|
|
818
|
+
d, area, base, px, py,
|
|
819
|
+
xs: list.map((p) => px(p.x)),
|
|
820
|
+
endX: px(x1),
|
|
821
|
+
endY: py(last.value),
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// The line + its area + the end dot, ready to drop into plotSvgHtml.
|
|
826
|
+
function timeStepSeriesHtml(points, opts = {}) {
|
|
827
|
+
const g = timeStepGeometry(points, opts);
|
|
828
|
+
if (!g.d) return { body: '', xs: [], geometry: g };
|
|
829
|
+
return {
|
|
830
|
+
geometry: g,
|
|
831
|
+
xs: g.xs,
|
|
832
|
+
body: `<path class="kit-plot__area" d="${g.area}"></path>`
|
|
833
|
+
+ `<path class="kit-plot__line" d="${g.d}"></path>`
|
|
834
|
+
+ `<circle class="kit-plot__dot" cx="${g.endX.toFixed(1)}" cy="${g.endY.toFixed(1)}" r="4"></circle>`,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Columns: at most 24px thick, a rounded cap on a square base, a 2px gap
|
|
839
|
+
// between neighbours. A zero bucket draws a 1px stub on the axis rather than
|
|
840
|
+
// nothing, so a gap in the series still reads as a week that happened.
|
|
841
|
+
function columnSeriesHtml(values, opts = {}) {
|
|
842
|
+
const { width, height, pad, base } = plotBox(opts);
|
|
843
|
+
const nums = chartNums(values);
|
|
844
|
+
const n = nums.length;
|
|
845
|
+
if (!n) return { body: '', xs: [], base, py: () => base };
|
|
846
|
+
const max = Math.max(1, ...nums);
|
|
847
|
+
const slot = (width - pad.l - pad.r) / n;
|
|
848
|
+
const bw = Math.min(24, Math.max(2, slot - 2));
|
|
849
|
+
const py = (v) => base - (v / max) * (height - pad.t - pad.b);
|
|
850
|
+
const xs = nums.map((_, i) => pad.l + i * slot + slot / 2);
|
|
851
|
+
const body = nums.map((v, i) => {
|
|
852
|
+
const x = xs[i] - bw / 2;
|
|
853
|
+
const y = py(v);
|
|
854
|
+
const h = base - y;
|
|
855
|
+
if (!v) {
|
|
856
|
+
return `<rect class="kit-plot__col kit-plot__col--empty" x="${x.toFixed(1)}"`
|
|
857
|
+
+ ` y="${base - 1}" width="${bw.toFixed(1)}" height="1"></rect>`;
|
|
858
|
+
}
|
|
859
|
+
const r = Math.min(4, h / 2, bw / 2);
|
|
860
|
+
const d = `M${x.toFixed(1)},${base} V${(y + r).toFixed(1)}`
|
|
861
|
+
+ ` Q${x.toFixed(1)},${y.toFixed(1)} ${(x + r).toFixed(1)},${y.toFixed(1)}`
|
|
862
|
+
+ ` H${(x + bw - r).toFixed(1)}`
|
|
863
|
+
+ ` Q${(x + bw).toFixed(1)},${y.toFixed(1)} ${(x + bw).toFixed(1)},${(y + r).toFixed(1)}`
|
|
864
|
+
+ ` V${base} Z`;
|
|
865
|
+
return `<path class="kit-plot__col" d="${d}"></path>`;
|
|
866
|
+
}).join('');
|
|
867
|
+
return { body, xs, base, py, max };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// The plot itself: the series a geometry built, on the axis, with the hidden
|
|
871
|
+
// crosshair a caller's readout moves and the two end ticks. `tabindex="0"` is
|
|
872
|
+
// deliberate and paired with the readout — the chart is walkable by arrow
|
|
873
|
+
// key, so it has to be reachable by tab.
|
|
874
|
+
function plotSvgHtml(opts = {}) {
|
|
875
|
+
const { width, height, pad, base } = plotBox(opts);
|
|
876
|
+
const tick = (x, text, anchor) => (text
|
|
877
|
+
? `<text class="kit-plot__tick" x="${x}" y="${height - 6}"`
|
|
878
|
+
+ `${anchor ? ` text-anchor="${anchor}"` : ''}>${esc(text)}</text>`
|
|
879
|
+
: '');
|
|
880
|
+
return `<svg class="kit-plot" viewBox="0 0 ${width} ${height}" role="img" tabindex="0"`
|
|
881
|
+
+ ` aria-label="${esc(opts.ariaLabel || '')}">`
|
|
882
|
+
+ (opts.series || '')
|
|
883
|
+
+ `<line class="kit-plot__axis" x1="${pad.l}" y1="${base}" x2="${width - pad.r}" y2="${base}"></line>`
|
|
884
|
+
+ `<line class="kit-plot__hair" x1="0" y1="${pad.t}" x2="0" y2="${base}" style="visibility:hidden"></line>`
|
|
885
|
+
+ (opts.label || '')
|
|
886
|
+
+ tick(pad.l, opts.leftTick)
|
|
887
|
+
+ tick(width - pad.r, opts.rightTick, 'end')
|
|
888
|
+
+ '</svg>';
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// The one value a plot labels — the endpoint on a line, the peak on columns.
|
|
892
|
+
// Only ever one: labelling every point is the thing the table twin is for.
|
|
893
|
+
function plotValueLabelHtml(x, y, text, anchor) {
|
|
894
|
+
return `<text class="kit-plot__end" x="${Number(x).toFixed(1)}" y="${Number(y).toFixed(1)}"`
|
|
895
|
+
+ `${anchor ? ` text-anchor="${anchor}"` : ''}>${esc(text)}</text>`;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Title + chart + optional foot — the frame all four sites drew by hand.
|
|
899
|
+
// `inner` is already-built chart HTML, so the frame never needs to know
|
|
900
|
+
// which shape it holds. That is what makes the fifth chart free.
|
|
901
|
+
function chartCardHtml(title, inner, foot, emptyMsg) {
|
|
902
|
+
const body = inner || `<div class="kit-chart__empty">${esc(emptyMsg || 'no data in window')}</div>`;
|
|
903
|
+
return '<div class="kit-chart">'
|
|
904
|
+
+ (title ? `<div class="kit-chart__title">${esc(title)}</div>` : '')
|
|
905
|
+
+ body
|
|
906
|
+
+ (foot ? `<div class="kit-chart__foot">${esc(foot)}</div>` : '')
|
|
907
|
+
+ '</div>';
|
|
908
|
+
}
|
|
909
|
+
|
|
610
910
|
const api = {
|
|
611
911
|
esc,
|
|
612
912
|
// pager (low-level exports so pages can adopt incrementally)
|
|
@@ -615,6 +915,15 @@
|
|
|
615
915
|
pageOffset, renderServerPage,
|
|
616
916
|
// composed primitives
|
|
617
917
|
makeLedger, makeFilterBar, makeTabs, makeSectionRail,
|
|
918
|
+
// charts (task 1003445) — the ONE place the hall draws a series.
|
|
919
|
+
// linePoints/cumulate are the maths on their own, for a chart that wants
|
|
920
|
+
// the geometry without this SVG (the profile's timeChart).
|
|
921
|
+
sparklineHtml, barSeriesHtml, chartCardHtml, linePoints, cumulate,
|
|
922
|
+
// plotted charts: the axis frame + the two series shapes that ride it
|
|
923
|
+
plotSvgHtml, plotValueLabelHtml, timeStepSeriesHtml, timeStepGeometry, columnSeriesHtml,
|
|
924
|
+
// plotBox is exported so a caller mapping pointer x back into viewBox
|
|
925
|
+
// space reads the plot's width instead of re-declaring the number.
|
|
926
|
+
plotBox,
|
|
618
927
|
// states
|
|
619
928
|
emptyStateHtml, skeletonRows, collapsibleCard,
|
|
620
929
|
// ship-time task visual (task 1003109)
|
|
@@ -1073,30 +1073,12 @@
|
|
|
1073
1073
|
`).join('');
|
|
1074
1074
|
}
|
|
1075
1075
|
const analyticsState = { builderId: null, isArchon: false, scope: 'self', self: null, eco: null };
|
|
1076
|
+
// The line drawing moved to OTBKit.sparklineHtml (task 1003445) — it was one
|
|
1077
|
+
// of four chart implementations in this surface. This wrapper keeps the
|
|
1078
|
+
// Curve's own default colour, which is NOT the kit's: these four charts each
|
|
1079
|
+
// spend a different accent, and --accent-deep is the first one's.
|
|
1076
1080
|
function sparkline(values, opts = {}) {
|
|
1077
|
-
|
|
1078
|
-
const H = opts.height || 46;
|
|
1079
|
-
const pad = 3;
|
|
1080
|
-
const color = opts.color || 'var(--accent-deep)';
|
|
1081
|
-
const nums = (values || []).map((v) => (Number.isFinite(Number(v)) ? Number(v) : 0));
|
|
1082
|
-
if (nums.length === 0) {
|
|
1083
|
-
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="no data"></svg>`;
|
|
1084
|
-
}
|
|
1085
|
-
const min = Math.min(...nums);
|
|
1086
|
-
const max = Math.max(...nums);
|
|
1087
|
-
const span = max - min || 1;
|
|
1088
|
-
const n = nums.length;
|
|
1089
|
-
const x = (i) => (n === 1 ? W / 2 : pad + (i * (W - 2 * pad)) / (n - 1));
|
|
1090
|
-
const y = (v) => H - pad - ((v - min) / span) * (H - 2 * pad);
|
|
1091
|
-
const pts = nums.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`);
|
|
1092
|
-
const line = pts.join(' ');
|
|
1093
|
-
const area = `${pad},${H - pad} ${line} ${(W - pad)},${H - pad}`;
|
|
1094
|
-
const last = nums[n - 1];
|
|
1095
|
-
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="trend, latest ${last}">
|
|
1096
|
-
<polyline class="spark__area" points="${area}" fill="${color}" />
|
|
1097
|
-
<polyline class="spark__line" points="${line}" fill="none" stroke="${color}" />
|
|
1098
|
-
${n === 1 ? `<circle cx="${x(0).toFixed(1)}" cy="${y(last).toFixed(1)}" r="2.4" fill="${color}" />` : ''}
|
|
1099
|
-
</svg>`;
|
|
1081
|
+
return window.OTBKit.sparklineHtml(values, { color: 'var(--accent-deep)', ...opts });
|
|
1100
1082
|
}
|
|
1101
1083
|
function statCard(label, value, sub) {
|
|
1102
1084
|
return `<div class="curve__stat">
|
|
@@ -1107,11 +1089,7 @@
|
|
|
1107
1089
|
}
|
|
1108
1090
|
function chartCard(title, values, color, footer) {
|
|
1109
1091
|
const has = Array.isArray(values) && values.length > 0;
|
|
1110
|
-
return
|
|
1111
|
-
<div class="curve__chart-title">${escapeHtml(title)}</div>
|
|
1112
|
-
${has ? sparkline(values, { color }) : `<div class="curve__chart-empty">no data in window</div>`}
|
|
1113
|
-
${footer ? `<div class="curve__chart-foot">${escapeHtml(footer)}</div>` : ''}
|
|
1114
|
-
</div>`;
|
|
1092
|
+
return window.OTBKit.chartCardHtml(title, has ? sparkline(values, { color }) : '', footer);
|
|
1115
1093
|
}
|
|
1116
1094
|
function renderCurveBody() {
|
|
1117
1095
|
const headlineEl = $('#curve-headline');
|
|
@@ -1177,17 +1155,11 @@
|
|
|
1177
1155
|
// Top kinds — horizontal bars.
|
|
1178
1156
|
const kinds = (data.top_kinds || []).slice(0, 8);
|
|
1179
1157
|
if (kinds.length) {
|
|
1180
|
-
const maxN = Math.max(...kinds.map((k) => Number(k.n)));
|
|
1181
1158
|
kindsEl.innerHTML =
|
|
1182
|
-
`<div class="
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
<span class="curve__kind-label">${escapeHtml(k.kind)}</span>
|
|
1187
|
-
<span class="curve__kind-bar"><span class="curve__kind-fill" style="width:${w}%"></span></span>
|
|
1188
|
-
<span class="curve__kind-n">${k.n}</span>
|
|
1189
|
-
</div>`;
|
|
1190
|
-
}).join('');
|
|
1159
|
+
`<div class="kit-chart__title">Kinds of work shipped</div>` +
|
|
1160
|
+
window.OTBKit.barSeriesHtml(
|
|
1161
|
+
kinds.map((k) => ({ label: k.kind, value: k.n })),
|
|
1162
|
+
);
|
|
1191
1163
|
} else {
|
|
1192
1164
|
kindsEl.innerHTML = '';
|
|
1193
1165
|
}
|
|
@@ -55,9 +55,11 @@
|
|
|
55
55
|
const PACE_MAX_DAYS = 365;
|
|
56
56
|
const RANGE_KEY = 'otb.profile.chartRange';
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
// The plot's own viewBox width, read from the kit rather than re-declared
|
|
59
|
+
// (task 1003445): wireReadout maps a pointer's x back into viewBox space, so
|
|
60
|
+
// a second copy of this number silently mis-aims the crosshair the day the
|
|
61
|
+
// kit's plot changes size. Its height and padding are the kit's alone now.
|
|
62
|
+
const W = window.OTBKit.plotBox().width;
|
|
61
63
|
|
|
62
64
|
const esc = (s) => window.OTB.escapeHtml(s);
|
|
63
65
|
const $ = (sel) => window.OTB.$(sel);
|
|
@@ -107,9 +109,12 @@
|
|
|
107
109
|
const d = midnight(t);
|
|
108
110
|
byDay.set(d, (byDay.get(d) || 0) + Number(valueOf(r)) || 0);
|
|
109
111
|
}
|
|
112
|
+
// The grouping is this function's; the running total is the kit's
|
|
113
|
+
// (task 1003445), so the hall has ONE accumulator rather than two that
|
|
114
|
+
// could disagree about what a hole in the series means.
|
|
110
115
|
const days = [...byDay.keys()].sort((a, b) => a - b);
|
|
111
|
-
|
|
112
|
-
return days.map((d) => ({ day: d, value:
|
|
116
|
+
const totals = window.OTBKit.cumulate(days.map((d) => byDay.get(d)));
|
|
117
|
+
return days.map((d, i) => ({ day: d, value: totals[i] }));
|
|
113
118
|
}
|
|
114
119
|
|
|
115
120
|
// Clip a cumulative series to [windowStart, ∞) for drawing. The line must
|
|
@@ -165,7 +170,7 @@
|
|
|
165
170
|
function wireReadout(fig, xs, textAt) {
|
|
166
171
|
const svg = fig.querySelector('svg');
|
|
167
172
|
const read = fig.querySelector('.pchart__read');
|
|
168
|
-
const hair = fig.querySelector('.
|
|
173
|
+
const hair = fig.querySelector('.kit-plot__hair');
|
|
169
174
|
if (!svg || !read || !xs.length) return;
|
|
170
175
|
let idx = xs.length - 1;
|
|
171
176
|
const show = (i) => {
|
|
@@ -226,39 +231,28 @@
|
|
|
226
231
|
wire() {},
|
|
227
232
|
};
|
|
228
233
|
}
|
|
234
|
+
// task 1003445: the geometry and the plot are OTBKit's; the time semantics
|
|
235
|
+
// stay here, because the kit must not learn what a week or midnight is.
|
|
229
236
|
const today = midnight(now);
|
|
230
237
|
const x0 = windowStart != null ? windowStart : points[0].day;
|
|
231
238
|
const x1 = Math.max(today, x0 + WEEK);
|
|
232
239
|
const last = points[points.length - 1];
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (i === 0) return;
|
|
240
|
-
const prev = points[i - 1].value;
|
|
241
|
-
d += ` L${px(p.day).toFixed(1)},${py(prev).toFixed(1)} L${px(p.day).toFixed(1)},${py(p.value).toFixed(1)}`;
|
|
242
|
-
});
|
|
243
|
-
d += ` L${px(x1).toFixed(1)},${py(last.value).toFixed(1)}`;
|
|
244
|
-
const area = `${d} L${px(x1).toFixed(1)},${base} L${px(points[0].day).toFixed(1)},${base} Z`;
|
|
245
|
-
const endX = px(x1);
|
|
246
|
-
const endY = py(last.value);
|
|
247
|
-
const xs = points.map((p) => px(p.day));
|
|
240
|
+
const series = window.OTBKit.timeStepSeriesHtml(
|
|
241
|
+
points.map((p) => ({ x: p.day, value: p.value })),
|
|
242
|
+
{ x0, x1, yMax: last.value },
|
|
243
|
+
);
|
|
244
|
+
const { endX, endY } = series.geometry;
|
|
245
|
+
const xs = series.xs;
|
|
248
246
|
const html = `<figure class="pchart" id="${esc(id)}">
|
|
249
247
|
<figcaption class="pchart__title">${esc(title)}</figcaption>
|
|
250
248
|
<p class="pchart__read" aria-live="polite"></p>
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
<text class="pchart__end" x="${endX.toFixed(1)}" y="${(endY - 8).toFixed(1)}" text-anchor="end">${esc(valueText(last.value))}</text>
|
|
259
|
-
<text class="pchart__tick" x="${PAD.l}" y="${H - 6}">${esc(fmtDay(x0))}</text>
|
|
260
|
-
<text class="pchart__tick" x="${W - PAD.r}" y="${H - 6}" text-anchor="end">today</text>
|
|
261
|
-
</svg>
|
|
249
|
+
${window.OTBKit.plotSvgHtml({
|
|
250
|
+
series: series.body,
|
|
251
|
+
ariaLabel: `${title}: ${valueText(last.value)} as of today; arrow keys walk the days`,
|
|
252
|
+
label: window.OTBKit.plotValueLabelHtml(endX, endY - 8, valueText(last.value), 'end'),
|
|
253
|
+
leftTick: fmtDay(x0),
|
|
254
|
+
rightTick: 'today',
|
|
255
|
+
})}
|
|
262
256
|
${twinHtml(['Day', title], points.map((p) => [fmtDay(p.day), valueText(p.value)]))}
|
|
263
257
|
<p class="pchart__foot">${esc(footer)}</p>
|
|
264
258
|
</figure>`;
|
|
@@ -274,39 +268,27 @@
|
|
|
274
268
|
// Weekly columns: at most 24px thick, a 4px rounded cap on a square base, a
|
|
275
269
|
// 2px surface gap between neighbours, the tallest column labelled.
|
|
276
270
|
function columnChart({ id, title, buckets, footer }) {
|
|
271
|
+
// task 1003445: the columns and the plot are OTBKit's. What stays here is
|
|
272
|
+
// which value gets labelled (the peak) and what the ticks say — the
|
|
273
|
+
// reading of the chart, not its drawing.
|
|
277
274
|
const n = buckets.length;
|
|
278
|
-
const
|
|
279
|
-
const slot = (W - PAD.l - PAD.r) / n;
|
|
280
|
-
const bw = Math.min(24, Math.max(2, slot - 2));
|
|
281
|
-
const base = H - PAD.b;
|
|
282
|
-
const py = (v) => base - (v / max) * (H - PAD.t - PAD.b);
|
|
283
|
-
const xs = buckets.map((_, i) => PAD.l + i * slot + slot / 2);
|
|
275
|
+
const cols = window.OTBKit.columnSeriesHtml(buckets.map((b) => b.n));
|
|
284
276
|
const peak = buckets.reduce((best, b, i) => (b.n > buckets[best].n ? i : best), 0);
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
const y = py(b.n);
|
|
288
|
-
const h = base - y;
|
|
289
|
-
if (!b.n) return `<rect class="pchart__col pchart__col--empty" x="${x.toFixed(1)}" y="${base - 1}" width="${bw.toFixed(1)}" height="1"></rect>`;
|
|
290
|
-
const r = Math.min(4, h / 2, bw / 2);
|
|
291
|
-
const d = `M${x.toFixed(1)},${base} V${(y + r).toFixed(1)} Q${x.toFixed(1)},${y.toFixed(1)} ${(x + r).toFixed(1)},${y.toFixed(1)} H${(x + bw - r).toFixed(1)} Q${(x + bw).toFixed(1)},${y.toFixed(1)} ${(x + bw).toFixed(1)},${(y + r).toFixed(1)} V${base} Z`;
|
|
292
|
-
return `<path class="pchart__col" d="${d}"></path>`;
|
|
293
|
-
}).join('');
|
|
277
|
+
const xs = cols.xs;
|
|
278
|
+
const total = buckets.reduce((s, b) => s + b.n, 0);
|
|
294
279
|
const label = buckets[peak].n
|
|
295
|
-
?
|
|
280
|
+
? window.OTBKit.plotValueLabelHtml(xs[peak], cols.py(buckets[peak].n) - 6, buckets[peak].n, 'middle')
|
|
296
281
|
: '';
|
|
297
|
-
const total = buckets.reduce((s, b) => s + b.n, 0);
|
|
298
282
|
const html = `<figure class="pchart" id="${esc(id)}">
|
|
299
283
|
<figcaption class="pchart__title">${esc(title)}</figcaption>
|
|
300
284
|
<p class="pchart__read" aria-live="polite"></p>
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
<text class="pchart__tick" x="${W - PAD.r}" y="${H - 6}" text-anchor="end">this week</text>
|
|
309
|
-
</svg>
|
|
285
|
+
${window.OTBKit.plotSvgHtml({
|
|
286
|
+
series: cols.body,
|
|
287
|
+
ariaLabel: `${title}: ${total} in ${n} weeks; arrow keys walk the weeks`,
|
|
288
|
+
label,
|
|
289
|
+
leftTick: fmtDay(buckets[0].start),
|
|
290
|
+
rightTick: 'this week',
|
|
291
|
+
})}
|
|
310
292
|
${twinHtml(['Week of', 'Works'], buckets.map((b) => [fmtDay(b.start), String(b.n)]))}
|
|
311
293
|
<p class="pchart__foot">${esc(footer)}</p>
|
|
312
294
|
</figure>`;
|
|
@@ -321,18 +303,29 @@
|
|
|
321
303
|
|
|
322
304
|
// Horizontal bars for the craft mix. Each row is its own table row already —
|
|
323
305
|
// label, bar, count — so it needs no twin; the count at the tip is the value.
|
|
306
|
+
// task 1003445: the bars are OTBKit.barSeriesHtml — this was the hall's
|
|
307
|
+
// FOURTH chart drawing site and its third copy of the same horizontal bar
|
|
308
|
+
// (the Curve's kinds and Watch's trend were the other two). The figure, the
|
|
309
|
+
// caption and the foot stay here: they are this page's composition, and the
|
|
310
|
+
// kit deliberately draws a series rather than owning a surface. The list
|
|
311
|
+
// semantics travel as an option, because the mix IS announced as a list.
|
|
324
312
|
function mixChart({ id, title, mix, footer }) {
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
313
|
+
const bars = window.OTBKit.barSeriesHtml(
|
|
314
|
+
mix.map((k) => ({ label: k.label, value: k.n, valueLabel: fmtNum(k.n) })),
|
|
315
|
+
{
|
|
316
|
+
list: true,
|
|
317
|
+
ariaLabel: title,
|
|
318
|
+
labelWidth: 'minmax(72px, 110px)',
|
|
319
|
+
// The bar grows FROM the axis, so its left edge stays square.
|
|
320
|
+
radius: '0 4px 4px 0',
|
|
321
|
+
track: 'soft',
|
|
322
|
+
},
|
|
323
|
+
);
|
|
331
324
|
return {
|
|
332
325
|
id,
|
|
333
326
|
html: `<figure class="pchart" id="${esc(id)}">
|
|
334
327
|
<figcaption class="pchart__title">${esc(title)}</figcaption>
|
|
335
|
-
|
|
328
|
+
${bars || `<p class="pchart__foot">nothing in this window</p>`}
|
|
336
329
|
<p class="pchart__foot">${esc(footer)}</p>
|
|
337
330
|
</figure>`,
|
|
338
331
|
wire() {},
|