@mthanh/vespercli 2.2.0 → 2.3.0
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/README.md +6 -3
- package/assets/CATALOG.md +9 -2
- package/assets/claude/.claude-plugin/marketplace.json +9 -9
- package/assets/claude/.claude-plugin/plugin.json +1 -1
- package/assets/claude/agents/code-planner.md +1 -1
- package/assets/claude/agents/code-reviewer.md +1 -1
- package/assets/claude/agents/code-tester.md +1 -1
- package/assets/claude/agents/copywriter.md +1 -1
- package/assets/claude/agents/feature-wrap-document.md +1 -1
- package/assets/claude/agents/janitor.md +1 -1
- package/assets/claude/agents/modernization.md +1 -1
- package/assets/claude/agents/principal-software-engineer.md +1 -1
- package/assets/claude/agents/refine-issue.md +1 -1
- package/assets/claude/agents/se-system-architecture-reviewer.md +1 -1
- package/assets/claude/agents/se-technical-writer.md +1 -1
- package/assets/claude/agents/skill-creator.md +1 -1
- package/assets/claude/agents/task-researcher.md +1 -1
- package/assets/claude/agents/technical-product-manager.md +1 -1
- package/assets/claude/agents/vision-analyst.md +1 -1
- package/assets/claude/agents/vitest.md +1 -1
- package/assets/claude/agents/workspace-learner.md +2 -2
- package/assets/claude/packs/backend/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/backend/agents/firebase-developer.md +1 -1
- package/assets/claude/packs/backend/agents/nodejs-express-developer.md +1 -1
- package/assets/claude/packs/docs/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/frontend/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/frontend/agents/expert-nextjs-developer.md +1 -1
- package/assets/claude/packs/frontend/agents/expert-react-frontend-engineer.md +1 -1
- package/assets/claude/packs/frontend/agents/jest-react-component-analyzer.md +1 -1
- package/assets/claude/packs/frontend/agents/jest-react-scenario-scanner.md +1 -1
- package/assets/claude/packs/frontend/agents/jest-react-test-generator.md +1 -1
- package/assets/claude/packs/frontend/agents/jest-react-test-planner.md +1 -1
- package/assets/claude/packs/frontend/skills/design-taste-frontend/LICENSE +21 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/SKILL.md +529 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/UPSTREAM.json +7 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/app.css +216 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/base.css +105 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/base.js +37 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/charts.js +617 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/scenes/stage.css +28 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/scenes/stage.js +133 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/evals/evals.json +76 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/app.json +1327 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/directions.json +1078 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/heroes.json +1086 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/motions.json +92 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/palettes.json +1485 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/photos.json +7240 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/sections.json +4429 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/sites.json +8350 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/type.json +2024 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/app-screens.md +1199 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/colour.md +141 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/copy.md +177 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/critique.md +182 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/imagery.md +791 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/layout.md +292 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/motion.md +980 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/research.md +356 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/scenes.md +174 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/tells.md +115 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/type.md +170 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/assets.mjs +865 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/draw.mjs +584 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/inspect.mjs +303 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lib/app-draw.mjs +203 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lib/cdp.mjs +248 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lib/probe.mjs +909 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lib/rules.mjs +243 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lint.mjs +119 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/scenes.mjs +124 -0
- package/assets/claude/packs/graphify/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/integrations/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/playwright/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/playwright/agents/playwright-component-analyzer.md +1 -1
- package/assets/claude/packs/playwright/agents/playwright-scenario-scanner.md +1 -1
- package/assets/claude/packs/playwright/agents/playwright-test-generator.md +1 -1
- package/assets/claude/packs/playwright/agents/playwright-test-planner.md +1 -1
- package/assets/claude/packs/playwright/agents/playwright-tester.md +1 -1
- package/assets/claude/packs/polyglot/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/styles/.claude-plugin/plugin.json +1 -1
- package/assets/copilot/agents/code-planner.agent.md +1 -1
- package/assets/copilot/agents/code-reviewer.agent.md +1 -1
- package/assets/copilot/agents/code-tester.agent.md +1 -1
- package/assets/copilot/agents/copywriter.agent.md +1 -1
- package/assets/copilot/agents/feature-wrap-document.agent.md +1 -1
- package/assets/copilot/agents/janitor.agent.md +1 -1
- package/assets/copilot/agents/modernization.agent.md +1 -1
- package/assets/copilot/agents/principal-software-engineer.agent.md +1 -1
- package/assets/copilot/agents/refine-issue.agent.md +1 -1
- package/assets/copilot/agents/se-system-architecture-reviewer.agent.md +1 -1
- package/assets/copilot/agents/se-technical-writer.agent.md +1 -1
- package/assets/copilot/agents/skill-creator.agent.md +1 -1
- package/assets/copilot/agents/task-researcher.agent.md +1 -1
- package/assets/copilot/agents/technical-product-manager.agent.md +1 -1
- package/assets/copilot/agents/vision-analyst.agent.md +1 -1
- package/assets/copilot/agents/vitest.agent.md +1 -1
- package/assets/copilot/agents/workspace-learner.agent.md +2 -2
- package/assets/copilot/packs/backend/agents/firebase-developer.agent.md +1 -1
- package/assets/copilot/packs/backend/agents/nodejs-express-developer.agent.md +1 -1
- package/assets/copilot/packs/frontend/agents/expert-nextjs-developer.agent.md +1 -1
- package/assets/copilot/packs/frontend/agents/expert-react-frontend-engineer.agent.md +1 -1
- package/assets/copilot/packs/frontend/agents/jest-react-component-analyzer.agent.md +1 -1
- package/assets/copilot/packs/frontend/agents/jest-react-scenario-scanner.agent.md +1 -1
- package/assets/copilot/packs/frontend/agents/jest-react-test-generator.agent.md +1 -1
- package/assets/copilot/packs/frontend/agents/jest-react-test-planner.agent.md +1 -1
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/LICENSE +21 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/SKILL.md +529 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/UPSTREAM.json +7 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/app.css +216 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/base.css +105 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/base.js +37 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/charts.js +617 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/scenes/stage.css +28 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/scenes/stage.js +133 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/evals/evals.json +76 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/app.json +1327 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/directions.json +1078 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/heroes.json +1086 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/motions.json +92 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/palettes.json +1485 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/photos.json +7240 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/sections.json +4429 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/sites.json +8350 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/type.json +2024 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/app-screens.md +1199 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/colour.md +141 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/copy.md +177 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/critique.md +182 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/imagery.md +791 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/layout.md +292 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/motion.md +980 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/research.md +356 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/scenes.md +174 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/tells.md +115 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/type.md +170 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/assets.mjs +865 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/draw.mjs +584 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/inspect.mjs +303 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lib/app-draw.mjs +203 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lib/cdp.mjs +248 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lib/probe.mjs +909 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lib/rules.mjs +243 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lint.mjs +119 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/scenes.mjs +124 -0
- package/assets/copilot/packs/playwright/agents/playwright-component-analyzer.agent.md +1 -1
- package/assets/copilot/packs/playwright/agents/playwright-scenario-scanner.agent.md +1 -1
- package/assets/copilot/packs/playwright/agents/playwright-test-generator.agent.md +1 -1
- package/assets/copilot/packs/playwright/agents/playwright-test-planner.agent.md +1 -1
- package/assets/copilot/packs/playwright/agents/playwright-tester.agent.md +1 -1
- package/assets/manifest.json +2 -0
- package/dist/cli.min.js +102 -102
- package/package.json +1 -1
- package/scripts/__snapshots__/generate-claude-assets.test.js.snap +1 -1
- package/scripts/generate-claude-assets.strict.test.js +1 -1
- package/scripts/generate-claude-assets.test.js +6 -6
- package/scripts/packs-layout.test.js +2 -1
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
// tasteskill / charts.js — small, dependency-free charts for app screens, drawn in SVG from your data.
|
|
2
|
+
// They follow references/app-screens.md: one question per chart, one series that matters, 3–5 quiet
|
|
3
|
+
// horizontal gridlines, round ticks that top out above the largest value, 2px straight lines with no
|
|
4
|
+
// markers, square bars, no gradients, a bare tooltip, tabular figures. Colours come from CSS variables:
|
|
5
|
+
// --chart-1 … --chart-4 (series), --chart-muted (other series), --chart-mark (a marked period; ink by default),
|
|
6
|
+
// --chart-span (the opacity of a shaded span: about .06 on light, .12 on dark), --chart-track (a time strip's track),
|
|
7
|
+
// --grid, --axis-ink, --ink, --surface, --raised, --rule, --negative
|
|
8
|
+
// Usage (after this file loads):
|
|
9
|
+
// Charts.line(el, { labels: ['1 Oct', …], series: [{ name: 'Requests', values: [...] }], format: Charts.fmt.compact })
|
|
10
|
+
// Charts.bars(el, { labels, values, highlight: 6, format }) one bar (or [indices]) may carry the accent; dim: [indices]
|
|
11
|
+
// Charts.diverging(el, { labels, up, down, above: 'Sold', below: 'Bought', mark: [3, 9], markLabel: 'Car charged' })
|
|
12
|
+
// Charts.ring(el, { value: 0.72, label: '72%', sub: 'of capacity' }) a progress ring with its total inside
|
|
13
|
+
// Charts.barList(el, [{ label: 'Berlin', value: 412 }, …], { format, thin }) a ranked list where the row is the bar
|
|
14
|
+
// Charts.plan(el, [{ label: 'Groceries', planned: 600, spent: 412, committed: 60 }, …], { scale: 'row' | 'plan' }) planned against spent
|
|
15
|
+
// Charts.bullet(el, [{ label: 'Bonds', value: 0.31, target: 0.25 }, …]) share against a target
|
|
16
|
+
// Charts.timeline(el, rows, kinds, { days, today, weekends }) who is away when, each kind of booking its own mark
|
|
17
|
+
// Charts.matrix(el, rows, { cols, comfort: 2, today }) slack against a minimum, team by day
|
|
18
|
+
// Charts.timeStrip(el, rows, { open: '08:00', close: '15:00', compareLabel: 'Last Sunday' }) when, within the day
|
|
19
|
+
// line, bars and diverging take annotations: [{ at: index, label: 'Deploy a3f09c1', short: 'a3f09c1' }] or spans
|
|
20
|
+
// [{ from, to, label: 'Cloud' }]. Their labels sit in rows above the plot, measured once the fonts have loaded, and no
|
|
21
|
+
// leader line crosses another label; the rows are reserved only when a label exists. reference: { value, label }, its
|
|
22
|
+
// label set where no series passes.
|
|
23
|
+
// line takes step: true for a balance that only changes on given days; today: index (the forecast after it is dashed)
|
|
24
|
+
// with todayLabel: 'Now 20:34' (set clear of the line); endLabel: true (the last value at the line's end); max: caps a
|
|
25
|
+
// flat series with one spike (the peak is labelled); yTicks: fixed ticks for a bounded quantity (0–100%); tickFormat:
|
|
26
|
+
// axis labels apart from the tooltip format; xAt: the indices to label. Without xAt the x labels keep one step.
|
|
27
|
+
// diverging, timeline, matrix and timeStrip return { svg, legend }, and line returns { legend } for two series or more:
|
|
28
|
+
// the legend is drawn from the same style objects as the marks, so a swatch is always the mark scaled down. Put it in
|
|
29
|
+
// the card's header, or pass legend: element (or a selector) to have it filled.
|
|
30
|
+
// Numbers follow the page's language (<html lang>). Every chart gets role="img" and an aria-label that states what it shows.
|
|
31
|
+
(() => {
|
|
32
|
+
const NS = 'http://www.w3.org/2000/svg', MINUS = '\u2212', ROW = 14, LANE = (rows) => 8 + rows * ROW + (rows ? 4 : 0);
|
|
33
|
+
const css = (el, name, fb) => getComputedStyle(el).getPropertyValue(name).trim() || fb;
|
|
34
|
+
const cssNum = (el, name, fb) => { const v = parseFloat(css(el, name, '')); return Number.isFinite(v) ? v : fb; };
|
|
35
|
+
const svg = (tag, attrs = {}, parent) => { const n = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs)) if (v != null) n.setAttribute(k, v); if (parent) parent.appendChild(n); return n; };
|
|
36
|
+
const text = (parent, str, attrs) => { const t = svg('text', attrs, parent); t.textContent = str; return t; };
|
|
37
|
+
const titled = (parent, str) => { svg('title', {}, parent).textContent = str; return parent; };
|
|
38
|
+
// numbers follow the page's language (<html lang>), so separators match the rest of the screen
|
|
39
|
+
const nf = (o) => new Intl.NumberFormat(document.documentElement.lang || 'en', o);
|
|
40
|
+
const fmt = {
|
|
41
|
+
int: (v) => nf({ maximumFractionDigits: 0 }).format(v),
|
|
42
|
+
dec: (d) => (v) => nf({ minimumFractionDigits: d, maximumFractionDigits: d }).format(v),
|
|
43
|
+
pct: (d = 1) => (v) => nf({ style: 'percent', minimumFractionDigits: d, maximumFractionDigits: d }).format(v),
|
|
44
|
+
money: (cur = 'EUR', d = 0) => (v) => nf({ style: 'currency', currency: cur, minimumFractionDigits: d, maximumFractionDigits: d }).format(v),
|
|
45
|
+
compact: (v) => nf({ notation: 'compact', maximumFractionDigits: 1 }).format(v),
|
|
46
|
+
ms: (v) => `${nf({ maximumFractionDigits: 0 }).format(v)} ms`,
|
|
47
|
+
// a sign on every change, with a true minus: fmt.signed() → '+2', '0', '−1'; fmt.signed(fmt.dec(1)) → '+0.4'
|
|
48
|
+
signed: (f = (v) => nf({ maximumFractionDigits: 0 }).format(v)) => (v) => (v > 0 ? '+' : v < 0 ? MINUS : '') + f(Math.abs(v)),
|
|
49
|
+
// minutes since midnight as a 24-hour time: 680 → '11:20'
|
|
50
|
+
clock: (m) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(Math.round(m % 60)).padStart(2, '0')}`, // taste-skill-ignore: a clock, not a counter
|
|
51
|
+
};
|
|
52
|
+
const minutes = (t) => (t == null || t === '' ? null : typeof t === 'number' ? t : (([hh, mm]) => hh * 60 + (mm || 0))(String(t).split(':').map(Number)));
|
|
53
|
+
// Round ticks (steps of 1, 2, 2.5 or 5 × 10^n) whose top sits above the largest value.
|
|
54
|
+
function ticks(min, max, count = 4) {
|
|
55
|
+
if (max === min) max = min + 1;
|
|
56
|
+
const raw = (max - min) / count, mag = 10 ** Math.floor(Math.log10(raw));
|
|
57
|
+
const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) || 10 * mag;
|
|
58
|
+
const lo = Math.floor(min / step) * step; let hi = Math.ceil(max / step) * step; if (hi <= max) hi += step;
|
|
59
|
+
const out = []; for (let v = lo; v <= hi + step / 2; v += step) out.push(+v.toFixed(10));
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
// x labels keep one step all the way: every k-th label, with k chosen so the labels fit the width, and a step that
|
|
63
|
+
// lands on the last label when one gives enough labels. Never a forced end label that breaks the step. Clock labels
|
|
64
|
+
// ('08:30') step in whole hours (or halves and quarters) from a round time, so a half-hourly axis reads 06:00, 09:00.
|
|
65
|
+
function xPicks(labels, span, at) {
|
|
66
|
+
const n = labels.length;
|
|
67
|
+
if (at) return at.filter((i) => i >= 0 && i < n);
|
|
68
|
+
if (n <= 1) return n ? [0] : [];
|
|
69
|
+
const longest = Math.max(...labels.map((l) => String(l).length));
|
|
70
|
+
const cap = Math.max(2, Math.min(8, Math.floor(span / (longest * 7 + 18))));
|
|
71
|
+
if (n <= cap) return [...Array(n).keys()];
|
|
72
|
+
const mins = labels.every((l) => /^\d{1,2}:\d{2}$/.test(String(l))) ? labels.map(minutes) : null;
|
|
73
|
+
const d = mins ? mins[1] - mins[0] : 0;
|
|
74
|
+
let first = null, land = null;
|
|
75
|
+
for (let k = 1; k < n; k++) {
|
|
76
|
+
const stepM = k * d, clock = mins && d > 0;
|
|
77
|
+
if (clock && (stepM >= 60 ? ![1, 2, 3, 4, 6, 12].includes(stepM / 60) : 60 % stepM !== 0)) continue;
|
|
78
|
+
// start on a round time: a multiple of the step (09:00 for three hours), or a whole hour for shorter steps
|
|
79
|
+
let s = clock ? mins.findIndex((m) => m % Math.max(60, stepM) === 0) : 0;
|
|
80
|
+
if (s < 0) s = Math.max(0, mins.findIndex((m) => m % 60 === 0));
|
|
81
|
+
const count = Math.floor((n - 1 - s) / k) + 1;
|
|
82
|
+
if (count > cap) continue;
|
|
83
|
+
if (first == null) first = { k, s };
|
|
84
|
+
if ((n - 1 - s) % k === 0) { land = { k, s, count }; break; }
|
|
85
|
+
}
|
|
86
|
+
const pick = land && land.count >= Math.max(3, Math.ceil(cap * 0.6)) ? land : first || { k: n - 1, s: 0 };
|
|
87
|
+
const out = []; for (let i = pick.s; i < n; i += pick.k) out.push(i);
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
// colours: tokens resolved to rgb, so a mark can be mixed with the surface (a pale fill, a darker step)
|
|
91
|
+
const swatchProbe = document.createElement('span'); swatchProbe.style.display = 'none';
|
|
92
|
+
const rgb = (el, value) => {
|
|
93
|
+
el.appendChild(swatchProbe); swatchProbe.style.color = ''; swatchProbe.style.color = value;
|
|
94
|
+
const c = getComputedStyle(swatchProbe).color; swatchProbe.remove();
|
|
95
|
+
const m = c.match(/-?[\d.]+/g); if (!m || m.length < 3) return null;
|
|
96
|
+
return c.startsWith('color(') ? m.slice(0, 3).map((v) => Math.round(+v * 255)) : m.slice(0, 3).map(Number);
|
|
97
|
+
};
|
|
98
|
+
const mix = (el, a, b, t) => { const p = rgb(el, a), q = rgb(el, b); return p && q ? `rgb(${p.map((v, i) => Math.round(v * t + q[i] * (1 - t))).join(',')})` : a; };
|
|
99
|
+
// text width, measured in the chart's own face (falls back to an estimate when the chart is hidden)
|
|
100
|
+
const measure = (s, str, size = 11, weight = 400) => { const t = text(s, str, { x: -999, y: -999, 'font-size': size, 'font-weight': weight, visibility: 'hidden' }); let wd = 0; try { wd = t.getComputedTextLength(); } catch {} t.remove(); return wd > 0 ? wd : String(str).length * size * 0.56; };
|
|
101
|
+
const fit = (s, str, max, size, weight) => { str = String(str); if (measure(s, str, size, weight) <= max) return str; while (str.length > 1 && measure(s, str + '\u2026', size, weight) > max) str = str.slice(0, -1); return str.trimEnd() + '\u2026'; };
|
|
102
|
+
function frame(el, h) {
|
|
103
|
+
el.innerHTML = '';
|
|
104
|
+
const w = Math.max(240, el.clientWidth || 600);
|
|
105
|
+
const s = svg('svg', { viewBox: `0 0 ${w} ${h}`, width: '100%', height: h, role: 'img', style: 'display:block;overflow:visible;font-variant-numeric:tabular-nums' }, el);
|
|
106
|
+
return { s, w, h };
|
|
107
|
+
}
|
|
108
|
+
function tooltip(el) {
|
|
109
|
+
let t = el.querySelector('.chart-tip');
|
|
110
|
+
if (!t) { t = document.createElement('div'); t.className = 'chart-tip'; t.style.cssText = 'position:absolute;pointer-events:none;padding:6px 8px;font:inherit;font-size:12px;line-height:1.35;border-radius:6px;white-space:nowrap;opacity:0;transition:opacity 120ms ease;background:var(--surface,#fff);color:var(--ink,#111);border:1px solid var(--grid,#e5e7eb)'; if (getComputedStyle(el).position === 'static') el.style.position = 'relative'; el.appendChild(t); }
|
|
111
|
+
return t;
|
|
112
|
+
}
|
|
113
|
+
function axes(g, el, { w, h, padL, padB, yt, y, labels, x, band = false, at = null, tick, avoid = null }) {
|
|
114
|
+
const grid = css(el, '--grid', '#e8e8e8'), ink = css(el, '--axis-ink', '#6b6b6b'), s = g.ownerSVGElement;
|
|
115
|
+
for (const v of yt) {
|
|
116
|
+
const yy = y(v);
|
|
117
|
+
svg('line', { x1: padL, x2: w, y1: yy, y2: yy, stroke: grid, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g);
|
|
118
|
+
text(g, tick(v), { x: padL - 8, y: yy + 4, 'text-anchor': 'end', 'font-size': 12, fill: ink });
|
|
119
|
+
}
|
|
120
|
+
const n = labels.length;
|
|
121
|
+
for (const i of xPicks(labels, w - padL, at)) {
|
|
122
|
+
const lab = String(labels[i]), tw = measure(s, lab, 12), xx = x(i);
|
|
123
|
+
// the first and last labels are pulled inside the plot; none runs past either edge
|
|
124
|
+
let left = band ? xx - tw / 2 : i === 0 ? xx : i === n - 1 ? xx - tw : xx - tw / 2;
|
|
125
|
+
left = Math.max(band ? 0 : padL, Math.min(w - tw, left));
|
|
126
|
+
if (avoid && left < avoid[1] + 8 && left + tw > avoid[0] - 8) continue;
|
|
127
|
+
text(g, lab, { x: left, y: h - padB + 18, 'font-size': 12, fill: ink });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Annotation labels sit in rows above the plot, never on the data. A label sits beside the top of its own leader
|
|
131
|
+
// line, right of it or left of it, in one of three rows. A place is free when no other label in that row overlaps
|
|
132
|
+
// it, no leader line passes through it, and its own leader passes through no label below it. The layout is searched
|
|
133
|
+
// (close markers often need the first label on the left so the next leader runs clear): full labels first, then
|
|
134
|
+
// short forms (`short`), and only then is a label dropped to its marker's tooltip, with a note in the console.
|
|
135
|
+
function lane(s, items, w, maxRows = 3) {
|
|
136
|
+
const gap = 6, rank = (it) => (it.fixed != null ? 0 : it.span ? 1 : 2);
|
|
137
|
+
// fixed captions first, then spans, then markers from left to right
|
|
138
|
+
const order = [...items].sort((a, b) => rank(a) - rank(b) || (a.x ?? a.x0) - (b.x ?? b.x0));
|
|
139
|
+
const opts = order.map((it) => {
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const str of [it.label, it.short].filter(Boolean)) {
|
|
142
|
+
const tw = measure(s, str, 11) + (it.glyph ? 9 : 0), mid = it.span ? (it.x0 + it.x1) / 2 : 0;
|
|
143
|
+
const cands = it.fixed != null ? [[it.fixed, it.fixed + tw]] : it.span ? [[mid - tw / 2, mid + tw / 2], [it.x0, it.x0 + tw], [it.x1 - tw, it.x1]] : [[it.x + 4, it.x + 4 + tw], [it.x - 4 - tw, it.x - 4]];
|
|
144
|
+
for (let row = 0; row < maxRows; row++) for (const [x0, x1] of cands) if (x0 >= 0 && x1 <= w) out.push({ ...it, text: str, isShort: str !== it.label, row, x0, x1 });
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
});
|
|
148
|
+
const free = (c, placed) => placed.every((p) => p.dropped || (!(p.row === c.row && c.x0 < p.x1 + gap && c.x1 > p.x0 - gap)
|
|
149
|
+
&& !(p.leader != null && p.row >= c.row && p.leader > c.x0 - 3 && p.leader < c.x1 + 3)
|
|
150
|
+
&& !(c.leader != null && p.row <= c.row && c.leader > p.x0 - 3 && c.leader < p.x1 + 3)));
|
|
151
|
+
let best = null, steps = 0;
|
|
152
|
+
const solve = (i, placed, allowShort, allowDrop) => {
|
|
153
|
+
if (++steps > 20000) return false;
|
|
154
|
+
if (i === order.length) { best = placed.slice(); return true; }
|
|
155
|
+
for (const c of opts[i]) { if ((c.isShort && !allowShort) || !free(c, placed)) continue; placed.push(c); if (solve(i + 1, placed, allowShort, allowDrop)) return true; placed.pop(); }
|
|
156
|
+
if (allowDrop) { placed.push({ ...order[i], dropped: true }); if (solve(i + 1, placed, allowShort, allowDrop)) return true; placed.pop(); }
|
|
157
|
+
return false;
|
|
158
|
+
};
|
|
159
|
+
for (const [sh, dr] of [[false, false], [true, false], [true, true]]) { steps = 0; if (solve(0, [], sh, dr)) break; }
|
|
160
|
+
const placed = (best || []).filter((p) => !p.dropped), dropped = (best || order.map((it) => ({ ...it, dropped: true }))).filter((p) => p.dropped);
|
|
161
|
+
return { placed, dropped, rows: placed.length ? Math.max(...placed.map((p) => p.row)) + 1 : 0 };
|
|
162
|
+
}
|
|
163
|
+
function prepNotes(s, annotations = [], x, w, extra = []) {
|
|
164
|
+
const items = [];
|
|
165
|
+
for (const a of annotations || []) {
|
|
166
|
+
if (a.from != null) items.push({ span: true, x0: Math.min(x(a.from), x(a.to)), x1: Math.max(x(a.from), x(a.to)), label: a.label, short: a.short, colour: a.colour });
|
|
167
|
+
else if (a.at != null) items.push({ x: x(a.at), at: a.at, leader: x(a.at), label: a.label, short: a.short, colour: a.colour });
|
|
168
|
+
}
|
|
169
|
+
items.push(...extra);
|
|
170
|
+
return { items, ...lane(s, items.filter((it) => it.label), w) };
|
|
171
|
+
}
|
|
172
|
+
// shaded spans go under the data; marker lines and the label rows over it
|
|
173
|
+
function notesUnder(g, el, N, { padT, h, padB }) {
|
|
174
|
+
const ink = css(el, '--axis-ink', '#6b6b6b'), op = cssNum(el, '--chart-span', 0.07);
|
|
175
|
+
for (const it of N.items) if (it.span) svg('rect', { x: it.x0, y: padT, width: it.x1 - it.x0, height: h - padT - padB, fill: ink, opacity: op }, g);
|
|
176
|
+
}
|
|
177
|
+
function notesOver(g, el, N, { padT, h, padB }) {
|
|
178
|
+
const ink = css(el, '--axis-ink', '#6b6b6b');
|
|
179
|
+
// a marker line runs to the plot's foot, or stops above the bar it points at (it.end)
|
|
180
|
+
for (const it of N.items) if (it.leader != null && !it.drawn) svg('line', { x1: it.leader, x2: it.leader, y1: padT, y2: Math.max(padT, it.end ?? h - padB), stroke: ink, 'stroke-width': 1, opacity: 0.45, 'shape-rendering': 'crispEdges' }, g);
|
|
181
|
+
for (const p of N.placed) {
|
|
182
|
+
const base = padT - 9 - p.row * ROW;
|
|
183
|
+
if (p.leader != null) svg('line', { x1: p.leader, x2: p.leader, y1: base - 8, y2: padT, stroke: ink, 'stroke-width': 1, opacity: 0.45, 'shape-rendering': 'crispEdges' }, g);
|
|
184
|
+
if (p.glyph) svg('path', { d: p.glyph === 'up' ? `M${p.x0} ${base - 1}l3 -6l3 6z` : `M${p.x0} ${base - 7}l3 6l3 -6z`, fill: ink }, g);
|
|
185
|
+
text(g, p.text, { x: p.x0 + (p.glyph ? 9 : 0), y: base, 'font-size': 11, fill: p.colour || ink });
|
|
186
|
+
}
|
|
187
|
+
const el2 = g.ownerSVGElement.parentNode; el2._warned = el2._warned || new Set();
|
|
188
|
+
for (const d of N.dropped) {
|
|
189
|
+
if (d.leader != null) { const hit = svg('line', { x1: d.leader, x2: d.leader, y1: padT, y2: h - padB, stroke: 'transparent', 'stroke-width': 8 }, g); titled(hit, d.label, {}); }
|
|
190
|
+
if (!el2._warned.has(d.label)) { el2._warned.add(d.label); console.warn(`Charts: no room above the plot for "${d.label}". Give it a short form (short) or label fewer markers.`); }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// does a label box touch a drawn line? polylines are [[x, y], …]; a box is { x0, x1, y0, y1 }
|
|
194
|
+
function hits(b, lines) {
|
|
195
|
+
for (const pts of lines) for (let k = 1; k < pts.length; k++) {
|
|
196
|
+
const a = pts[k - 1], c = pts[k]; if (!a || !c) continue;
|
|
197
|
+
if (a[0] === c[0]) { if (a[0] >= b.x0 - 3 && a[0] <= b.x1 + 3 && Math.max(a[1], c[1]) >= b.y0 - 3 && Math.min(a[1], c[1]) <= b.y1 + 3) return true; continue; }
|
|
198
|
+
const lo = Math.max(b.x0 - 3, Math.min(a[0], c[0])), hi = Math.min(b.x1 + 3, Math.max(a[0], c[0]));
|
|
199
|
+
for (let xx = lo; xx <= hi; xx += 2) { const yy = a[1] + ((xx - a[0]) / (c[0] - a[0])) * (c[1] - a[1]); if (yy >= b.y0 - 3 && yy <= b.y1 + 3) return true; }
|
|
200
|
+
}
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
const boxHits = (b, boxes) => boxes.some((r) => b.x0 < r.x1 + 3 && b.x1 > r.x0 - 3 && b.y0 < r.y1 + 3 && b.y1 > r.y0 - 3);
|
|
204
|
+
// a reference line (a budget, an SLO) with its label at an end where no series passes
|
|
205
|
+
function references(g, el, refs, { y, padL, w, lines = [], boxes = [] }) {
|
|
206
|
+
const ink = css(el, '--axis-ink', '#6b6b6b'), s = g.ownerSVGElement;
|
|
207
|
+
for (const r of [].concat(refs || [])) {
|
|
208
|
+
const yy = y(r.value);
|
|
209
|
+
svg('line', { x1: padL, x2: w, y1: yy, y2: yy, stroke: ink, 'stroke-width': 1, 'stroke-dasharray': '4 4', opacity: 0.7 }, g);
|
|
210
|
+
if (!r.label) continue;
|
|
211
|
+
const tw = measure(s, r.label, 11), right = r.at !== 'left';
|
|
212
|
+
const cand = [[right, -5], [right, 13], [!right, -5], [!right, 13]].map(([rt, dy]) => ({ x0: rt ? w - tw : padL + 4, x1: rt ? w : padL + 4 + tw, y0: yy + dy - 9, y1: yy + dy + 2, base: yy + dy }));
|
|
213
|
+
const b = cand.find((c) => !hits(c, lines) && !boxHits(c, boxes)) || cand[0];
|
|
214
|
+
text(g, r.label, { x: b.x0, y: b.base, 'font-size': 11, fill: ink });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// One style object per kind of mark draws both the mark and its legend swatch, so the two cannot drift apart.
|
|
218
|
+
function paintRect(parent, st, x, y, w, h, r = 0) {
|
|
219
|
+
let fill = st.fill || 'none';
|
|
220
|
+
if (st.hatch) {
|
|
221
|
+
const s = parent.ownerSVGElement || parent, id = 'h' + Math.random().toString(36).slice(2, 8);
|
|
222
|
+
const p = svg('pattern', { id, width: 5, height: 5, patternUnits: 'userSpaceOnUse', patternTransform: 'rotate(45)' }, svg('defs', {}, s));
|
|
223
|
+
svg('rect', { width: 5, height: 5, fill: st.fill }, p); svg('line', { x1: 0, y1: 0, x2: 0, y2: 5, stroke: st.hatch, 'stroke-width': 1.2 }, p);
|
|
224
|
+
fill = `url(#${id})`;
|
|
225
|
+
}
|
|
226
|
+
const inset = st.stroke ? (st.width || 1) / 2 : 0;
|
|
227
|
+
const n = svg('rect', { x: x + inset, y: y + inset, width: Math.max(0, w - inset * 2), height: Math.max(0, h - inset * 2), rx: r, fill, stroke: st.stroke, 'stroke-width': st.stroke ? st.width || 1 : null, 'stroke-dasharray': st.dash, opacity: st.opacity, 'shape-rendering': r ? null : 'crispEdges' }, parent);
|
|
228
|
+
if (st.edge) svg('rect', { x, y, width: 3, height: h, rx: Math.min(r, 1.5), fill: st.edge }, parent);
|
|
229
|
+
return n;
|
|
230
|
+
}
|
|
231
|
+
const legendCss = ':where(.chart-legend){list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:4px 14px;font-size:12px;line-height:16px;color:var(--muted,#5f6368)}:where(.chart-legend li){display:inline-flex;align-items:center;gap:6px;white-space:nowrap}:where(.chart-legend svg){flex:none;overflow:visible}@media (max-width:480px){:where(.chart-legend){display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}}';
|
|
232
|
+
function legendEl(items, target) {
|
|
233
|
+
if (!document.getElementById('charts-legend-css')) { const st = document.createElement('style'); st.id = 'charts-legend-css'; st.textContent = legendCss; document.head.appendChild(st); }
|
|
234
|
+
const ul = document.createElement('ul'); ul.className = 'chart-legend';
|
|
235
|
+
for (const it of items) {
|
|
236
|
+
const li = document.createElement('li'), sw = svg('svg', { width: it.w || 14, height: 12, 'aria-hidden': 'true' });
|
|
237
|
+
it.draw(sw); li.append(sw, document.createTextNode(it.label)); ul.appendChild(li);
|
|
238
|
+
}
|
|
239
|
+
const t = typeof target === 'string' ? document.querySelector(target) : target;
|
|
240
|
+
if (t) t.replaceChildren(ul);
|
|
241
|
+
return ul;
|
|
242
|
+
}
|
|
243
|
+
const rectSwatch = (st, w = 14, h = 10, r = 2) => (sw) => paintRect(sw, st, 0, (12 - h) / 2, w, h, r);
|
|
244
|
+
// charts are laid out with measured text: draw again once the page's fonts have arrived
|
|
245
|
+
function settle(draw) { if (document.fonts) { document.fonts.ready.then(draw); document.fonts.addEventListener?.('loadingdone', draw, { once: true }); } }
|
|
246
|
+
const heightOf = (h) => (typeof h === 'function' ? h() : h);
|
|
247
|
+
|
|
248
|
+
function line(el, { labels, series, format = fmt.int, tickFormat = null, height = 220, fill = false, min, max, step = false, annotations, reference, today = null, todayLabel = 'Today', endLabel = false, yTicks = null, xAt = null, legend = null }) {
|
|
249
|
+
el._fmt = format;
|
|
250
|
+
const colourOf = (sr, k) => sr.colour || (k === 0 ? css(el, '--chart-1', '#2f6fdf') : css(el, `--chart-${k + 1}`, css(el, '--chart-muted', '#a3a3a3')));
|
|
251
|
+
const draw = () => {
|
|
252
|
+
const { s, w, h } = frame(el, heightOf(height));
|
|
253
|
+
const all = series.flatMap((x) => x.values.filter((v) => v != null)).concat([].concat(reference || []).map((r) => r.value));
|
|
254
|
+
// max caps the range (a flat series with one spike): values above it are clipped and their peak labelled
|
|
255
|
+
const top = max ?? Math.max(...all);
|
|
256
|
+
const yt = yTicks || ticks(min ?? Math.min(0, ...all), top, 4);
|
|
257
|
+
const tick = tickFormat || format;
|
|
258
|
+
const lastVal = series[0].values.filter((v) => v != null).at(-1);
|
|
259
|
+
const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26, padR = endLabel ? 10 + measure(s, format(lastVal), 12, 600) : 0;
|
|
260
|
+
const x = (i) => padL + (i * (w - padL - padR)) / Math.max(1, labels.length - 1);
|
|
261
|
+
// the peaks clipped by max are labelled in the rows above the plot, where the line leaves it
|
|
262
|
+
const peaks = [];
|
|
263
|
+
series.forEach((sr, k) => sr.values.forEach((v, i) => { if (v != null && v > yt.at(-1) && (i === 0 || sr.values[i - 1] == null || sr.values[i - 1] <= yt.at(-1))) { let j = i, peak = v; while (j < sr.values.length && sr.values[j] != null && sr.values[j] > yt.at(-1)) { peak = Math.max(peak, sr.values[j]); j++; } peaks.push({ x: x(i), label: format(peak) + ' at ' + labels[i], colour: colourOf(sr, k) }); } }));
|
|
264
|
+
const scale = (N) => { const padT = LANE(N.rows); return { padT, y: (v) => padT + (h - padT - padB) * (1 - (v - yt[0]) / (yt.at(-1) - yt[0])) }; };
|
|
265
|
+
const linesAt = (y) => series.map((sr) => { const out = []; sr.values.forEach((v, i) => { if (v == null) { out.push(null); return; } const p = [x(i), y(Math.min(v, yt.at(-1)))]; const prev = out.at(-1); if (step && prev) out.push([p[0], prev[1]]); out.push(p); }); return out; }).concat([].concat(reference || []).map((r) => [[padL, y(r.value)], [w, y(r.value)]]));
|
|
266
|
+
let N = prepNotes(s, annotations, x, w, peaks), { padT, y } = scale(N), todayBox = null;
|
|
267
|
+
// the today label goes inside the plot on whichever side of its line no series crosses; failing that, above
|
|
268
|
+
if (today != null && todayLabel) {
|
|
269
|
+
const cut = x(today), tw = measure(s, todayLabel, 11), lines = linesAt(y);
|
|
270
|
+
const cand = [[cut + 4, h - padB - 6], [cut - 4 - tw, h - padB - 6], [cut + 4, padT + 11], [cut - 4 - tw, padT + 11]].map(([x0, b]) => ({ x0, x1: x0 + tw, y0: b - 9, y1: b + 2, base: b }));
|
|
271
|
+
todayBox = cand.find((b) => b.x0 >= padL && b.x1 <= w && !hits(b, lines)) || null;
|
|
272
|
+
if (!todayBox) { N = prepNotes(s, annotations, x, w, [...peaks, { x: cut, leader: cut, drawn: true, label: todayLabel }]); ({ padT, y } = scale(N)); }
|
|
273
|
+
}
|
|
274
|
+
const g = svg('g', {}, s);
|
|
275
|
+
axes(g, el, { w, h, padL, padB, yt, y, labels, x, at: xAt, tick });
|
|
276
|
+
notesUnder(g, el, N, { padT, h, padB });
|
|
277
|
+
series.forEach((sr, k) => {
|
|
278
|
+
const col = colourOf(sr, k);
|
|
279
|
+
const pts = sr.values.map((v, i) => (v == null ? null : [x(i), y(Math.min(v, yt.at(-1)))]));
|
|
280
|
+
// step: a balance that only changes on given days holds its value until the next one
|
|
281
|
+
const d = pts.reduce((acc, p, i) => (p ? acc + (acc && pts[i - 1] ? (step ? `L${p[0].toFixed(1)} ${pts[i - 1][1].toFixed(1)}L` : 'L') : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1) : acc), '');
|
|
282
|
+
if (fill && k === 0) { const first = pts.find(Boolean), last = [...pts].reverse().find(Boolean); svg('path', { d: `${d}L${last[0]} ${y(yt[0])}L${first[0]} ${y(yt[0])}Z`, fill: col, opacity: 0.12 }, g); }
|
|
283
|
+
if (today == null) svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }, g);
|
|
284
|
+
else {
|
|
285
|
+
// a forecast: the line after today is dashed, so a projection never reads as a fact
|
|
286
|
+
const cut = x(today), id = 'c' + k + Math.random().toString(36).slice(2, 8);
|
|
287
|
+
const defs = svg('defs', {}, g);
|
|
288
|
+
const a = svg('clipPath', { id: id + 'a' }, defs); svg('rect', { x: 0, y: 0, width: cut, height: h }, a);
|
|
289
|
+
const b = svg('clipPath', { id: id + 'b' }, defs); svg('rect', { x: cut, y: 0, width: w, height: h }, b);
|
|
290
|
+
svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-linejoin': 'round', 'clip-path': 'url(#' + id + 'a)' }, g);
|
|
291
|
+
svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-dasharray': '5 4', opacity: 0.8, 'clip-path': 'url(#' + id + 'b)' }, g);
|
|
292
|
+
if (k === 0) { svg('line', { x1: cut, x2: cut, y1: padT, y2: h - padB, stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1, opacity: 0.4 }, g); if (todayBox) text(g, todayLabel, { x: todayBox.x0, y: todayBox.base, 'font-size': 11, fill: css(el, '--axis-ink', '#6b6b6b') }); }
|
|
293
|
+
}
|
|
294
|
+
if (endLabel && k === 0) { const li = sr.values.map((v, i) => (v == null ? -1 : i)).filter((i) => i >= 0).at(-1); text(g, format(sr.values[li]), { x: x(li) + 6, y: y(Math.min(sr.values[li], yt.at(-1))) + 4, 'font-size': 12, 'font-weight': 600, fill: css(el, '--ink', '#111') }); }
|
|
295
|
+
});
|
|
296
|
+
references(g, el, reference, { y, padL, w, lines: linesAt(y).slice(0, series.length) });
|
|
297
|
+
notesOver(g, el, N, { padT, h, padB });
|
|
298
|
+
// hover: a rule, a dot per series and a bare tooltip
|
|
299
|
+
const tip = tooltip(el), rule = svg('line', { y1: padT, y2: h - padB, stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1, opacity: 0 }, g);
|
|
300
|
+
const dots = series.map((sr, k) => svg('circle', { r: 3.5, fill: css(el, '--surface', '#fff'), stroke: colourOf(sr, k), 'stroke-width': 2, opacity: 0 }, g));
|
|
301
|
+
const hit = svg('rect', { x: padL, y: padT, width: w - padL, height: h - padT - padB, fill: 'transparent' }, s);
|
|
302
|
+
const move = (ev) => { const r = s.getBoundingClientRect(); const px = ((ev.clientX - r.left) / r.width) * w; const i = Math.max(0, Math.min(labels.length - 1, Math.round(((px - padL) / (w - padL - padR)) * (labels.length - 1)))); rule.setAttribute('x1', x(i)); rule.setAttribute('x2', x(i)); rule.setAttribute('opacity', 0.35); series.forEach((sr, k) => { const v = sr.values[i]; dots[k].setAttribute('cx', x(i)); dots[k].setAttribute('cy', v == null ? -99 : y(Math.min(v, yt.at(-1)))); dots[k].setAttribute('opacity', v == null ? 0 : 1); }); tip.innerHTML = `<div style="opacity:.7">${labels[i]}</div>` + series.map((sr) => `<div><b style="font-weight:600">${sr.values[i] == null ? '–' : format(sr.values[i])}</b> ${series.length > 1 ? sr.name : ''}</div>`).join(''); const left = Math.min((x(i) / w) * r.width + 12, r.width - tip.offsetWidth - 4); tip.style.left = `${Math.max(0, left)}px`; tip.style.top = '0px'; tip.style.opacity = 1; };
|
|
303
|
+
hit.addEventListener('pointermove', move); hit.addEventListener('pointerleave', () => { tip.style.opacity = 0; rule.setAttribute('opacity', 0); dots.forEach((d) => d.setAttribute('opacity', 0)); });
|
|
304
|
+
const last = series[0].values.filter((v) => v != null).at(-1);
|
|
305
|
+
s.setAttribute('aria-label', `${series.map((x) => x.name).join(', ')}, ${labels[0]} to ${labels.at(-1)}; latest ${last == null ? 'no data' : format(last)}`);
|
|
306
|
+
};
|
|
307
|
+
draw(); watch(el, draw); settle(draw);
|
|
308
|
+
const lg = series.length > 1 ? legendEl(series.map((sr, k) => ({ label: sr.name, w: 16, draw: (sw) => svg('line', { x1: 0, x2: 16, y1: 6, y2: 6, stroke: colourOf(sr, k), 'stroke-width': 2, 'stroke-dasharray': sr.dash ? '4 3' : null }, sw) })), legend) : null;
|
|
309
|
+
return { legend: lg };
|
|
310
|
+
}
|
|
311
|
+
function bars(el, { labels, values, format = fmt.int, tickFormat = null, height = 200, highlight = null, dim = [], colour, mutedColour, annotations, reference, xAt = null }) {
|
|
312
|
+
el._fmt = format;
|
|
313
|
+
const lit = (i) => highlight == null || (Array.isArray(highlight) ? highlight.includes(i) : highlight === i);
|
|
314
|
+
const draw = () => {
|
|
315
|
+
const { s, w, h } = frame(el, heightOf(height));
|
|
316
|
+
const yt = ticks(0, Math.max(...values, ...[].concat(reference || []).map((r) => r.value)), 4), tick = tickFormat || format;
|
|
317
|
+
const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26;
|
|
318
|
+
const band = (w - padL) / values.length, bw = Math.max(2, band * 0.62);
|
|
319
|
+
const x = (i) => padL + band * i + band / 2;
|
|
320
|
+
const N = prepNotes(s, annotations, x, w), padT = LANE(N.rows);
|
|
321
|
+
const y = (v) => padT + (h - padT - padB) * (1 - v / yt.at(-1));
|
|
322
|
+
const g = svg('g', {}, s);
|
|
323
|
+
axes(g, el, { w, h, padL, padB, yt, y, labels, x, band: true, at: xAt, tick });
|
|
324
|
+
notesUnder(g, el, N, { padT, h, padB });
|
|
325
|
+
for (const it of N.items) if (it.at != null && values[it.at] != null) it.end = y(values[it.at]) - 4;
|
|
326
|
+
const main = colour || css(el, '--chart-1', '#2f6fdf'), muted = mutedColour || css(el, '--chart-muted', '#c9c9c9');
|
|
327
|
+
values.forEach((v, i) => { const r = svg('rect', { x: x(i) - bw / 2, y: y(v), width: bw, height: Math.max(0, y(0) - y(v)), fill: lit(i) ? main : muted, opacity: dim.includes(i) ? 0.45 : null, 'shape-rendering': 'crispEdges' }, g); titled(r, `${labels[i]}: ${format(v)}`, {}); });
|
|
328
|
+
references(g, el, reference, { y, padL, w, boxes: values.map((v, i) => ({ x0: x(i) - bw / 2, x1: x(i) + bw / 2, y0: y(v), y1: y(0) })) });
|
|
329
|
+
notesOver(g, el, N, { padT, h, padB });
|
|
330
|
+
s.setAttribute('aria-label', `${labels.length} bars from ${labels[0]} to ${labels.at(-1)}; highest ${format(Math.max(...values))}`);
|
|
331
|
+
};
|
|
332
|
+
draw(); watch(el, draw); settle(draw);
|
|
333
|
+
}
|
|
334
|
+
// Two directions on one scale: what went out above the zero line and what came in below it (sold and bought,
|
|
335
|
+
// income and spend). The axis says which is which, with signed ticks and a caption on each side of zero.
|
|
336
|
+
// mark: [indices] draws those periods in a second tone with their own legend entry (the nights the car charged).
|
|
337
|
+
function diverging(el, { labels, up, down, values, above = 'Above', below = 'Below', mark = [], markLabel = null, markSide = 'below', partial = null, format = fmt.int, tickFormat = null, height = 200, annotations, reference, xAt = null, legend = null }) {
|
|
338
|
+
if (values) { up = values.map((v) => Math.max(0, v || 0)); down = values.map((v) => Math.max(0, -(v || 0))); }
|
|
339
|
+
up = up || labels.map(() => 0); down = down || labels.map(() => 0);
|
|
340
|
+
const st = { above: { fill: css(el, '--chart-1', '#2f6fdf') }, below: { fill: css(el, '--chart-muted', '#c9c9c9') }, mark: { fill: css(el, '--chart-mark', css(el, '--ink', '#111')) } };
|
|
341
|
+
const marked = (i, side) => mark.includes(i) && (markSide === 'both' || markSide === side);
|
|
342
|
+
const tick = (v) => (v > 0 ? '+' : v < 0 ? MINUS : '') + (tickFormat || format)(Math.abs(v));
|
|
343
|
+
const draw = () => {
|
|
344
|
+
const { s, w, h } = frame(el, heightOf(height));
|
|
345
|
+
const hiUp = Math.max(0, ...up), hiDown = Math.max(0, ...down);
|
|
346
|
+
const yt = ticks(-hiDown, hiUp || 1, 4); const stp = yt[1] - yt[0]; if (hiDown > 0 && yt[0] >= -hiDown) yt.unshift(+(yt[0] - stp).toFixed(10));
|
|
347
|
+
const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26;
|
|
348
|
+
const band = (w - padL) / labels.length, bw = Math.max(2, band * 0.62);
|
|
349
|
+
const x = (i) => padL + band * i + band / 2;
|
|
350
|
+
// the caption for the upper side opens the label rows above the plot; the lower one sits under the plot, at left
|
|
351
|
+
const N = prepNotes(s, annotations, x, w, [{ fixed: padL, glyph: 'up', label: above }]), padT = LANE(N.rows);
|
|
352
|
+
const y = (v) => padT + (h - padT - padB) * (1 - (v - yt[0]) / (yt.at(-1) - yt[0]));
|
|
353
|
+
const g = svg('g', {}, s), ink = css(el, '--axis-ink', '#6b6b6b');
|
|
354
|
+
const bwid = measure(s, below, 11) + 9;
|
|
355
|
+
axes(g, el, { w, h, padL, padB, yt, y, labels, x, band: true, at: xAt, tick, avoid: [padL, padL + bwid] });
|
|
356
|
+
svg('path', { d: `M${padL} ${h - padB + 11}l3 6l3 -6z`, fill: ink }, g); text(g, below, { x: padL + 9, y: h - padB + 18, 'font-size': 11, fill: ink });
|
|
357
|
+
notesUnder(g, el, N, { padT, h, padB });
|
|
358
|
+
for (const it of N.items) if (it.at != null) it.end = y(up[it.at] || 0) - 4;
|
|
359
|
+
labels.forEach((lab, i) => {
|
|
360
|
+
const o = partial === i ? 0.5 : null;
|
|
361
|
+
if (up[i] > 0) { const r = paintRect(g, { ...(marked(i, 'above') ? st.mark : st.above), opacity: o }, x(i) - bw / 2, y(up[i]), bw, Math.max(0, y(0) - y(up[i]) - 1)); titled(r, `${lab}: ${above} ${format(up[i])}`, {}); }
|
|
362
|
+
if (down[i] > 0) { const r = paintRect(g, { ...(marked(i, 'below') ? st.mark : st.below), opacity: o }, x(i) - bw / 2, y(0) + 1, bw, Math.max(0, y(-down[i]) - y(0) - 1)); titled(r, `${lab}: ${below} ${format(down[i])}`, {}); }
|
|
363
|
+
});
|
|
364
|
+
svg('line', { x1: padL, x2: w, y1: y(0), y2: y(0), stroke: ink, 'stroke-width': 1, opacity: 0.8, 'shape-rendering': 'crispEdges' }, g);
|
|
365
|
+
references(g, el, reference, { y, padL, w, boxes: labels.map((_, i) => ({ x0: x(i) - bw / 2, x1: x(i) + bw / 2, y0: y(up[i]), y1: y(-down[i]) })) });
|
|
366
|
+
notesOver(g, el, N, { padT, h, padB });
|
|
367
|
+
s.setAttribute('aria-label', `${above} above the line and ${below} below it, ${labels[0]} to ${labels.at(-1)}`);
|
|
368
|
+
};
|
|
369
|
+
draw(); watch(el, draw); settle(draw);
|
|
370
|
+
const items = [];
|
|
371
|
+
if (up.some((v) => v > 0)) items.push({ label: above, draw: rectSwatch(st.above) });
|
|
372
|
+
if (down.some((v) => v > 0)) items.push({ label: below, draw: rectSwatch(st.below) });
|
|
373
|
+
if (mark.length && markLabel) items.push({ label: markLabel, draw: rectSwatch(st.mark) });
|
|
374
|
+
return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
|
|
375
|
+
}
|
|
376
|
+
function ring(el, { value, label, sub, size = 132, stroke = 8, colour }) {
|
|
377
|
+
el.innerHTML = '';
|
|
378
|
+
const r = (size - stroke) / 2, c = 2 * Math.PI * r, v = Math.max(0, Math.min(1, value));
|
|
379
|
+
const wrap = document.createElement('div'); wrap.style.cssText = `position:relative;width:${size}px;height:${size}px`;
|
|
380
|
+
const s = svg('svg', { viewBox: `0 0 ${size} ${size}`, width: size, height: size, role: 'img', 'aria-label': `${label}${sub ? ' ' + sub : ''}` }, wrap);
|
|
381
|
+
svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: css(el, '--grid', '#e8e8e8'), 'stroke-width': stroke }, s);
|
|
382
|
+
svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: colour || css(el, '--chart-1', '#2f6fdf'), 'stroke-width': stroke, 'stroke-dasharray': `${c * v} ${c}`, transform: `rotate(-90 ${size / 2} ${size / 2})` }, s);
|
|
383
|
+
const t = document.createElement('div'); t.style.cssText = 'position:absolute;inset:0;display:grid;place-content:center;text-align:center;font-variant-numeric:tabular-nums';
|
|
384
|
+
t.innerHTML = `<div style="font-size:${Math.round(size / 5.5)}px;font-weight:600;line-height:1">${label}</div>${sub ? `<div style="font-size:12px;opacity:.7;margin-top:4px">${sub}</div>` : ''}`;
|
|
385
|
+
wrap.appendChild(t); el.appendChild(wrap);
|
|
386
|
+
}
|
|
387
|
+
// A ranked list. By default the row's fill is the bar (a one-figure list); thin: true puts a 2px grey bar under each
|
|
388
|
+
// label instead, ink for the top item, for a ranked list of money in a narrow card.
|
|
389
|
+
function barList(el, rows, { format = fmt.int, colour, thin = false } = {}) {
|
|
390
|
+
el.innerHTML = '';
|
|
391
|
+
const max = Math.max(...rows.map((r) => r.value)) || 1;
|
|
392
|
+
const list = document.createElement('ol'); list.style.cssText = `list-style:none;margin:0;padding:0;display:grid;gap:${thin ? 10 : 2}px;font-variant-numeric:tabular-nums`;
|
|
393
|
+
const valueCol = Math.max(...rows.map((r) => String(format(r.value)).length)) + 1;
|
|
394
|
+
rows.forEach((r, k) => {
|
|
395
|
+
const li = document.createElement('li'); li.style.cssText = 'display:grid;grid-template-columns:minmax(0,1fr) ' + valueCol + 'ch;align-items:center;column-gap:12px';
|
|
396
|
+
const a = document.createElement('span'); a.textContent = r.label; a.style.cssText = 'position:relative;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
|
|
397
|
+
const b = document.createElement('span'); b.textContent = format(r.value); b.style.cssText = 'font-weight:500;text-align:right';
|
|
398
|
+
if (thin) {
|
|
399
|
+
const bar = document.createElement('span'); bar.style.cssText = `grid-column:1 / -1;height:2px;margin-top:4px;width:${(r.value / max) * 100}%;background:${k === 0 ? colour || 'var(--ink,#111)' : 'var(--chart-muted,#c9c9c9)'}`;
|
|
400
|
+
li.append(a, b, bar);
|
|
401
|
+
} else {
|
|
402
|
+
const track = document.createElement('span'); track.style.cssText = 'position:relative;padding:10px 12px;border-radius:6px;isolation:isolate;min-width:0';
|
|
403
|
+
const bar = document.createElement('span'); bar.style.cssText = `position:absolute;inset:0 auto 0 0;width:${(r.value / max) * 100}%;background:${colour || 'var(--bar-fill, color-mix(in srgb, var(--chart-1, #2f6fdf) 12%, transparent))'};border-radius:inherit;z-index:-1`;
|
|
404
|
+
track.append(bar, a); li.append(track, b);
|
|
405
|
+
}
|
|
406
|
+
list.appendChild(li);
|
|
407
|
+
});
|
|
408
|
+
el.appendChild(list);
|
|
409
|
+
}
|
|
410
|
+
// Planned against spent: the track is the plan, the fill what is spent, a lighter segment what is
|
|
411
|
+
// already committed (bills to come), and an overrun past the track's end in the negative colour.
|
|
412
|
+
// One row per category: label and figures on one line, the 8px track under them.
|
|
413
|
+
function plan(el, rows, { format = fmt.money('EUR', 0), scale = 'row', note = (r) => r.spent > r.planned ? `${format(r.spent - r.planned)} over` : `${format(r.planned - r.spent - (r.committed || 0))} left` } = {}) {
|
|
414
|
+
el.innerHTML = '';
|
|
415
|
+
const list = document.createElement('ul'); list.style.cssText = 'list-style:none;margin:0;padding:0;display:grid;gap:14px;font-variant-numeric:tabular-nums';
|
|
416
|
+
const max = Math.max(...rows.map((r) => Math.max(r.planned, r.spent + (r.committed || 0))));
|
|
417
|
+
for (const r of rows) {
|
|
418
|
+
const over = r.spent > r.planned;
|
|
419
|
+
const li = document.createElement('li');
|
|
420
|
+
const top = document.createElement('div'); top.style.cssText = 'display:flex;justify-content:space-between;gap:12px;margin-bottom:6px';
|
|
421
|
+
top.innerHTML = `<span>${r.label}</span><span><b style="font-weight:500">${format(r.spent)}</b> <span style="color:var(--muted,#5f6368)">of ${format(r.planned)} · </span><span style="color:${over ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${note(r)}</span></span>`;
|
|
422
|
+
// scale 'row': each track is full width and shows the share used (rows in a grid compare); 'plan': as long as its plan
|
|
423
|
+
const room = over ? 100 / (1 + Math.max(0.06, (r.spent - r.planned) / r.planned)) : 100;
|
|
424
|
+
const track = document.createElement('div'); track.style.cssText = 'position:relative;height:8px;border-radius:999px;background:var(--rule,#eceeef);overflow:visible;width:' + (scale === 'plan' ? (r.planned / max) * room : room) + '%';
|
|
425
|
+
const pct = (v) => (v / r.planned) * 100;
|
|
426
|
+
const spent = Math.min(r.spent, r.planned);
|
|
427
|
+
track.innerHTML = `<span style="position:absolute;inset:0 auto 0 0;width:${pct(spent)}%;border-radius:999px;background:var(--chart-1,#2f6fdf)"></span>`
|
|
428
|
+
+ (r.committed ? `<span style="position:absolute;top:0;bottom:0;left:${pct(spent)}%;width:${Math.min(pct(r.committed), 100 - pct(spent))}%;background:var(--chart-1,#2f6fdf);opacity:.35"></span>` : '')
|
|
429
|
+
+ (over ? `<span style="position:absolute;top:-3px;bottom:-3px;left:100%;width:1px;background:var(--text,#111)"></span><span style="position:absolute;top:0;bottom:0;left:calc(100% + 2px);width:max(12px, ${pct(r.spent - r.planned)}%);border-radius:0 999px 999px 0;background:var(--negative,#c4242a)"></span>` : '');
|
|
430
|
+
track.setAttribute('role', 'img'); track.setAttribute('aria-label', `${r.label}: ${format(r.spent)} of ${format(r.planned)}${r.committed ? `, ${format(r.committed)} committed` : ''}${over ? ', over plan' : ''}`);
|
|
431
|
+
li.append(top, track); list.appendChild(li);
|
|
432
|
+
}
|
|
433
|
+
el.appendChild(list);
|
|
434
|
+
}
|
|
435
|
+
// Share against a target: one row per part, the current share as a bar and a 1px tick at the target,
|
|
436
|
+
// so drift reads at a glance (never two stacked bars to compare).
|
|
437
|
+
function bullet(el, rows, { format = fmt.pct(1), max = Math.max(...rows.map((r) => Math.max(r.value, r.target))) * 1.15 } = {}) {
|
|
438
|
+
el.innerHTML = '';
|
|
439
|
+
const list = document.createElement('ul'); list.style.cssText = 'list-style:none;margin:0;padding:0;display:grid;gap:12px;font-variant-numeric:tabular-nums';
|
|
440
|
+
for (const r of rows) {
|
|
441
|
+
const drift = r.value - r.target;
|
|
442
|
+
const li = document.createElement('li'); li.style.cssText = 'display:grid;grid-template-columns:minmax(90px,30%) 1fr auto;align-items:center;gap:12px';
|
|
443
|
+
li.innerHTML = `<span>${r.label}</span><span style="position:relative;height:10px;background:var(--rule,#eceeef);border-radius:2px"><span style="position:absolute;inset:0 auto 0 0;width:${(r.value / max) * 100}%;background:var(--chart-1,#2f6fdf);border-radius:2px"></span><span style="position:absolute;top:-4px;bottom:-4px;left:${(r.target / max) * 100}%;width:2px;margin-left:-1px;background:var(--text,#111)"></span></span><span><b style="font-weight:500">${format(r.value)}</b> <span style="color:${Math.abs(drift) >= (r.tolerance ?? 0.02) ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${drift >= 0 ? '+' : '−'}${format(Math.abs(drift))} vs ${format(r.target)}</span></span>`;
|
|
444
|
+
li.setAttribute('aria-label', `${r.label}: ${format(r.value)}, target ${format(r.target)}`);
|
|
445
|
+
list.appendChild(li);
|
|
446
|
+
}
|
|
447
|
+
el.appendChild(list);
|
|
448
|
+
}
|
|
449
|
+
// shared by the row charts (timeline, matrix, time strip): a label column with an optional grey subline.
|
|
450
|
+
// A subline needs a 40-44px row pitch; one-line labels sit on 32px.
|
|
451
|
+
function rowLabel(g, s, el, r, { y0, pitch, lw }) {
|
|
452
|
+
const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b');
|
|
453
|
+
if (r.sub) { text(g, fit(s, r.label, lw - 12, 14), { x: 0, y: y0 + pitch / 2 - 3, 'font-size': 14, fill: ink }); text(g, fit(s, r.sub, lw - 12, 12), { x: 0, y: y0 + pitch / 2 + 13, 'font-size': 12, fill: muted }); }
|
|
454
|
+
else text(g, fit(s, r.label, lw - 12, 14), { x: 0, y: y0 + pitch / 2 + 5, 'font-size': 14, fill: ink });
|
|
455
|
+
}
|
|
456
|
+
const dayText = (d) => (typeof d === 'object' && d ? d.label : String(d));
|
|
457
|
+
// day headers: the full label when every one fits its column, else the short form ('19'), else every k-th short
|
|
458
|
+
// label with today always shown and its neighbours left out, so no two headers ever touch
|
|
459
|
+
function dayHeads(g, s, el, days, { x0, cw, y, today }) {
|
|
460
|
+
const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b');
|
|
461
|
+
const full = days.map(dayText), short = days.map((d) => (typeof d === 'object' && d ? d.short || dayText(d) : dayText(d).split(' ').at(-1)));
|
|
462
|
+
const wide = (labs) => Math.max(...labs.map((t) => measure(s, t, 12))) + 6;
|
|
463
|
+
const labs = wide(full) <= cw ? full : short, k = Math.max(1, Math.ceil(wide(labs) / cw));
|
|
464
|
+
labs.forEach((t, i) => {
|
|
465
|
+
if (i !== today && (i % k || (today != null && k > 1 && Math.abs(i - today) < k))) return;
|
|
466
|
+
text(g, t, { x: x0 + i * cw + cw / 2, y, 'text-anchor': 'middle', 'font-size': 12, 'font-weight': i === today ? 600 : 400, fill: i === today ? ink : muted });
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
// The style of each kind of booking: a pale fill, a 1px diagonal hatch, a darker grey step, an outline, or the one
|
|
470
|
+
// chart hue for the screen's main data element. tone: 'negative' turns the edge and the label red (it needs a decision).
|
|
471
|
+
function kindStyle(el, k = {}) {
|
|
472
|
+
const ink = css(el, '--ink', '#111'), surface = css(el, '--surface', '#fff'), neg = css(el, '--negative', '#c4242a'), hue = k.colour || css(el, '--chart-1', '#2f6fdf');
|
|
473
|
+
const tone = k.tone === 'negative' ? { stroke: neg, width: 1.5 } : {};
|
|
474
|
+
const dash = k.dash ? { dash: '3 2' } : {};
|
|
475
|
+
if (k.mark === 'hatch') return { fill: mix(el, ink, surface, 0.04), hatch: mix(el, ink, surface, 0.32), ...tone, ...dash };
|
|
476
|
+
if (k.mark === 'step') return { fill: mix(el, ink, surface, 0.24), ...tone, ...dash };
|
|
477
|
+
if (k.mark === 'outline') return { fill: surface, stroke: mix(el, ink, surface, 0.55), width: 1, ...tone, ...dash };
|
|
478
|
+
if (k.mark === 'hue') return { fill: mix(el, hue, surface, 0.18), edge: hue, ...tone, ...dash };
|
|
479
|
+
return { fill: mix(el, ink, surface, 0.12), ...tone, ...dash };
|
|
480
|
+
}
|
|
481
|
+
// Who is away when: people (or rooms, vans) down the left, days across, a bar per booking told apart by its kind.
|
|
482
|
+
// rows: [{ label: 'Lena Hoffmann', sub: 'Product design · Berlin', bars: [{ from: 3, to: 7, kind: 'holiday', label, note }] }]
|
|
483
|
+
// and { group: 'Brand' } rows for a team subheader
|
|
484
|
+
// kinds: { holiday: { label: 'Holiday', mark: 'fill' | 'hatch' | 'step' | 'outline' | 'hue', code: 'H', tone, dash } }
|
|
485
|
+
// days: ['Mon 19', …] or [{ label: 'Mon 19', short: '19' }]; weekends: [indices]; today: index;
|
|
486
|
+
// holidays: [{ day: index, label: 'All Saints’ Day (Lisbon)' }]; onSelect(bar, row) makes the bars buttons.
|
|
487
|
+
// A label that does not fit its bar becomes the kind's code inside it, then goes after the bar's end (never into the
|
|
488
|
+
// days before it), and is always in the bar's tooltip.
|
|
489
|
+
function timeline(el, rows, kinds = {}, { days = [], weekends = [], today = null, holidays = [], labelWidth = null, rowHeight = null, barHeight = 22, onSelect = null, legend = null, extras = true } = {}) {
|
|
490
|
+
const draw = () => {
|
|
491
|
+
const pitch = rowHeight || (rows.some((r) => r.sub) ? 44 : 32), head = 30, groupH = 30;
|
|
492
|
+
const height = head + rows.reduce((a, r) => a + (r.group ? groupH : pitch), 0) + 2;
|
|
493
|
+
const { s, w, h } = frame(el, height);
|
|
494
|
+
const lw = labelWidth || Math.round(Math.min(220, Math.max(w < 480 ? 96 : 120, w * 0.2)));
|
|
495
|
+
const cw = (w - lw) / Math.max(1, days.length), col = (i) => lw + i * cw;
|
|
496
|
+
const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b'), rule = css(el, '--rule', '#f0f0f0'), raised = css(el, '--raised', '#f4f5f6'), hue = css(el, '--chart-1', '#2f6fdf');
|
|
497
|
+
const g = svg('g', {}, s);
|
|
498
|
+
// weekends one tone step darker, faint day lines, the day header (today in ink)
|
|
499
|
+
for (const i of weekends) svg('rect', { x: col(i), y: head, width: cw, height: h - head, fill: raised }, g);
|
|
500
|
+
for (let i = 0; i <= days.length; i++) svg('line', { x1: col(i), x2: col(i), y1: head, y2: h, stroke: rule, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g);
|
|
501
|
+
dayHeads(g, s, el, days, { x0: lw, cw, y: head - 10, today });
|
|
502
|
+
for (const hd of holidays) { const c = svg('circle', { cx: col(hd.day) + cw / 2, cy: head - 3, r: 2, fill: muted }, g); titled(c, hd.label, {}); }
|
|
503
|
+
if (today != null) svg('line', { x1: col(today) + cw / 2, x2: col(today) + cw / 2, y1: head, y2: h, stroke: hue, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g);
|
|
504
|
+
let y0 = head;
|
|
505
|
+
for (const r of rows) {
|
|
506
|
+
if (r.group) { svg('line', { x1: 0, x2: w, y1: y0 + 0.5, y2: y0 + 0.5, stroke: rule, 'stroke-width': 1 }, g); text(g, r.group, { x: 0, y: y0 + 20, 'font-size': 12, 'font-weight': 500, fill: muted }); y0 += groupH; continue; }
|
|
507
|
+
rowLabel(g, s, el, r, { y0, pitch, lw });
|
|
508
|
+
const bs = (r.bars || []).filter((b) => b.to >= 0 && b.from < days.length).sort((a, b) => a.from - b.from);
|
|
509
|
+
bs.forEach((b, k) => {
|
|
510
|
+
const kind = kinds[b.kind] || {}, st = kindStyle(el, kind);
|
|
511
|
+
const x0 = col(Math.max(0, b.from)) + 2, x1 = col(Math.min(days.length - 1, b.to) + 1) - 2, by = y0 + (pitch - barHeight) / 2;
|
|
512
|
+
const grp = svg('g', onSelect ? { tabindex: 0, role: 'button', style: 'cursor:pointer' } : {}, g);
|
|
513
|
+
paintRect(grp, st, x0, by, x1 - x0, barHeight, 4);
|
|
514
|
+
const label = b.label ?? kind.label ?? '', code = b.code ?? kind.code, tw = measure(s, label, 12), labInk = kind.tone === 'negative' ? css(el, '--negative', '#c4242a') : ink;
|
|
515
|
+
const next = bs[k + 1] ? col(Math.max(0, bs[k + 1].from)) : w;
|
|
516
|
+
// over a hatch the label keeps a halo of the bar's own fill, so the lines never run through the words
|
|
517
|
+
const halo = st.hatch ? { stroke: st.fill, 'stroke-width': 4, 'stroke-linejoin': 'round', 'paint-order': 'stroke' } : {};
|
|
518
|
+
if (label && tw + 16 <= x1 - x0) text(grp, label, { x: x0 + 8 + (st.edge ? 2 : 0), y: by + barHeight / 2 + 4, 'font-size': 12, fill: labInk, ...halo });
|
|
519
|
+
else if (code && measure(s, code, 12) + 8 <= x1 - x0) text(grp, code, { x: (x0 + x1) / 2, y: by + barHeight / 2 + 4, 'text-anchor': 'middle', 'font-size': 12, fill: labInk, ...halo });
|
|
520
|
+
else if (label && x1 + 6 + tw <= Math.min(next - 6, w)) text(grp, label, { x: x1 + 6, y: by + barHeight / 2 + 4, 'font-size': 12, fill: kind.tone === 'negative' ? labInk : muted });
|
|
521
|
+
titled(grp, `${r.label}: ${label}${b.note ? ' \u00b7 ' + b.note : ''}`, {});
|
|
522
|
+
if (onSelect) { grp.addEventListener('click', () => onSelect(b, r)); grp.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(b, r); } }); }
|
|
523
|
+
});
|
|
524
|
+
y0 += pitch;
|
|
525
|
+
}
|
|
526
|
+
s.setAttribute('aria-label', `${rows.filter((r) => !r.group).length} rows across ${days.length} days, ${dayText(days[0])} to ${dayText(days.at(-1))}`);
|
|
527
|
+
};
|
|
528
|
+
draw(); watch(el, draw); settle(draw);
|
|
529
|
+
const used = new Set(rows.flatMap((r) => (r.bars || []).map((b) => b.kind)));
|
|
530
|
+
const items = Object.entries(kinds).filter(([id]) => used.has(id)).map(([, k]) => ({ label: k.label, w: 22, draw: (sw) => { const st = kindStyle(el, k); paintRect(sw, st, 0, 0, 22, 12, 3); } }));
|
|
531
|
+
if (extras && today != null) items.push({ label: 'Today', w: 8, draw: (sw) => svg('line', { x1: 4, x2: 4, y1: 0, y2: 12, stroke: css(el, '--chart-1', '#2f6fdf'), 'stroke-width': 1.5 }, sw) });
|
|
532
|
+
return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
|
|
533
|
+
}
|
|
534
|
+
// Is there enough cover? A team-by-day grid of slack against the minimum (+2, +1, 0, −1). Cells with room to spare
|
|
535
|
+
// stay plain, and one hue ramps toward the negative colour as the slack runs out; the headcount is in the tooltip.
|
|
536
|
+
// rows: [{ label: 'Product design', sub: 'needs 7 of 10', values: [2, 1, 0, -1, null, …], titles: ['8 in', …] }]
|
|
537
|
+
// cols: ['Mon 19', …]; comfort: the slack that needs no attention (2); null is a day with no cover to count (a dash)
|
|
538
|
+
function matrix(el, rows, { cols = [], comfort = 2, today = null, format = fmt.signed(), labelWidth = null, rowHeight = null, levels = ['Room to spare', 'Tight', 'At the minimum', 'Short'], legend = null } = {}) {
|
|
539
|
+
const level = (v) => (v >= comfort ? 0 : v > 0 ? 1 : v === 0 ? 2 : 3);
|
|
540
|
+
const styles = () => { const neg = css(el, '--negative', '#c4242a'), surface = css(el, '--surface', '#fff'), ink = css(el, '--ink', '#111'); return [{ fill: surface, ink: css(el, '--axis-ink', '#6b6b6b'), stroke: css(el, '--grid', '#e5e5e5') }, { fill: mix(el, neg, surface, 0.1), ink }, { fill: mix(el, neg, surface, 0.26), ink }, { fill: neg, ink: surface }]; };
|
|
541
|
+
const draw = () => {
|
|
542
|
+
const pitch = rowHeight || (rows.some((r) => r.sub) ? 44 : 32), head = 26;
|
|
543
|
+
const { s, w, h } = frame(el, head + rows.length * pitch);
|
|
544
|
+
const lw = labelWidth || Math.round(Math.min(220, Math.max(w < 480 ? 96 : 120, w * 0.22)));
|
|
545
|
+
const cw = (w - lw) / Math.max(1, cols.length), ST = styles(), muted = css(el, '--axis-ink', '#6b6b6b'), ink = css(el, '--ink', '#111');
|
|
546
|
+
const g = svg('g', {}, s);
|
|
547
|
+
dayHeads(g, s, el, cols, { x0: lw, cw, y: head - 10, today });
|
|
548
|
+
rows.forEach((r, k) => {
|
|
549
|
+
const y0 = head + k * pitch; rowLabel(g, s, el, r, { y0, pitch, lw });
|
|
550
|
+
const ch = Math.min(pitch - 6, 28), cy = y0 + (pitch - ch) / 2;
|
|
551
|
+
r.values.forEach((v, i) => {
|
|
552
|
+
const cx = lw + i * cw + 1;
|
|
553
|
+
if (v == null) { svg('rect', { x: cx + cw / 2 - 4, y: cy + ch / 2 - 1, width: 6, height: 2, fill: muted }, g); return; }
|
|
554
|
+
const st = ST[level(v)], cell = paintRect(g, { fill: st.fill }, cx, cy, cw - 2, ch, 3);
|
|
555
|
+
text(g, format(v), { x: cx + (cw - 2) / 2, y: cy + ch / 2 + 4, 'text-anchor': 'middle', 'font-size': 12, 'font-weight': level(v) === 3 ? 600 : 400, fill: st.ink });
|
|
556
|
+
titled(cell, `${r.label}, ${dayText(cols[i])}: ${r.titles?.[i] || format(v) + ' against the minimum'}`, {});
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
s.setAttribute('aria-label', `Cover against the minimum for ${rows.length} rows across ${cols.length} days`);
|
|
560
|
+
};
|
|
561
|
+
draw(); watch(el, draw); settle(draw);
|
|
562
|
+
const seen = new Set(rows.flatMap((r) => r.values.filter((v) => v != null).map(level)));
|
|
563
|
+
const ST = styles();
|
|
564
|
+
const items = [0, 1, 2, 3].filter((l) => seen.has(l)).map((l) => ({ label: levels[l], w: 14, draw: rectSwatch(l === 0 ? { fill: ST[0].fill, stroke: ST[0].stroke } : { fill: ST[l].fill }, 14, 12, 3) }));
|
|
565
|
+
return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
|
|
566
|
+
}
|
|
567
|
+
// When, within the day: one row per item on a shared open–close axis. A 2px grey track, an ink dot at today's time and
|
|
568
|
+
// a hollow tick at the comparison day's, so "earlier than last Sunday" is drawn and not only written.
|
|
569
|
+
// rows: [{ label: 'White sourdough', sub: 'Headingley', at: '11:20', compare: '12:05', value: '11:20', note: '3 h 40 m before close' }]
|
|
570
|
+
// at or compare may be null (it did not sell out); step: minutes between axis labels (60).
|
|
571
|
+
function timeStrip(el, rows, { open = '08:00', close = '18:00', step = 60, todayLabel = 'Today', compareLabel = null, labelWidth = null, valueWidth = null, rowHeight = null, legend = null } = {}) {
|
|
572
|
+
const o = minutes(open), c = minutes(close);
|
|
573
|
+
const st = { today: { r: 4.5 }, compare: { w: 5, h: 12 } };
|
|
574
|
+
const dot = (parent, cx, cy) => svg('circle', { cx, cy, r: st.today.r, fill: css(el, '--ink', '#111') }, parent);
|
|
575
|
+
const tick = (parent, cx, cy) => svg('rect', { x: cx - st.compare.w / 2, y: cy - st.compare.h / 2, width: st.compare.w, height: st.compare.h, rx: 2.5, fill: css(el, '--surface', '#fff'), stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1.5 }, parent);
|
|
576
|
+
const draw = () => {
|
|
577
|
+
const narrow = (el.clientWidth || 600) < 560, axisH = 24;
|
|
578
|
+
const pitch = rowHeight || (narrow ? 62 : rows.some((r) => r.sub || r.note) ? 44 : 32);
|
|
579
|
+
const { s, w, h } = frame(el, rows.length * pitch + axisH);
|
|
580
|
+
const lw = narrow ? 0 : labelWidth || Math.round(Math.min(200, Math.max(110, w * 0.26)));
|
|
581
|
+
const vw = narrow ? 0 : valueWidth ?? Math.min(w * 0.32, Math.max(0, ...rows.map((r) => Math.max(r.value ? measure(s, r.value, 13, 500) : 0, r.note ? measure(s, r.note, 12) : 0))) + (rows.some((r) => r.value || r.note) ? 16 : 0));
|
|
582
|
+
const x0 = narrow ? 18 : lw + 6, x1 = narrow ? w - 18 : w - vw - 6, tx = (m) => x0 + ((m - o) / Math.max(1, c - o)) * (x1 - x0);
|
|
583
|
+
const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b'), rule = css(el, '--rule', '#f0f0f0');
|
|
584
|
+
const track = css(el, '--chart-track', mix(el, ink, css(el, '--surface', '#fff'), 0.16));
|
|
585
|
+
const g = svg('g', {}, s), plotH = rows.length * pitch;
|
|
586
|
+
// the axis: one step from the opening, the labels thinned by the same rule as the charts
|
|
587
|
+
const marks = []; for (let m = Math.ceil(o / step) * step; m <= c; m += step) marks.push(m);
|
|
588
|
+
const picks = xPicks(marks.map(fmt.clock), x1 - x0);
|
|
589
|
+
for (const i of picks) { const xx = tx(marks[i]); if (!narrow) svg('line', { x1: xx, x2: xx, y1: 0, y2: plotH, stroke: rule, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g); const lab = fmt.clock(marks[i]), tw = measure(s, lab, 12); text(g, lab, { x: Math.max(x0 - 2, Math.min(x1 + 6 - tw, xx - tw / 2)), y: plotH + 17, 'font-size': 12, fill: muted }); }
|
|
590
|
+
rows.forEach((r, k) => {
|
|
591
|
+
const y0 = k * pitch, cy = narrow ? y0 + pitch - 12 : y0 + pitch / 2;
|
|
592
|
+
if (narrow) {
|
|
593
|
+
const vwid = r.value ? measure(s, r.value, 13, 500) + 12 : 0;
|
|
594
|
+
text(g, fit(s, r.label, w - vwid, 14), { x: 0, y: y0 + 16, 'font-size': 14, fill: ink });
|
|
595
|
+
if (r.value) text(g, r.value, { x: w, y: y0 + 16, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink });
|
|
596
|
+
const second = [r.sub, r.note].filter(Boolean).join(' \u00b7 ');
|
|
597
|
+
if (second) text(g, fit(s, second, w, 12), { x: 0, y: y0 + 33, 'font-size': 12, fill: muted });
|
|
598
|
+
} else rowLabel(g, s, el, r, { y0, pitch, lw });
|
|
599
|
+
svg('line', { x1: x0, x2: x1, y1: cy, y2: cy, stroke: track, 'stroke-width': 2, 'stroke-linecap': 'round' }, g);
|
|
600
|
+
const cm = minutes(r.compare), am = minutes(r.at);
|
|
601
|
+
if (cm != null) titled(tick(g, tx(cm), cy), `${compareLabel || 'Comparison'}: ${fmt.clock(cm)}`, {});
|
|
602
|
+
if (am != null) titled(dot(g, tx(am), cy), `${todayLabel}: ${fmt.clock(am)}`, {});
|
|
603
|
+
if (narrow) return;
|
|
604
|
+
if (r.note) { if (r.value) text(g, r.value, { x: w, y: cy - 3, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink }); text(g, fit(s, r.note, vw - 8, 12), { x: w, y: r.value ? cy + 13 : cy + 4, 'text-anchor': 'end', 'font-size': 12, fill: muted }); }
|
|
605
|
+
else if (r.value) text(g, r.value, { x: w, y: cy + 5, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink });
|
|
606
|
+
});
|
|
607
|
+
s.setAttribute('aria-label', `${rows.length} rows between ${fmt.clock(o)} and ${fmt.clock(c)}`);
|
|
608
|
+
};
|
|
609
|
+
draw(); watch(el, draw); settle(draw);
|
|
610
|
+
const items = [];
|
|
611
|
+
if (rows.some((r) => minutes(r.at) != null)) items.push({ label: todayLabel, w: 10, draw: (sw) => dot(sw, 5, 6) });
|
|
612
|
+
if (compareLabel && rows.some((r) => minutes(r.compare) != null)) items.push({ label: compareLabel, w: 8, draw: (sw) => tick(sw, 4, 6) });
|
|
613
|
+
return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
|
|
614
|
+
}
|
|
615
|
+
function watch(el, draw) { if (!('ResizeObserver' in window)) return; let w = el.clientWidth; new ResizeObserver(() => { if (Math.abs(el.clientWidth - w) > 4) { w = el.clientWidth; draw(); } }).observe(el); }
|
|
616
|
+
window.Charts = { line, bars, diverging, ring, barList, plan, bullet, timeline, matrix, timeStrip, ticks, fmt };
|
|
617
|
+
})();
|