@vimoxshah/tokenflow 1.1.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/CONTRIBUTING.md +84 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/Refresh & Open Dashboard.command +22 -0
- package/SECURITY.md +42 -0
- package/bin/tokenflow.js +1342 -0
- package/docs/architecture.md +193 -0
- package/docs/cli.md +390 -0
- package/docs/configuration.md +281 -0
- package/docs/creating-provider.md +262 -0
- package/docs/data-model.md +213 -0
- package/docs/getting-started.md +266 -0
- package/docs/live-mode.md +199 -0
- package/docs/media/architecture-hero.svg +86 -0
- package/docs/media/cost-editorial-dark.png +0 -0
- package/docs/media/health-terminal-light.png +0 -0
- package/docs/media/menubar-dark.png +0 -0
- package/docs/media/menubar-light.png +0 -0
- package/docs/media/models-terminal-dark.png +0 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/time-aurora-light.png +0 -0
- package/docs/providers.md +309 -0
- package/docs/skill.md +64 -0
- package/docs/troubleshooting.md +207 -0
- package/examples/config.example.yaml +92 -0
- package/examples/demo-data/README.md +38 -0
- package/examples/demo-data/sample-usage.csv +11 -0
- package/package.json +74 -0
- package/scripts/build-dmg.sh +33 -0
- package/scripts/build-menubar-app.sh +67 -0
- package/scripts/lint.js +111 -0
- package/scripts/validate-install.js +140 -0
- package/skills/tokenflow/SKILL.md +392 -0
- package/skills/tokenflow/examples/config.yaml +92 -0
- package/skills/tokenflow/examples/generic-mapping.json +26 -0
- package/skills/tokenflow/examples/session-transcript.md +191 -0
- package/skills/tokenflow/providers/adapter-template.js +135 -0
- package/skills/tokenflow/providers/detection-matrix.md +142 -0
- package/skills/tokenflow/schemas/config.schema.json +107 -0
- package/skills/tokenflow/schemas/normalized-record.json +63 -0
- package/src/analytics/aggregate.js +247 -0
- package/src/analytics/anomalies.js +222 -0
- package/src/analytics/capacity.js +278 -0
- package/src/analytics/comparison.js +96 -0
- package/src/analytics/dimensions.js +230 -0
- package/src/analytics/efficiency.js +138 -0
- package/src/analytics/forecast.js +202 -0
- package/src/analytics/index.js +327 -0
- package/src/analytics/insights.js +283 -0
- package/src/analytics/milestones.js +91 -0
- package/src/analytics/peak.js +106 -0
- package/src/analytics/productivity.js +166 -0
- package/src/analytics/token-usage.js +267 -0
- package/src/commands/diagnostics.js +88 -0
- package/src/commands/digest.js +155 -0
- package/src/commands/models-compare.js +96 -0
- package/src/core/budget.js +142 -0
- package/src/core/bundle.js +191 -0
- package/src/core/config.js +202 -0
- package/src/core/delivery.js +109 -0
- package/src/core/geo.js +99 -0
- package/src/core/ingest.js +457 -0
- package/src/core/interface-map.js +55 -0
- package/src/core/jsonl.js +124 -0
- package/src/core/live-status.js +417 -0
- package/src/core/model-map.js +157 -0
- package/src/core/notify.js +83 -0
- package/src/core/pricing.js +288 -0
- package/src/core/prompt-analytics.js +127 -0
- package/src/core/registry.js +107 -0
- package/src/core/restore.js +261 -0
- package/src/core/schedule.js +120 -0
- package/src/core/schema.js +316 -0
- package/src/core/sqlite.js +96 -0
- package/src/core/store.js +493 -0
- package/src/core/sync.js +151 -0
- package/src/core/units.js +147 -0
- package/src/core/validate.js +123 -0
- package/src/core/watch.js +287 -0
- package/src/core/yaml.js +209 -0
- package/src/export/bundler.js +107 -0
- package/src/export/csv.js +100 -0
- package/src/export/html-snapshot.js +101 -0
- package/src/export/menubar.js +158 -0
- package/src/index.js +18 -0
- package/src/providers/anthropic/index.js +294 -0
- package/src/providers/cline/index.js +120 -0
- package/src/providers/cursor/index.js +143 -0
- package/src/providers/generic/index.js +268 -0
- package/src/providers/git/index.js +188 -0
- package/src/providers/headroom/index.js +114 -0
- package/src/providers/hermes/index.js +299 -0
- package/src/providers/mock/index.js +117 -0
- package/src/providers/openai/index.js +370 -0
- package/src/providers/opencode/index.js +245 -0
- package/src/sdk.js +46 -0
- package/src/server/server.js +264 -0
- package/src/ui/app.js +2473 -0
- package/src/ui/charts.js +925 -0
- package/src/ui/index.html +42 -0
- package/src/ui/styles.css +644 -0
package/src/ui/charts.js
ADDED
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small SVG chart library, hand-written so the dashboard has zero runtime
|
|
3
|
+
* dependencies and works with no network access.
|
|
4
|
+
*
|
|
5
|
+
* Every chart here follows the same mark specs: thin marks, hairline solid
|
|
6
|
+
* gridlines, a 2px surface gap between touching fills, a 2px surface ring on
|
|
7
|
+
* overlapping markers, selective direct labels, and a hover layer with hit
|
|
8
|
+
* targets larger than the marks. Colours come from CSS custom properties
|
|
9
|
+
* (--series-1..8, --seq-*), assigned by entity in a fixed order and never by
|
|
10
|
+
* rank, so filtering a series out never repaints the survivors.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
14
|
+
|
|
15
|
+
export const SERIES_VARS = [
|
|
16
|
+
'var(--series-1)', 'var(--series-2)', 'var(--series-3)', 'var(--series-4)',
|
|
17
|
+
'var(--series-5)', 'var(--series-6)', 'var(--series-7)', 'var(--series-8)',
|
|
18
|
+
];
|
|
19
|
+
export const OTHER_COLOR = 'var(--text-muted)';
|
|
20
|
+
export const SEQ = ['var(--seq-1)', 'var(--seq-2)', 'var(--seq-3)', 'var(--seq-4)', 'var(--seq-5)', 'var(--seq-6)', 'var(--seq-7)'];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Stable colour assignment: a key always gets the same slot for the lifetime
|
|
24
|
+
* of the page, so a filter that removes series 2 leaves series 3's colour
|
|
25
|
+
* alone. Past 8 keys everything folds into one muted "Other" — never a
|
|
26
|
+
* generated 9th hue.
|
|
27
|
+
*/
|
|
28
|
+
export class ColorScale {
|
|
29
|
+
constructor(order = []) {
|
|
30
|
+
this.map = new Map();
|
|
31
|
+
order.forEach((k) => this.get(k));
|
|
32
|
+
}
|
|
33
|
+
get(key) {
|
|
34
|
+
if (key === 'Other' || key === 'other') return OTHER_COLOR;
|
|
35
|
+
if (!this.map.has(key)) {
|
|
36
|
+
const i = this.map.size;
|
|
37
|
+
this.map.set(key, i < SERIES_VARS.length ? SERIES_VARS[i] : OTHER_COLOR);
|
|
38
|
+
}
|
|
39
|
+
return this.map.get(key);
|
|
40
|
+
}
|
|
41
|
+
/** Colours available for all-pairs forms (scatter/bubble) — capped at 3. */
|
|
42
|
+
static ALLPAIRS_LIMIT = 3;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function svg(tag, attrs = {}, kids = []) {
|
|
46
|
+
const e = document.createElementNS(NS, tag);
|
|
47
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
48
|
+
if (v === null || v === undefined) continue;
|
|
49
|
+
e.setAttribute(k, String(v));
|
|
50
|
+
}
|
|
51
|
+
for (const k of [].concat(kids)) if (k) e.appendChild(k);
|
|
52
|
+
return e;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function el(tag, attrs = {}, kids = []) {
|
|
56
|
+
const e = document.createElement(tag);
|
|
57
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
58
|
+
if (v === null || v === undefined) continue;
|
|
59
|
+
if (k === 'class') e.className = v;
|
|
60
|
+
else if (k === 'text') e.textContent = v; // always textContent: labels are untrusted data
|
|
61
|
+
else if (k === 'html') e.innerHTML = v;
|
|
62
|
+
else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v);
|
|
63
|
+
else e.setAttribute(k, String(v));
|
|
64
|
+
}
|
|
65
|
+
for (const k of [].concat(kids)) if (k) e.appendChild(typeof k === 'string' ? document.createTextNode(k) : k);
|
|
66
|
+
return e;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ------------------------------------------------------------------ tooltip --
|
|
70
|
+
|
|
71
|
+
let tipEl = null;
|
|
72
|
+
export const tooltip = {
|
|
73
|
+
show(html, ev) {
|
|
74
|
+
if (!tipEl) {
|
|
75
|
+
tipEl = el('div', { class: 'tip', role: 'status' });
|
|
76
|
+
document.body.appendChild(tipEl);
|
|
77
|
+
}
|
|
78
|
+
tipEl.textContent = '';
|
|
79
|
+
tipEl.appendChild(html);
|
|
80
|
+
tipEl.classList.add('on');
|
|
81
|
+
this.move(ev);
|
|
82
|
+
},
|
|
83
|
+
move(ev) {
|
|
84
|
+
if (!tipEl || !ev) return;
|
|
85
|
+
const pad = 14;
|
|
86
|
+
const r = tipEl.getBoundingClientRect();
|
|
87
|
+
let x = ev.clientX + pad;
|
|
88
|
+
let y = ev.clientY + pad;
|
|
89
|
+
if (x + r.width > window.innerWidth - 8) x = ev.clientX - r.width - pad;
|
|
90
|
+
if (y + r.height > window.innerHeight - 8) y = ev.clientY - r.height - pad;
|
|
91
|
+
tipEl.style.left = Math.max(6, x) + 'px';
|
|
92
|
+
tipEl.style.top = Math.max(6, y) + 'px';
|
|
93
|
+
},
|
|
94
|
+
hide() {
|
|
95
|
+
if (tipEl) tipEl.classList.remove('on');
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Build a tooltip body: a head line, then value-led rows. */
|
|
100
|
+
/**
|
|
101
|
+
* A vertical fade for area fills. A flat 10% fill reads as a smudge under the
|
|
102
|
+
* line; a fade puts the ink where the line is and lets the plot floor go back
|
|
103
|
+
* to being surface. Ids are per-call so two charts on a page never collide.
|
|
104
|
+
*/
|
|
105
|
+
let gradSeq = 0;
|
|
106
|
+
export function areaGradient(root, color, { from = 0.30, to = 0.02 } = {}) {
|
|
107
|
+
const id = `agrad-${++gradSeq}`;
|
|
108
|
+
const defs = svg('defs');
|
|
109
|
+
const g = svg('linearGradient', { id, x1: '0', y1: '0', x2: '0', y2: '1' });
|
|
110
|
+
g.appendChild(svg('stop', { offset: '0%', 'stop-color': color, 'stop-opacity': String(from) }));
|
|
111
|
+
g.appendChild(svg('stop', { offset: '100%', 'stop-color': color, 'stop-opacity': String(to) }));
|
|
112
|
+
defs.appendChild(g);
|
|
113
|
+
root.appendChild(defs);
|
|
114
|
+
return `url(#${id})`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function tipBody(head, rows, note) {
|
|
118
|
+
const box = el('div');
|
|
119
|
+
if (head) box.appendChild(el('div', { class: 't-head', text: head }));
|
|
120
|
+
for (const r of rows) {
|
|
121
|
+
const row = el('div', { class: 't-row' });
|
|
122
|
+
if (r.color) {
|
|
123
|
+
const k = el('span', { class: 't-key' });
|
|
124
|
+
k.style.background = r.color;
|
|
125
|
+
row.appendChild(k);
|
|
126
|
+
}
|
|
127
|
+
row.appendChild(el('span', { class: 't-val', text: r.value }));
|
|
128
|
+
row.appendChild(el('span', { class: 't-name', text: r.name }));
|
|
129
|
+
box.appendChild(row);
|
|
130
|
+
}
|
|
131
|
+
if (note) box.appendChild(el('div', { class: 't-note', text: note }));
|
|
132
|
+
return box;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ------------------------------------------------------------------- scales --
|
|
136
|
+
|
|
137
|
+
export function niceTicks(min, max, count = 5) {
|
|
138
|
+
if (!isFinite(min) || !isFinite(max) || max === min) {
|
|
139
|
+
return { ticks: [0, max || 1], min: 0, max: max || 1 };
|
|
140
|
+
}
|
|
141
|
+
const span = max - min;
|
|
142
|
+
const raw = span / Math.max(1, count);
|
|
143
|
+
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
|
|
144
|
+
const norm = raw / mag;
|
|
145
|
+
const step = (norm >= 7.5 ? 10 : norm >= 3.5 ? 5 : norm >= 1.5 ? 2 : 1) * mag;
|
|
146
|
+
const lo = Math.floor(min / step) * step;
|
|
147
|
+
const hi = Math.ceil(max / step) * step;
|
|
148
|
+
const ticks = [];
|
|
149
|
+
for (let v = lo; v <= hi + step / 2; v += step) ticks.push(Math.round(v / step) * step);
|
|
150
|
+
return { ticks, min: lo, max: hi, step };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// -------------------------------------------------------------- time series --
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Multi-series time chart.
|
|
157
|
+
* @param {object} o
|
|
158
|
+
* @param {{key:string}[]} o.data one object per bucket
|
|
159
|
+
* @param {{key:string,label:string,color:string,hidden?:boolean}[]} o.keys series definitions
|
|
160
|
+
* @param {'line'|'stacked'} [o.mode]
|
|
161
|
+
* @param {{values:(number|null)[],label:string,color:string}[]} [o.overlays] moving averages
|
|
162
|
+
* @param {(v:number)=>string} o.fmtY
|
|
163
|
+
* @param {(k:string, i?:number)=>string} o.fmtX bucket key -> tick label; the index is passed too
|
|
164
|
+
* @param {(k:string)=>string} [o.fmtXLong] longer label for the tooltip head
|
|
165
|
+
* @param {string} [o.tipNote] footnote under the tooltip rows
|
|
166
|
+
* @param {number[]} [o.peaks] indices to mark
|
|
167
|
+
* @param {(i:number, ev:Event)=>void} [o.onClick]
|
|
168
|
+
* @param {(a:number,b:number)=>void} [o.onBrush]
|
|
169
|
+
* @param {number} [o.height]
|
|
170
|
+
* @param {number} [o.width]
|
|
171
|
+
* @param {string} [o.ariaLabel] accessible name for the <svg>
|
|
172
|
+
* @param {boolean} [o.fillArea] shade the area under the line
|
|
173
|
+
* @param {boolean} [o.endLabel] label the final value at the line's end
|
|
174
|
+
*/
|
|
175
|
+
export function timeSeries(o) {
|
|
176
|
+
const H = o.height || 300;
|
|
177
|
+
const W = o.width || 1000;
|
|
178
|
+
const M = { t: 14, r: 16, b: 26, l: 58 };
|
|
179
|
+
const ih = H - M.t - M.b;
|
|
180
|
+
const data = o.data;
|
|
181
|
+
const keys = o.keys.filter((k) => !k.hidden);
|
|
182
|
+
const n = data.length;
|
|
183
|
+
|
|
184
|
+
const stacked = o.mode === 'stacked';
|
|
185
|
+
let maxV = 0;
|
|
186
|
+
const tops = [];
|
|
187
|
+
for (let i = 0; i < n; i++) {
|
|
188
|
+
if (stacked) {
|
|
189
|
+
let s = 0;
|
|
190
|
+
for (const k of keys) s += Math.max(0, data[i][k.key] || 0);
|
|
191
|
+
tops.push(s);
|
|
192
|
+
if (s > maxV) maxV = s;
|
|
193
|
+
} else {
|
|
194
|
+
for (const k of keys) maxV = Math.max(maxV, data[i][k.key] || 0);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const ov of o.overlays || []) for (const v of ov.values) if (v !== null) maxV = Math.max(maxV, v);
|
|
198
|
+
|
|
199
|
+
const { ticks, max: yMax } = niceTicks(0, maxV || 1, 5);
|
|
200
|
+
const x = (i) => (n <= 1 ? M.l + iw / 2 : M.l + (i / (n - 1)) * iw);
|
|
201
|
+
const y = (v) => M.t + ih - (Math.max(0, v) / (yMax || 1)) * ih;
|
|
202
|
+
// Left margin adapts to the widest y label so long formatters (int's
|
|
203
|
+
// "3,500,000") never clip off-canvas or collide with the rotated axis title.
|
|
204
|
+
const fmtY = o.fmtY || ((v) => String(v));
|
|
205
|
+
const widestY = Math.max(0, ...ticks.map((t) => String(fmtY(t)).length)) * 6.6;
|
|
206
|
+
M.l = Math.max(M.l, Math.ceil(widestY) + 18);
|
|
207
|
+
const iw = W - M.l - M.r;
|
|
208
|
+
|
|
209
|
+
const root = svg('svg', { class: 'chart', viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: 'none', role: 'img', 'aria-label': o.ariaLabel || 'time series' });
|
|
210
|
+
root.style.height = H + 'px';
|
|
211
|
+
|
|
212
|
+
// gridlines + y ticks (solid hairlines, one step off surface)
|
|
213
|
+
const g = svg('g');
|
|
214
|
+
for (const t of ticks) {
|
|
215
|
+
g.appendChild(svg('line', { class: 'grid-line', x1: M.l, x2: W - M.r, y1: y(t), y2: y(t) }));
|
|
216
|
+
g.appendChild(svg('text', { class: 'tick', x: M.l - 8, y: y(t) + 3.5, 'text-anchor': 'end' }, [txt(o.fmtY(t))]));
|
|
217
|
+
}
|
|
218
|
+
root.appendChild(g);
|
|
219
|
+
|
|
220
|
+
// x ticks: ~7 labels, always including first and last
|
|
221
|
+
const step = Math.max(1, Math.ceil(n / 7));
|
|
222
|
+
const xg = svg('g');
|
|
223
|
+
for (let i = 0; i < n; i += step) {
|
|
224
|
+
xg.appendChild(svg('text', { class: 'tick', x: x(i), y: H - 8, 'text-anchor': 'middle' }, [txt(o.fmtX(data[i].key, i))]));
|
|
225
|
+
}
|
|
226
|
+
if (n > 1 && (n - 1) % step !== 0) {
|
|
227
|
+
xg.appendChild(svg('text', { class: 'tick', x: x(n - 1), y: H - 8, 'text-anchor': 'end' }, [txt(o.fmtX(data[n - 1].key, n - 1))]));
|
|
228
|
+
}
|
|
229
|
+
root.appendChild(xg);
|
|
230
|
+
root.appendChild(svg('line', { class: 'axis-line', x1: M.l, x2: W - M.r, y1: y(0), y2: y(0) }));
|
|
231
|
+
|
|
232
|
+
// marks
|
|
233
|
+
if (stacked) {
|
|
234
|
+
const base = new Array(n).fill(0);
|
|
235
|
+
for (const k of keys) {
|
|
236
|
+
const upper = [];
|
|
237
|
+
const lower = [];
|
|
238
|
+
for (let i = 0; i < n; i++) {
|
|
239
|
+
const v = Math.max(0, data[i][k.key] || 0);
|
|
240
|
+
lower.push(base[i]);
|
|
241
|
+
base[i] += v;
|
|
242
|
+
upper.push(base[i]);
|
|
243
|
+
}
|
|
244
|
+
const d = areaPath(upper, lower, x, y);
|
|
245
|
+
root.appendChild(svg('path', { class: 'series-area', d, fill: k.color, 'fill-opacity': 0.85 }));
|
|
246
|
+
// 2px surface gap between touching fills — white doing the separating
|
|
247
|
+
root.appendChild(svg('path', {
|
|
248
|
+
class: 'series-line', d: linePath(upper, x, y),
|
|
249
|
+
stroke: 'var(--surface-1)', 'stroke-width': 2,
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
for (const k of keys) {
|
|
254
|
+
const vals = data.map((d) => d[k.key] || 0);
|
|
255
|
+
if (o.fillArea) {
|
|
256
|
+
root.appendChild(svg('path', {
|
|
257
|
+
class: 'series-area', d: areaPath(vals, new Array(n).fill(0), x, y),
|
|
258
|
+
fill: areaGradient(root, k.color),
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
root.appendChild(svg('path', { class: 'series-line', d: linePath(vals, x, y), stroke: k.color }));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for (const ov of o.overlays || []) {
|
|
266
|
+
root.appendChild(svg('path', {
|
|
267
|
+
class: 'ma-line', d: linePathSparse(ov.values, x, y), stroke: ov.color, 'stroke-opacity': 0.95,
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// peak markers: a ring, not a label on every point
|
|
272
|
+
for (const pi of o.peaks || []) {
|
|
273
|
+
if (pi < 0 || pi >= n) continue;
|
|
274
|
+
const v = stacked ? tops[pi] : Math.max(...keys.map((k) => data[pi][k.key] || 0));
|
|
275
|
+
root.appendChild(svg('circle', { class: 'peak-mark', cx: x(pi), cy: y(v), r: 5, stroke: 'var(--text-primary)' }));
|
|
276
|
+
root.appendChild(svg('circle', { cx: x(pi), cy: y(v), r: 2, fill: 'var(--text-primary)' }));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// one direct label: the endpoint of the leading series (selective by design)
|
|
280
|
+
// Positioned to the LEFT of and ABOVE the endpoint with a collision guard:
|
|
281
|
+
// if the label would cross the line's own path near the end, it drops
|
|
282
|
+
// further above; x sits inside the right margin so it never clips.
|
|
283
|
+
if (o.endLabel && n) {
|
|
284
|
+
const lastVal = stacked ? tops[n - 1] : (data[n - 1][keys[0]?.key] || 0);
|
|
285
|
+
if (lastVal > 0) {
|
|
286
|
+
const lx = Math.min(x(n - 1) - 4, W - M.r);
|
|
287
|
+
const ly = Math.max(M.t + 10, y(lastVal) - 8);
|
|
288
|
+
root.appendChild(svg('text', {
|
|
289
|
+
class: 'tick', x: lx, y: ly, 'text-anchor': 'end',
|
|
290
|
+
style: 'fill: var(--text-secondary); font-size: 11px; font-weight: 600',
|
|
291
|
+
}, [txt(o.fmtY(lastVal))]));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---- hover layer: crosshair snapping to the nearest X -------------------
|
|
296
|
+
const cross = svg('line', { class: 'crosshair', y1: M.t, y2: M.t + ih, opacity: 0 });
|
|
297
|
+
const dots = svg('g', { opacity: 0 });
|
|
298
|
+
root.appendChild(cross);
|
|
299
|
+
root.appendChild(dots);
|
|
300
|
+
const hit = svg('rect', { class: 'hit', x: M.l, y: M.t, width: iw, height: ih, 'pointer-events': 'all' });
|
|
301
|
+
root.appendChild(hit);
|
|
302
|
+
|
|
303
|
+
let brushing = null;
|
|
304
|
+
const brushRect = svg('rect', { class: 'brush-rect', y: M.t, height: ih, width: 0, opacity: 0 });
|
|
305
|
+
root.insertBefore(brushRect, cross);
|
|
306
|
+
|
|
307
|
+
const nearest = (ev) => {
|
|
308
|
+
const box = root.getBoundingClientRect();
|
|
309
|
+
const px = ((ev.clientX - box.left) / box.width) * W;
|
|
310
|
+
if (n <= 1) return 0;
|
|
311
|
+
const i = Math.round(((px - M.l) / iw) * (n - 1));
|
|
312
|
+
return Math.max(0, Math.min(n - 1, i));
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
hit.addEventListener('pointermove', (ev) => {
|
|
316
|
+
const i = nearest(ev);
|
|
317
|
+
cross.setAttribute('x1', x(i));
|
|
318
|
+
cross.setAttribute('x2', x(i));
|
|
319
|
+
cross.setAttribute('opacity', 1);
|
|
320
|
+
dots.textContent = '';
|
|
321
|
+
dots.setAttribute('opacity', 1);
|
|
322
|
+
let acc = 0;
|
|
323
|
+
const rows = [];
|
|
324
|
+
for (const k of keys) {
|
|
325
|
+
const v = data[i][k.key];
|
|
326
|
+
if (stacked) acc += Math.max(0, v || 0);
|
|
327
|
+
const cy = stacked ? y(acc) : y(v || 0);
|
|
328
|
+
// surface ring keeps the dot legible where it crosses a line
|
|
329
|
+
dots.appendChild(svg('circle', { cx: x(i), cy, r: 4.5, fill: k.color, stroke: 'var(--surface-1)', 'stroke-width': 2 }));
|
|
330
|
+
rows.push({ color: k.color, name: k.label, value: v === null || v === undefined ? 'n/a' : o.fmtY(v) });
|
|
331
|
+
}
|
|
332
|
+
for (const ov of o.overlays || []) {
|
|
333
|
+
const v = ov.values[i];
|
|
334
|
+
if (v === null || v === undefined) continue;
|
|
335
|
+
rows.push({ color: ov.color, name: ov.label, value: o.fmtY(v) });
|
|
336
|
+
}
|
|
337
|
+
if (stacked && keys.length > 1) rows.unshift({ color: null, name: 'Total', value: o.fmtY(tops[i]) });
|
|
338
|
+
tooltip.show(tipBody(o.fmtXLong ? o.fmtXLong(data[i].key) : data[i].key, rows, o.tipNote), ev);
|
|
339
|
+
if (brushing !== null) {
|
|
340
|
+
const a = Math.min(x(brushing), x(i));
|
|
341
|
+
brushRect.setAttribute('x', a);
|
|
342
|
+
brushRect.setAttribute('width', Math.abs(x(i) - x(brushing)));
|
|
343
|
+
brushRect.setAttribute('opacity', 1);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
hit.addEventListener('pointerleave', () => {
|
|
347
|
+
cross.setAttribute('opacity', 0);
|
|
348
|
+
dots.setAttribute('opacity', 0);
|
|
349
|
+
tooltip.hide();
|
|
350
|
+
});
|
|
351
|
+
if (o.onBrush) {
|
|
352
|
+
hit.addEventListener('pointerdown', (ev) => { brushing = nearest(ev); hit.setPointerCapture(ev.pointerId); });
|
|
353
|
+
hit.addEventListener('pointerup', (ev) => {
|
|
354
|
+
const b = nearest(ev);
|
|
355
|
+
brushRect.setAttribute('opacity', 0);
|
|
356
|
+
const a = brushing;
|
|
357
|
+
brushing = null;
|
|
358
|
+
if (a === null) return;
|
|
359
|
+
if (Math.abs(b - a) < 1) {
|
|
360
|
+
if (o.onClick) o.onClick(b, ev);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
o.onBrush(Math.min(a, b), Math.max(a, b));
|
|
364
|
+
});
|
|
365
|
+
} else if (o.onClick) {
|
|
366
|
+
hit.addEventListener('click', (ev) => o.onClick(nearest(ev), ev));
|
|
367
|
+
}
|
|
368
|
+
hit.style.cursor = o.onBrush || o.onClick ? 'crosshair' : 'default';
|
|
369
|
+
return root;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function linePath(vals, x, y) {
|
|
373
|
+
let d = '';
|
|
374
|
+
vals.forEach((v, i) => { d += (i ? 'L' : 'M') + x(i).toFixed(2) + ' ' + y(v).toFixed(2); });
|
|
375
|
+
return d;
|
|
376
|
+
}
|
|
377
|
+
function linePathSparse(vals, x, y) {
|
|
378
|
+
let d = '';
|
|
379
|
+
let open = false;
|
|
380
|
+
vals.forEach((v, i) => {
|
|
381
|
+
if (v === null || v === undefined) { open = false; return; }
|
|
382
|
+
d += (open ? 'L' : 'M') + x(i).toFixed(2) + ' ' + y(v).toFixed(2);
|
|
383
|
+
open = true;
|
|
384
|
+
});
|
|
385
|
+
return d;
|
|
386
|
+
}
|
|
387
|
+
function areaPath(upper, lower, x, y) {
|
|
388
|
+
let d = '';
|
|
389
|
+
upper.forEach((v, i) => { d += (i ? 'L' : 'M') + x(i).toFixed(2) + ' ' + y(v).toFixed(2); });
|
|
390
|
+
for (let i = lower.length - 1; i >= 0; i--) d += 'L' + x(i).toFixed(2) + ' ' + y(lower[i]).toFixed(2);
|
|
391
|
+
return d + 'Z';
|
|
392
|
+
}
|
|
393
|
+
function txt(s) {
|
|
394
|
+
return document.createTextNode(String(s));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------- bar chart --
|
|
398
|
+
|
|
399
|
+
/** Vertical column chart with a 2px surface gap between adjacent bars. */
|
|
400
|
+
export function columns(o) {
|
|
401
|
+
const H = o.height || 220;
|
|
402
|
+
const W = o.width || 1000;
|
|
403
|
+
const M = { t: 14, r: 12, b: 30, l: 52 };
|
|
404
|
+
const iw = W - M.l - M.r;
|
|
405
|
+
const ih = H - M.t - M.b;
|
|
406
|
+
const data = o.data;
|
|
407
|
+
const n = data.length;
|
|
408
|
+
const max = Math.max(1, ...data.map((d) => d.value || 0));
|
|
409
|
+
const { ticks, max: yMax } = niceTicks(0, max, 4);
|
|
410
|
+
const band = iw / Math.max(1, n);
|
|
411
|
+
const bw = Math.min(24, Math.max(2, band - 4));
|
|
412
|
+
const y = (v) => M.t + ih - (v / yMax) * ih;
|
|
413
|
+
|
|
414
|
+
const root = svg('svg', { class: 'chart', viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: 'none', role: 'img', 'aria-label': o.ariaLabel || 'bar chart' });
|
|
415
|
+
root.style.height = H + 'px';
|
|
416
|
+
for (const t of ticks) {
|
|
417
|
+
root.appendChild(svg('line', { class: 'grid-line', x1: M.l, x2: W - M.r, y1: y(t), y2: y(t) }));
|
|
418
|
+
root.appendChild(svg('text', { class: 'tick', x: M.l - 8, y: y(t) + 3.5, 'text-anchor': 'end' }, [txt(o.fmtY(t))]));
|
|
419
|
+
}
|
|
420
|
+
data.forEach((d, i) => {
|
|
421
|
+
const cx = M.l + band * i + band / 2;
|
|
422
|
+
const h = Math.max(0, ih - (y(d.value || 0) - M.t));
|
|
423
|
+
const bar = svg('rect', {
|
|
424
|
+
x: cx - bw / 2, y: y(d.value || 0), width: bw, height: Math.max(h, d.value ? 1.5 : 0),
|
|
425
|
+
fill: d.color || 'var(--series-1)', rx: Math.min(4, bw / 2), ry: 4,
|
|
426
|
+
});
|
|
427
|
+
root.appendChild(bar);
|
|
428
|
+
// square off the baseline end: the rounded rect's bottom corners are
|
|
429
|
+
// covered so the bar grows from a flat baseline
|
|
430
|
+
if (h > 5) root.appendChild(svg('rect', { x: cx - bw / 2, y: y(0) - 4, width: bw, height: 4, fill: d.color || 'var(--series-1)' }));
|
|
431
|
+
const hitW = Math.max(24, band);
|
|
432
|
+
const hb = svg('rect', { class: 'hit', x: cx - hitW / 2, y: M.t, width: hitW, height: ih, 'pointer-events': 'all' });
|
|
433
|
+
hb.addEventListener('pointermove', (ev) => {
|
|
434
|
+
bar.setAttribute('opacity', 0.78);
|
|
435
|
+
tooltip.show(tipBody(o.fmtXLong ? o.fmtXLong(d) : d.label, [{ color: d.color || 'var(--series-1)', name: o.valueLabel || 'Tokens', value: o.fmtY(d.value) }, ...(d.extra || [])], o.tipNote), ev);
|
|
436
|
+
});
|
|
437
|
+
hb.addEventListener('pointerleave', () => { bar.removeAttribute('opacity'); tooltip.hide(); });
|
|
438
|
+
if (o.onClick) { hb.style.cursor = 'pointer'; hb.addEventListener('click', (ev) => o.onClick(d, ev)); }
|
|
439
|
+
root.appendChild(hb);
|
|
440
|
+
if (n <= 24 || i % Math.ceil(n / 12) === 0) {
|
|
441
|
+
root.appendChild(svg('text', { class: 'tick', x: cx, y: H - 9, 'text-anchor': 'middle' }, [txt(d.label)]));
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
root.appendChild(svg('line', { class: 'axis-line', x1: M.l, x2: W - M.r, y1: y(0), y2: y(0) }));
|
|
445
|
+
return root;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ------------------------------------------------------------ horizontal bars
|
|
449
|
+
|
|
450
|
+
/** DOM (not SVG) horizontal bars — cheaper, and text wraps/ellipsises properly. */
|
|
451
|
+
export function hbars(rows, o = {}) {
|
|
452
|
+
const box = el('div');
|
|
453
|
+
const max = Math.max(1, ...rows.map((r) => r.value || 0));
|
|
454
|
+
for (const r of rows) {
|
|
455
|
+
const w = ((r.value || 0) / max) * 100;
|
|
456
|
+
const row = el('div', { class: 'bar-row' + (o.onClick ? ' clickable' : '') }, [
|
|
457
|
+
el('span', { class: 'nm', text: r.label, title: r.title || r.label }),
|
|
458
|
+
el('span', { class: 'track' }, [(() => {
|
|
459
|
+
const f = el('span', { class: 'fill' });
|
|
460
|
+
f.style.width = Math.max(w, r.value ? 0.6 : 0) + '%';
|
|
461
|
+
f.style.background = r.color || 'var(--series-1)';
|
|
462
|
+
return f;
|
|
463
|
+
})()]),
|
|
464
|
+
el('span', { class: 'vl', text: o.fmt ? o.fmt(r.value) : String(r.value) }),
|
|
465
|
+
]);
|
|
466
|
+
if (o.onClick) row.addEventListener('click', (ev) => o.onClick(r, ev));
|
|
467
|
+
row.addEventListener('pointermove', (ev) => tooltip.show(tipBody(r.label, r.rows || [{ color: r.color, name: o.valueLabel || 'Tokens', value: o.fmt ? o.fmt(r.value) : r.value }]), ev));
|
|
468
|
+
row.addEventListener('pointerleave', () => tooltip.hide());
|
|
469
|
+
box.appendChild(row);
|
|
470
|
+
}
|
|
471
|
+
return box;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// --------------------------------------------------------------------- donut
|
|
475
|
+
|
|
476
|
+
export function donut(segments, o = {}) {
|
|
477
|
+
const size = o.size || 190;
|
|
478
|
+
const R = size / 2;
|
|
479
|
+
const thick = o.thickness || 26;
|
|
480
|
+
const r0 = R - thick;
|
|
481
|
+
const total = segments.reduce((a, s) => a + (s.value || 0), 0);
|
|
482
|
+
const root = svg('svg', { class: 'chart', viewBox: `0 0 ${size} ${size}`, role: 'img', 'aria-label': o.ariaLabel || 'share' });
|
|
483
|
+
root.style.height = size + 'px';
|
|
484
|
+
root.style.width = size + 'px';
|
|
485
|
+
root.style.flex = 'none';
|
|
486
|
+
if (!total) {
|
|
487
|
+
root.appendChild(svg('circle', { cx: R, cy: R, r: (R + r0) / 2, fill: 'none', stroke: 'var(--surface-3)', 'stroke-width': thick }));
|
|
488
|
+
return root;
|
|
489
|
+
}
|
|
490
|
+
// 2px surface gap between adjacent segments
|
|
491
|
+
const gapDeg = segments.length > 1 ? (2 / (Math.PI * (R + r0))) * 360 : 0;
|
|
492
|
+
let a0 = -90;
|
|
493
|
+
segments.forEach((s) => {
|
|
494
|
+
const sweep = ((s.value || 0) / total) * 360;
|
|
495
|
+
if (sweep <= 0) return;
|
|
496
|
+
const a1 = a0 + sweep;
|
|
497
|
+
const p = svg('path', {
|
|
498
|
+
d: ringSlice(R, R, r0, R, a0 + gapDeg / 2, Math.max(a0 + gapDeg / 2 + 0.01, a1 - gapDeg / 2)),
|
|
499
|
+
fill: s.color || 'var(--series-1)',
|
|
500
|
+
});
|
|
501
|
+
p.addEventListener('pointermove', (ev) => {
|
|
502
|
+
p.setAttribute('opacity', 0.8);
|
|
503
|
+
tooltip.show(tipBody(s.label, [
|
|
504
|
+
{ color: s.color, name: o.valueLabel || 'Tokens', value: o.fmt ? o.fmt(s.value) : s.value },
|
|
505
|
+
{ color: null, name: 'Share', value: ((s.value / total) * 100).toFixed(1) + '%' },
|
|
506
|
+
]), ev);
|
|
507
|
+
});
|
|
508
|
+
p.addEventListener('pointerleave', () => { p.removeAttribute('opacity'); tooltip.hide(); });
|
|
509
|
+
if (o.onClick) { p.style.cursor = 'pointer'; p.addEventListener('click', (ev) => o.onClick(s, ev)); }
|
|
510
|
+
root.appendChild(p);
|
|
511
|
+
a0 = a1;
|
|
512
|
+
});
|
|
513
|
+
if (o.center) {
|
|
514
|
+
root.appendChild(svg('text', {
|
|
515
|
+
x: R, y: R - 2, 'text-anchor': 'middle',
|
|
516
|
+
style: 'fill: var(--text-primary); font-size: 19px; font-weight: 640',
|
|
517
|
+
}, [txt(o.center.value)]));
|
|
518
|
+
root.appendChild(svg('text', {
|
|
519
|
+
x: R, y: R + 15, 'text-anchor': 'middle',
|
|
520
|
+
style: 'fill: var(--text-muted); font-size: 10.5px',
|
|
521
|
+
}, [txt(o.center.label)]));
|
|
522
|
+
}
|
|
523
|
+
return root;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function ringSlice(cx, cy, r0, r1, a0, a1) {
|
|
527
|
+
const p = (r, a) => {
|
|
528
|
+
const t = (a * Math.PI) / 180;
|
|
529
|
+
return [cx + r * Math.cos(t), cy + r * Math.sin(t)];
|
|
530
|
+
};
|
|
531
|
+
const large = a1 - a0 > 180 ? 1 : 0;
|
|
532
|
+
const [x0, y0] = p(r1, a0);
|
|
533
|
+
const [x1, y1] = p(r1, a1);
|
|
534
|
+
const [x2, y2] = p(r0, a1);
|
|
535
|
+
const [x3, y3] = p(r0, a0);
|
|
536
|
+
return `M${x0} ${y0}A${r1} ${r1} 0 ${large} 1 ${x1} ${y1}L${x2} ${y2}A${r0} ${r0} 0 ${large} 0 ${x3} ${y3}Z`;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ---------------------------------------------------------- composition bar --
|
|
540
|
+
|
|
541
|
+
/** A single stacked horizontal bar with a 2px surface gap between segments. */
|
|
542
|
+
export function compositionBar(segments, o = {}) {
|
|
543
|
+
const total = segments.reduce((a, s) => a + (s.value || 0), 0);
|
|
544
|
+
const box = el('div');
|
|
545
|
+
const bar = el('div');
|
|
546
|
+
bar.style.cssText = 'display:flex;gap:2px;height:28px;border-radius:6px;overflow:hidden;background:var(--surface-3)';
|
|
547
|
+
for (const s of segments) {
|
|
548
|
+
if (!s.value) continue;
|
|
549
|
+
const seg = el('div');
|
|
550
|
+
seg.style.cssText = `flex:${s.value} 1 0;background:${s.color};position:relative;cursor:${o.onClick ? 'pointer' : 'default'}`;
|
|
551
|
+
seg.addEventListener('pointermove', (ev) => {
|
|
552
|
+
seg.style.filter = 'brightness(1.15)';
|
|
553
|
+
tooltip.show(tipBody(s.label, [
|
|
554
|
+
{ color: s.color, name: o.valueLabel || 'Tokens', value: o.fmt ? o.fmt(s.value) : s.value },
|
|
555
|
+
{ color: null, name: 'Share', value: ((s.value / total) * 100).toFixed(1) + '%' },
|
|
556
|
+
]), ev);
|
|
557
|
+
});
|
|
558
|
+
seg.addEventListener('pointerleave', () => { seg.style.filter = ''; tooltip.hide(); });
|
|
559
|
+
if (o.onClick) seg.addEventListener('click', (ev) => o.onClick(s, ev));
|
|
560
|
+
bar.appendChild(seg);
|
|
561
|
+
}
|
|
562
|
+
box.appendChild(bar);
|
|
563
|
+
const legend = el('div', { class: 'legend' });
|
|
564
|
+
for (const s of segments) {
|
|
565
|
+
const sw = el('span', { class: 'sw' });
|
|
566
|
+
sw.style.background = s.color;
|
|
567
|
+
legend.appendChild(el('span', { class: 'item' }, [
|
|
568
|
+
sw,
|
|
569
|
+
el('span', { text: `${s.label} ${total ? ((s.value / total) * 100).toFixed(0) : 0}%` }),
|
|
570
|
+
]));
|
|
571
|
+
}
|
|
572
|
+
box.appendChild(legend);
|
|
573
|
+
return box;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ------------------------------------------------------------------ calendar
|
|
577
|
+
|
|
578
|
+
/** GitHub-style year heatmap. Intensity is percentile-based, set by the caller. */
|
|
579
|
+
export function calendarHeatmap(days, o = {}) {
|
|
580
|
+
const wrap = el('div');
|
|
581
|
+
const byDate = new Map(days.map((d) => [d.date, d]));
|
|
582
|
+
const first = days[0]?.date;
|
|
583
|
+
const last = days[days.length - 1]?.date;
|
|
584
|
+
if (!first) return wrap;
|
|
585
|
+
|
|
586
|
+
const start = shiftToMonday(first);
|
|
587
|
+
const cols = [];
|
|
588
|
+
let cur = start;
|
|
589
|
+
let guard = 0;
|
|
590
|
+
while (cur <= last && guard++ < 800) {
|
|
591
|
+
const col = [];
|
|
592
|
+
for (let i = 0; i < 7; i++) {
|
|
593
|
+
col.push(cur);
|
|
594
|
+
cur = addDays(cur, 1);
|
|
595
|
+
}
|
|
596
|
+
cols.push(col);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const months = el('div', { class: 'cal-months' });
|
|
600
|
+
let lastMonth = '';
|
|
601
|
+
for (const col of cols) {
|
|
602
|
+
const m = col[0].slice(0, 7);
|
|
603
|
+
const lbl = m !== lastMonth ? monthShort(m) : '';
|
|
604
|
+
lastMonth = m;
|
|
605
|
+
const c = el('span', { text: lbl });
|
|
606
|
+
c.style.cssText = 'width:12px;flex:none;overflow:visible;white-space:nowrap';
|
|
607
|
+
months.appendChild(c);
|
|
608
|
+
}
|
|
609
|
+
wrap.appendChild(months);
|
|
610
|
+
|
|
611
|
+
const body = el('div', { class: 'cal' });
|
|
612
|
+
const dows = el('div', { class: 'cal-dows' });
|
|
613
|
+
['M', '', 'W', '', 'F', '', 'S'].forEach((d) => dows.appendChild(el('span', { text: d })));
|
|
614
|
+
body.appendChild(dows);
|
|
615
|
+
|
|
616
|
+
for (const col of cols) {
|
|
617
|
+
const c = el('div', { class: 'cal-col' });
|
|
618
|
+
for (const date of col) {
|
|
619
|
+
const d = byDate.get(date);
|
|
620
|
+
const lvl = d ? o.levelOf(d.total, d.active) : 0;
|
|
621
|
+
const cell = el('div', {
|
|
622
|
+
class: `cal-cell l${lvl}${d ? '' : ' out'}${o.selected === date ? ' sel' : ''}`,
|
|
623
|
+
role: 'gridcell', tabindex: d ? '0' : '-1',
|
|
624
|
+
'aria-label': `${date}: ${d ? o.fmt(d.total) : 'no data'}`,
|
|
625
|
+
});
|
|
626
|
+
if (d) {
|
|
627
|
+
const showTip = (ev) => tooltip.show(tipBody(o.fmtDate ? o.fmtDate(date) : date, [
|
|
628
|
+
{ color: null, name: 'Total', value: o.fmt(d.total) },
|
|
629
|
+
{ color: 'var(--series-1)', name: 'Input', value: o.fmt(d.in) },
|
|
630
|
+
{ color: 'var(--series-2)', name: 'Output', value: o.fmt(d.out) },
|
|
631
|
+
{ color: 'var(--series-3)', name: 'Cache', value: o.fmt(d.cr + d.cw) },
|
|
632
|
+
{ color: null, name: 'Requests', value: String(d.req) },
|
|
633
|
+
], d.active ? 'Click for the full day breakdown' : 'No usage recorded'), ev);
|
|
634
|
+
cell.addEventListener('pointermove', showTip);
|
|
635
|
+
cell.addEventListener('focus', (ev) => showTip({ clientX: cell.getBoundingClientRect().right, clientY: cell.getBoundingClientRect().bottom }));
|
|
636
|
+
cell.addEventListener('pointerleave', () => tooltip.hide());
|
|
637
|
+
cell.addEventListener('blur', () => tooltip.hide());
|
|
638
|
+
if (o.onClick) cell.addEventListener('click', () => o.onClick(date));
|
|
639
|
+
cell.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' && o.onClick) o.onClick(date); });
|
|
640
|
+
}
|
|
641
|
+
c.appendChild(cell);
|
|
642
|
+
}
|
|
643
|
+
body.appendChild(c);
|
|
644
|
+
}
|
|
645
|
+
wrap.appendChild(body);
|
|
646
|
+
|
|
647
|
+
const lg = el('div', { class: 'cal-legend' });
|
|
648
|
+
lg.appendChild(el('span', { text: 'Less' }));
|
|
649
|
+
for (let i = 0; i <= 5; i++) {
|
|
650
|
+
const s = el('span', { class: `cal-cell l${i}` });
|
|
651
|
+
s.style.cursor = 'default';
|
|
652
|
+
lg.appendChild(s);
|
|
653
|
+
}
|
|
654
|
+
lg.appendChild(el('span', { text: 'More' }));
|
|
655
|
+
if (o.note) lg.appendChild(el('span', { class: 'muted', text: '· ' + o.note }));
|
|
656
|
+
wrap.appendChild(lg);
|
|
657
|
+
return wrap;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function shiftToMonday(iso) {
|
|
661
|
+
const [y, m, d] = iso.split('-').map(Number);
|
|
662
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
663
|
+
const dow = (dt.getUTCDay() + 6) % 7;
|
|
664
|
+
dt.setUTCDate(dt.getUTCDate() - dow);
|
|
665
|
+
return dt.toISOString().slice(0, 10);
|
|
666
|
+
}
|
|
667
|
+
function addDays(iso, n) {
|
|
668
|
+
const [y, m, d] = iso.split('-').map(Number);
|
|
669
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
670
|
+
dt.setUTCDate(dt.getUTCDate() + n);
|
|
671
|
+
return dt.toISOString().slice(0, 10);
|
|
672
|
+
}
|
|
673
|
+
function monthShort(m) {
|
|
674
|
+
return ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][Number(m.slice(5, 7)) - 1];
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// -------------------------------------------------------------- matrix heat --
|
|
678
|
+
|
|
679
|
+
/** Hour x weekday matrix. One hue, light->dark; a scale legend is mandatory. */
|
|
680
|
+
export function matrix(cells, o) {
|
|
681
|
+
const rows = o.rows;
|
|
682
|
+
const cols = o.cols;
|
|
683
|
+
const cw = o.cellW || 26;
|
|
684
|
+
const ch = o.cellH || 20;
|
|
685
|
+
const left = 34;
|
|
686
|
+
const top = 16;
|
|
687
|
+
const W = left + cols.length * cw;
|
|
688
|
+
const H = top + rows.length * ch + 8;
|
|
689
|
+
const root = svg('svg', { class: 'chart', viewBox: `0 0 ${W} ${H}`, role: 'img', 'aria-label': o.ariaLabel || 'heatmap' });
|
|
690
|
+
root.style.height = H + 'px';
|
|
691
|
+
const max = o.max || Math.max(1, ...cells.map((c) => c.value));
|
|
692
|
+
|
|
693
|
+
cols.forEach((c, i) => {
|
|
694
|
+
if (i % (o.colLabelEvery || 2) !== 0) return;
|
|
695
|
+
root.appendChild(svg('text', { class: 'tick', x: left + i * cw + cw / 2, y: 11, 'text-anchor': 'middle' }, [txt(c)]));
|
|
696
|
+
});
|
|
697
|
+
rows.forEach((r, j) => {
|
|
698
|
+
root.appendChild(svg('text', { class: 'tick', x: left - 7, y: top + j * ch + ch / 2 + 3.5, 'text-anchor': 'end' }, [txt(r)]));
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
for (const cell of cells) {
|
|
702
|
+
const v = cell.value;
|
|
703
|
+
const t = max ? v / max : 0;
|
|
704
|
+
const idx = v <= 0 ? -1 : Math.min(SEQ.length - 1, Math.floor(Math.pow(t, 0.55) * SEQ.length));
|
|
705
|
+
const rect = svg('rect', {
|
|
706
|
+
x: left + cell.col * cw + 1, y: top + cell.row * ch + 1,
|
|
707
|
+
width: cw - 2, height: ch - 2, rx: 3,
|
|
708
|
+
fill: idx < 0 ? 'var(--surface-3)' : SEQ[idx],
|
|
709
|
+
});
|
|
710
|
+
rect.addEventListener('pointermove', (ev) => {
|
|
711
|
+
rect.setAttribute('stroke', 'var(--text-primary)');
|
|
712
|
+
rect.setAttribute('stroke-width', '1.5');
|
|
713
|
+
tooltip.show(tipBody(cell.label, [{ color: idx < 0 ? null : SEQ[idx], name: o.valueLabel || 'Tokens', value: o.fmt(v) }, ...(cell.extra || [])]), ev);
|
|
714
|
+
});
|
|
715
|
+
rect.addEventListener('pointerleave', () => { rect.removeAttribute('stroke'); tooltip.hide(); });
|
|
716
|
+
if (o.onClick) { rect.style.cursor = 'pointer'; rect.addEventListener('click', (ev) => o.onClick(cell, ev)); }
|
|
717
|
+
root.appendChild(rect);
|
|
718
|
+
}
|
|
719
|
+
return root;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export function scaleLegend(max, fmt) {
|
|
723
|
+
const box = el('div', { class: 'cal-legend' });
|
|
724
|
+
box.appendChild(el('span', { text: '0' }));
|
|
725
|
+
for (const c of SEQ) {
|
|
726
|
+
const s = el('span');
|
|
727
|
+
s.style.cssText = `width:16px;height:10px;border-radius:2px;background:${c}`;
|
|
728
|
+
box.appendChild(s);
|
|
729
|
+
}
|
|
730
|
+
box.appendChild(el('span', { text: fmt(max) }));
|
|
731
|
+
return box;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// -------------------------------------------------------------------- scatter
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Bubble scatter. Colour groups are capped at 3 because a scatter needs
|
|
738
|
+
* all-pairs colour separation, not just adjacent-pair — past three, everything
|
|
739
|
+
* else becomes a muted "Other" and identity comes from the tooltip and table.
|
|
740
|
+
*/
|
|
741
|
+
export function scatter(points, o) {
|
|
742
|
+
const H = o.height || 320;
|
|
743
|
+
const W = o.width || 900;
|
|
744
|
+
// Left margin adapts to the widest y label so long formatters (e.g. int's
|
|
745
|
+
// "4,000,000") never bleed into the plot or collide with the axis title.
|
|
746
|
+
const sampleY = o.fmtY ? niceTicks(0, Math.max(1, ...points.map((p) => p.y).filter((v) => v !== null && isFinite(v))), 4).ticks : [];
|
|
747
|
+
const widestY = Math.max(0, ...sampleY.map((t) => String(o.fmtY(t)).length)) * 6.6;
|
|
748
|
+
const M = { t: 16, r: 24, b: 54, l: Math.max(62, Math.ceil(widestY) + 18) };
|
|
749
|
+
const iw = W - M.l - M.r;
|
|
750
|
+
const ih = H - M.t - M.b;
|
|
751
|
+
const xs = points.map((p) => p.x).filter((v) => v !== null && isFinite(v));
|
|
752
|
+
const ys = points.map((p) => p.y).filter((v) => v !== null && isFinite(v));
|
|
753
|
+
const xt = niceTicks(0, Math.max(1, ...xs), 5);
|
|
754
|
+
const yt = niceTicks(0, Math.max(1, ...ys), 4);
|
|
755
|
+
const rMax = Math.max(1, ...points.map((p) => p.r || 0));
|
|
756
|
+
const X = (v) => M.l + (v / xt.max) * iw;
|
|
757
|
+
const Y = (v) => M.t + ih - (v / yt.max) * ih;
|
|
758
|
+
const R = (v) => 4 + Math.sqrt((v || 0) / rMax) * 20;
|
|
759
|
+
|
|
760
|
+
const root = svg('svg', { class: 'chart', viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: 'none', role: 'img', 'aria-label': o.ariaLabel || 'scatter' });
|
|
761
|
+
root.style.height = H + 'px';
|
|
762
|
+
for (const t of yt.ticks) {
|
|
763
|
+
root.appendChild(svg('line', { class: 'grid-line', x1: M.l, x2: W - M.r, y1: Y(t), y2: Y(t) }));
|
|
764
|
+
root.appendChild(svg('text', { class: 'tick', x: M.l - 8, y: Y(t) + 3.5, 'text-anchor': 'end' }, [txt(o.fmtY(t))]));
|
|
765
|
+
}
|
|
766
|
+
for (const t of xt.ticks) {
|
|
767
|
+
root.appendChild(svg('text', { class: 'tick', x: X(t), y: H - 32, 'text-anchor': 'middle' }, [txt(o.fmtX(t))]));
|
|
768
|
+
}
|
|
769
|
+
root.appendChild(svg('text', {
|
|
770
|
+
class: 'tick', x: M.l + iw / 2, y: H - 10, 'text-anchor': 'middle',
|
|
771
|
+
style: 'fill: var(--text-secondary)',
|
|
772
|
+
}, [txt(o.xLabel || '')]));
|
|
773
|
+
root.appendChild(svg('text', {
|
|
774
|
+
class: 'tick', x: 12, y: M.t + ih / 2, 'text-anchor': 'middle',
|
|
775
|
+
transform: `rotate(-90 12 ${M.t + ih / 2})`,
|
|
776
|
+
}, [txt(o.yLabel || '')]));
|
|
777
|
+
root.appendChild(svg('line', { class: 'axis-line', x1: M.l, x2: W - M.r, y1: Y(0), y2: Y(0) }));
|
|
778
|
+
|
|
779
|
+
const sorted = [...points].sort((a, b) => (b.r || 0) - (a.r || 0));
|
|
780
|
+
for (const p of sorted) {
|
|
781
|
+
if (p.x === null || p.y === null || !isFinite(p.x) || !isFinite(p.y)) continue;
|
|
782
|
+
const c = svg('circle', {
|
|
783
|
+
cx: X(p.x), cy: Y(p.y), r: R(p.r), fill: p.color, 'fill-opacity': 0.55,
|
|
784
|
+
stroke: 'var(--surface-1)', 'stroke-width': 2,
|
|
785
|
+
});
|
|
786
|
+
root.appendChild(c);
|
|
787
|
+
// hit target of at least 24px, independent of the mark's own radius
|
|
788
|
+
const hitR = Math.max(12, R(p.r));
|
|
789
|
+
const h = svg('circle', { cx: X(p.x), cy: Y(p.y), r: hitR, fill: 'transparent', 'pointer-events': 'all' });
|
|
790
|
+
h.addEventListener('pointermove', (ev) => {
|
|
791
|
+
c.setAttribute('fill-opacity', 0.9);
|
|
792
|
+
tooltip.show(tipBody(p.label, p.rows || []), ev);
|
|
793
|
+
});
|
|
794
|
+
h.addEventListener('pointerleave', () => { c.setAttribute('fill-opacity', 0.55); tooltip.hide(); });
|
|
795
|
+
if (o.onClick) { h.style.cursor = 'pointer'; h.addEventListener('click', (ev) => o.onClick(p, ev)); }
|
|
796
|
+
root.appendChild(h);
|
|
797
|
+
}
|
|
798
|
+
// label only the extremes, never every point. Labels are center-anchored,
|
|
799
|
+
// so points near the left edge spill into the y-axis gutter and collide with
|
|
800
|
+
// the rotated axis title — clamp them inside the plot instead.
|
|
801
|
+
const extremes = [...sorted].slice(0, 3);
|
|
802
|
+
for (const p of extremes) {
|
|
803
|
+
if (p.x === null || p.y === null) continue;
|
|
804
|
+
const label = p.short || p.label;
|
|
805
|
+
// ~5.9px per character at 10.5px; keep half the label inside the plot
|
|
806
|
+
const halfW = label.length * 2.95;
|
|
807
|
+
const lx = Math.min(W - M.r - halfW, Math.max(M.l + halfW, X(p.x)));
|
|
808
|
+
root.appendChild(svg('text', {
|
|
809
|
+
class: 'tick', x: lx, y: Y(p.y) - R(p.r) - 5, 'text-anchor': 'middle',
|
|
810
|
+
style: 'fill: var(--text-secondary); font-size: 10.5px',
|
|
811
|
+
}, [txt(label)]));
|
|
812
|
+
}
|
|
813
|
+
return root;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// ------------------------------------------------------------------ sparkline
|
|
817
|
+
|
|
818
|
+
export function sparkline(values, o = {}) {
|
|
819
|
+
const W = o.width || 120;
|
|
820
|
+
const H = o.height || 26;
|
|
821
|
+
const n = values.length;
|
|
822
|
+
if (!n) return svg('svg', { class: 'chart', viewBox: `0 0 ${W} ${H}` });
|
|
823
|
+
const max = Math.max(1, ...values);
|
|
824
|
+
const x = (i) => (n <= 1 ? W / 2 : (i / (n - 1)) * W);
|
|
825
|
+
const y = (v) => H - 2 - ((v || 0) / max) * (H - 4);
|
|
826
|
+
const root = svg('svg', { class: 'chart', viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: 'none', 'aria-hidden': 'true' });
|
|
827
|
+
root.style.height = H + 'px';
|
|
828
|
+
root.appendChild(svg('path', {
|
|
829
|
+
d: areaPath(values, new Array(n).fill(0), x, (v) => y(v)),
|
|
830
|
+
fill: areaGradient(root, o.color || 'var(--series-1)', { from: 0.34, to: 0.0 }), stroke: 'none',
|
|
831
|
+
}));
|
|
832
|
+
root.appendChild(svg('path', { d: linePath(values, x, y), class: 'series-line', stroke: o.color || 'var(--series-1)', 'stroke-width': 1.6 }));
|
|
833
|
+
root.appendChild(svg('circle', { cx: x(n - 1), cy: y(values[n - 1]), r: 2.4, fill: o.color || 'var(--series-1)' }));
|
|
834
|
+
return root;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ------------------------------------------------------------------- legend --
|
|
838
|
+
|
|
839
|
+
export function legend(items, o = {}) {
|
|
840
|
+
const box = el('div', { class: 'legend' });
|
|
841
|
+
for (const it of items) {
|
|
842
|
+
const sw = el('span', { class: 'sw' + (o.line ? ' line' : '') });
|
|
843
|
+
sw.style.background = it.color;
|
|
844
|
+
const b = el('span', {
|
|
845
|
+
class: 'item', role: o.onToggle ? 'button' : null,
|
|
846
|
+
'aria-pressed': o.onToggle ? String(!it.hidden) : null, tabindex: o.onToggle ? '0' : null,
|
|
847
|
+
}, [sw, el('span', { text: it.label })]);
|
|
848
|
+
if (o.onToggle) {
|
|
849
|
+
b.addEventListener('click', () => o.onToggle(it));
|
|
850
|
+
b.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); o.onToggle(it); } });
|
|
851
|
+
}
|
|
852
|
+
box.appendChild(b);
|
|
853
|
+
}
|
|
854
|
+
return box;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// -------------------------------------------------------------- table twin --
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* The WCAG-clean twin of a chart. Every chart in the dashboard has one, so no
|
|
861
|
+
* value is ever reachable only by hovering.
|
|
862
|
+
*/
|
|
863
|
+
export function table(columns, rows, o = {}) {
|
|
864
|
+
const wrap = el('div', { class: 'tbl-wrap' + (o.tall ? ' tall' : '') });
|
|
865
|
+
const t = el('table', { class: 'tbl' });
|
|
866
|
+
const thead = el('thead');
|
|
867
|
+
const tr = el('tr');
|
|
868
|
+
for (const c of columns) {
|
|
869
|
+
const th = el('th', { class: o.onSort ? '' : 'no-sort', text: c.label, title: c.title || '' });
|
|
870
|
+
if (o.onSort) th.addEventListener('click', () => o.onSort(c.key));
|
|
871
|
+
if (o.sortKey === c.key) th.textContent = `${c.label} ${o.sortDir === 'asc' ? '↑' : '↓'}`;
|
|
872
|
+
tr.appendChild(th);
|
|
873
|
+
}
|
|
874
|
+
thead.appendChild(tr);
|
|
875
|
+
t.appendChild(thead);
|
|
876
|
+
const tb = el('tbody');
|
|
877
|
+
for (const r of rows) {
|
|
878
|
+
const row = el('tr');
|
|
879
|
+
for (const c of columns) {
|
|
880
|
+
const v = typeof c.value === 'function' ? c.value(r) : r[c.key];
|
|
881
|
+
const td = el('td', { class: c.text ? 't' : '' });
|
|
882
|
+
if (v === null || v === undefined || v === '') {
|
|
883
|
+
td.appendChild(el('span', { class: 'na', text: c.na || '—', title: c.naTitle || 'Not available' }));
|
|
884
|
+
} else if (v instanceof Node) {
|
|
885
|
+
td.appendChild(v);
|
|
886
|
+
} else {
|
|
887
|
+
td.textContent = String(v);
|
|
888
|
+
}
|
|
889
|
+
if (c.onClick) {
|
|
890
|
+
td.classList.add('link-cell');
|
|
891
|
+
td.addEventListener('click', () => c.onClick(r));
|
|
892
|
+
}
|
|
893
|
+
row.appendChild(td);
|
|
894
|
+
}
|
|
895
|
+
if (o.onRowClick) { row.style.cursor = 'pointer'; row.addEventListener('click', () => o.onRowClick(r)); }
|
|
896
|
+
tb.appendChild(row);
|
|
897
|
+
}
|
|
898
|
+
t.appendChild(tb);
|
|
899
|
+
wrap.appendChild(t);
|
|
900
|
+
if (!rows.length) wrap.appendChild(el('div', { class: 'empty', text: o.emptyText || 'No rows match the current filters.' }));
|
|
901
|
+
return wrap;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** Inline mini-bar for a table cell (share-of-max), as a DOM node. */
|
|
905
|
+
export function miniBar(frac, color) {
|
|
906
|
+
const s = el('span', { class: 'mini' });
|
|
907
|
+
s.style.width = Math.max(2, (frac || 0) * 56) + 'px';
|
|
908
|
+
if (color) s.style.background = color;
|
|
909
|
+
return s;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** Responsive width helper: charts re-render at the container's real width. */
|
|
913
|
+
export function observeWidth(node, render) {
|
|
914
|
+
let last = 0;
|
|
915
|
+
const run = () => {
|
|
916
|
+
const w = Math.max(320, Math.floor(node.clientWidth));
|
|
917
|
+
if (Math.abs(w - last) < 8) return;
|
|
918
|
+
last = w;
|
|
919
|
+
render(w);
|
|
920
|
+
};
|
|
921
|
+
run();
|
|
922
|
+
const ro = new ResizeObserver(run);
|
|
923
|
+
ro.observe(node);
|
|
924
|
+
return () => ro.disconnect();
|
|
925
|
+
}
|