tledger 0.2.0 → 0.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 +152 -116
- package/bin/token-ledger-cache-image.mjs +1150 -0
- package/bin/token-ledger-controls.mjs +24 -0
- package/bin/token-ledger-rates.mjs +4 -1
- package/bin/token-ledger-terminal.mjs +143 -39
- package/bin/token-ledger-trend-image.mjs +1527 -584
- package/bin/token-ledger-trend-terminal.mjs +73 -28
- package/bin/token-ledger-trend.mjs +25 -11
- package/bin/token-ledger-tui.mjs +20 -14
- package/bin/token-ledger.mjs +536 -137
- package/docs/token-ledger-cli-week.png +0 -0
- package/docs/token-ledger-report-7-day.png +0 -0
- package/lib/token-ledger-importer.mjs +589 -279
- package/lib/token-ledger-snapshot.mjs +267 -0
- package/lib/token-ledger-usage.mjs +524 -0
- package/package.json +11 -5
|
@@ -2,9 +2,15 @@ import { Buffer } from "node:buffer";
|
|
|
2
2
|
|
|
3
3
|
import sharp from "sharp";
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
buildBurnDayBins,
|
|
7
|
+
buildUsageTrend,
|
|
8
|
+
weeklyQuotaObservations,
|
|
9
|
+
} from "./token-ledger-trend.mjs";
|
|
10
|
+
import { FAST_MODE_MULTIPLIER } from "./token-ledger-rates.mjs";
|
|
7
11
|
import { buildActualTokenBins } from "./token-ledger-trend-terminal.mjs";
|
|
12
|
+
import { buildCacheReportData } from "./token-ledger-cache-image.mjs";
|
|
13
|
+
import { usageBucketsInRange } from "../lib/token-ledger-usage.mjs";
|
|
8
14
|
|
|
9
15
|
const MODEL_ORDER = [
|
|
10
16
|
"Luna",
|
|
@@ -38,20 +44,38 @@ const COLORS = {
|
|
|
38
44
|
background: "#0e1420",
|
|
39
45
|
panel: "#151d2c",
|
|
40
46
|
panelBorder: "#273246",
|
|
47
|
+
meterPanel: "#1b1712",
|
|
48
|
+
meterPanelBorder: "rgba(246,183,60,.4)",
|
|
41
49
|
ink: "#f2f5fa",
|
|
42
50
|
secondary: "#aeb8c9",
|
|
43
51
|
muted: "#77839a",
|
|
44
52
|
grid: "#1c2534",
|
|
45
53
|
baseline: "#33405a",
|
|
54
|
+
rule: "rgba(255,255,255,.1)",
|
|
55
|
+
track: "rgba(255,255,255,.09)",
|
|
56
|
+
projectTrack: "rgba(255,255,255,.07)",
|
|
46
57
|
line: "#f6b73c",
|
|
58
|
+
meterAxis: "#cf9a37",
|
|
47
59
|
chipFill: "#151d2c",
|
|
48
60
|
leftAxis: "#7ea2f0",
|
|
61
|
+
deltaUp: "#7fb37a",
|
|
62
|
+
deltaUpFill: "rgba(127,179,122,.14)",
|
|
63
|
+
deltaDown: "#e08a86",
|
|
64
|
+
deltaDownFill: "rgba(217,83,79,.16)",
|
|
65
|
+
remainderBar: "#475569",
|
|
66
|
+
onFill: "rgba(255,255,255,.82)",
|
|
67
|
+
cached: "#2ec4a1",
|
|
68
|
+
uncached: "#d88362",
|
|
69
|
+
weighted: "#c7d2e8",
|
|
70
|
+
cacheTrack: "#202a3a",
|
|
49
71
|
};
|
|
50
72
|
|
|
51
73
|
const FONT_FAMILY = "system-ui, -apple-system, 'Segoe UI', sans-serif";
|
|
74
|
+
const MONO_FAMILY = "ui-monospace, Menlo, monospace";
|
|
52
75
|
const FAST_MODE_LABEL_COLOR = "#a78bfa";
|
|
76
|
+
const MIN_BAR_WIDTH = 26;
|
|
53
77
|
|
|
54
|
-
function escapeXml(value) {
|
|
78
|
+
export function escapeXml(value) {
|
|
55
79
|
return String(value)
|
|
56
80
|
.replaceAll("&", "&")
|
|
57
81
|
.replaceAll("<", "<")
|
|
@@ -60,25 +84,41 @@ function escapeXml(value) {
|
|
|
60
84
|
.replaceAll("'", "'");
|
|
61
85
|
}
|
|
62
86
|
|
|
63
|
-
function compact(value, digits = 2) {
|
|
87
|
+
export function compact(value, digits = 2) {
|
|
64
88
|
if (!Number.isFinite(value)) return "—";
|
|
65
89
|
const absolute = Math.abs(value);
|
|
66
|
-
|
|
90
|
+
const units = [
|
|
67
91
|
[1_000_000_000, "B"],
|
|
68
92
|
[1_000_000, "M"],
|
|
69
93
|
[1_000, "K"],
|
|
70
|
-
]
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
94
|
+
];
|
|
95
|
+
for (let index = 0; index < units.length; index += 1) {
|
|
96
|
+
const [divisor, suffix] = units[index];
|
|
97
|
+
if (absolute < divisor) continue;
|
|
98
|
+
const scaled = value / divisor;
|
|
99
|
+
const magnitude = Math.abs(scaled);
|
|
100
|
+
const precision = magnitude >= 100 ? 0 : magnitude >= 10 ? 1 : digits;
|
|
101
|
+
// Values that round to 1000 of a unit belong to the next unit up
|
|
102
|
+
// (999,999 → 1.00M, not 1000K).
|
|
103
|
+
if (index > 0 && Number(magnitude.toFixed(precision)) >= 1_000) {
|
|
104
|
+
return compact(Math.sign(value) * divisor * 1_000, digits);
|
|
75
105
|
}
|
|
106
|
+
return `${scaled.toFixed(precision)}${suffix}`;
|
|
76
107
|
}
|
|
77
108
|
return Math.round(value).toLocaleString("en-US");
|
|
78
109
|
}
|
|
79
110
|
|
|
80
111
|
function percent(value) {
|
|
81
|
-
|
|
112
|
+
const numeric = Number(value);
|
|
113
|
+
if (!Number.isFinite(numeric)) return "—";
|
|
114
|
+
if (numeric > 0 && numeric < 0.1) return "<0.1%";
|
|
115
|
+
return `${numeric.toFixed(1)}%`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function meterLabel(value) {
|
|
119
|
+
const numeric = Number(value);
|
|
120
|
+
if (!Number.isFinite(numeric)) return "—";
|
|
121
|
+
return `${numeric.toFixed(Number.isInteger(numeric) ? 0 : 1)}%`;
|
|
82
122
|
}
|
|
83
123
|
|
|
84
124
|
function niceCeiling(value) {
|
|
@@ -119,50 +159,6 @@ function sortedModelEntries(values) {
|
|
|
119
159
|
.sort(([left], [right]) => modelSort(left, right));
|
|
120
160
|
}
|
|
121
161
|
|
|
122
|
-
function eventRateCardCredits(event) {
|
|
123
|
-
const computed = creditsForUsage(event.model, event);
|
|
124
|
-
if (Number.isFinite(computed) && computed >= 0) {
|
|
125
|
-
return event.serviceTier === "priority" ? computed * 1.5 : computed;
|
|
126
|
-
}
|
|
127
|
-
const stored = Number(event.rateCardCredits);
|
|
128
|
-
if (
|
|
129
|
-
event.rateCardCredits !== null &&
|
|
130
|
-
event.rateCardCredits !== undefined &&
|
|
131
|
-
Number.isFinite(stored) &&
|
|
132
|
-
stored >= 0
|
|
133
|
-
) {
|
|
134
|
-
return stored;
|
|
135
|
-
}
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function rateCardSummary(snapshot, bounds) {
|
|
140
|
-
const startMs = bounds.start.getTime();
|
|
141
|
-
const endMs = bounds.end.getTime();
|
|
142
|
-
let totalTokens = 0;
|
|
143
|
-
let ratedTokens = 0;
|
|
144
|
-
let credits = 0;
|
|
145
|
-
for (const event of snapshot.events ?? []) {
|
|
146
|
-
const timestampMs = new Date(event.timestamp).getTime();
|
|
147
|
-
if (!Number.isFinite(timestampMs) || timestampMs < startMs || timestampMs >= endMs) {
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
const tokens = Math.max(0, Number(event.totalTokens) || 0);
|
|
151
|
-
totalTokens += tokens;
|
|
152
|
-
const eventCredits = eventRateCardCredits(event);
|
|
153
|
-
if (Number.isFinite(eventCredits) && eventCredits >= 0) {
|
|
154
|
-
ratedTokens += tokens;
|
|
155
|
-
credits += eventCredits;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return {
|
|
159
|
-
totalTokens,
|
|
160
|
-
ratedTokens,
|
|
161
|
-
credits,
|
|
162
|
-
coveragePercent: totalTokens > 0 ? (ratedTokens / totalTokens) * 100 : 0,
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
|
|
166
162
|
function dateParts(dateString) {
|
|
167
163
|
return dateString.split("-").map(Number);
|
|
168
164
|
}
|
|
@@ -175,7 +171,7 @@ function dateStringFromParts(year, month, day) {
|
|
|
175
171
|
.join("-");
|
|
176
172
|
}
|
|
177
173
|
|
|
178
|
-
function shiftCalendarDate(dateString, amount) {
|
|
174
|
+
export function shiftCalendarDate(dateString, amount) {
|
|
179
175
|
const [year, month, day] = dateParts(dateString);
|
|
180
176
|
const date = new Date(Date.UTC(year, month - 1, day + amount));
|
|
181
177
|
return dateStringFromParts(
|
|
@@ -220,12 +216,12 @@ function localWeekdayLabel(dateString, timeZone) {
|
|
|
220
216
|
}).format(zonedMidnight(dateString, timeZone));
|
|
221
217
|
}
|
|
222
218
|
|
|
223
|
-
function
|
|
224
|
-
if (!Number.isFinite(timestampMs)) return "unknown
|
|
219
|
+
function timestampDateLabel(timestampMs, timeZone) {
|
|
220
|
+
if (!Number.isFinite(timestampMs)) return "unknown";
|
|
225
221
|
return new Intl.DateTimeFormat("en-US", {
|
|
226
222
|
timeZone,
|
|
227
|
-
|
|
228
|
-
|
|
223
|
+
month: "short",
|
|
224
|
+
day: "numeric",
|
|
229
225
|
}).format(new Date(timestampMs));
|
|
230
226
|
}
|
|
231
227
|
|
|
@@ -236,7 +232,46 @@ function binDateLabel(bin, timeZone) {
|
|
|
236
232
|
return `${start}–${localDateLabel(lastDate, timeZone).replace(/^[A-Za-z]+ /, "")}`;
|
|
237
233
|
}
|
|
238
234
|
|
|
239
|
-
|
|
235
|
+
// Rough sans-serif advance widths in em units, for placing inline runs
|
|
236
|
+
// (value + chip, legend items, pace rows). SVG has no flow layout.
|
|
237
|
+
export function textWidth(text, size, weight = 400) {
|
|
238
|
+
let units = 0;
|
|
239
|
+
for (const character of String(text)) {
|
|
240
|
+
if (/[il.,:;'|!]/.test(character)) units += 0.3;
|
|
241
|
+
else if (/[Ijtfr\-()[\] ]/.test(character)) units += 0.37;
|
|
242
|
+
else if (/[mwMW@%]/.test(character)) units += 0.92;
|
|
243
|
+
else if (/[A-Z]/.test(character)) units += 0.7;
|
|
244
|
+
else if (/[0-9+±×−]/.test(character)) units += 0.58;
|
|
245
|
+
else units += 0.55;
|
|
246
|
+
}
|
|
247
|
+
return units * size * (weight >= 700 ? 1.05 : 1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function truncateText(text, maxWidth, size, weight = 400) {
|
|
251
|
+
let value = String(text ?? "").replace(/\.{3,}/g, "…");
|
|
252
|
+
if (!(maxWidth > 0) || textWidth(value, size, weight) <= maxWidth) return value;
|
|
253
|
+
if (value.includes("…")) {
|
|
254
|
+
const leading = `${value.split("…", 1)[0].trimEnd()}…`;
|
|
255
|
+
if (textWidth(leading, size, weight) <= maxWidth) return leading;
|
|
256
|
+
value = leading;
|
|
257
|
+
}
|
|
258
|
+
const ellipsis = "…";
|
|
259
|
+
const ellipsisWidth = textWidth(ellipsis, size, weight);
|
|
260
|
+
if (ellipsisWidth >= maxWidth) return ellipsis;
|
|
261
|
+
|
|
262
|
+
const characters = [...value];
|
|
263
|
+
let low = 0;
|
|
264
|
+
let high = characters.length;
|
|
265
|
+
while (low < high) {
|
|
266
|
+
const middle = Math.ceil((low + high) / 2);
|
|
267
|
+
const candidate = `${characters.slice(0, middle).join("")}${ellipsis}`;
|
|
268
|
+
if (textWidth(candidate, size, weight) <= maxWidth) low = middle;
|
|
269
|
+
else high = middle - 1;
|
|
270
|
+
}
|
|
271
|
+
return `${characters.slice(0, low).join("").trimEnd()}${ellipsis}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function svgText({
|
|
240
275
|
x,
|
|
241
276
|
y,
|
|
242
277
|
value,
|
|
@@ -246,75 +281,72 @@ function svgText({
|
|
|
246
281
|
anchor = "start",
|
|
247
282
|
spacing = null,
|
|
248
283
|
opacity = null,
|
|
284
|
+
mono = false,
|
|
249
285
|
}) {
|
|
250
286
|
const spacingAttr = spacing ? ` letter-spacing="${spacing}"` : "";
|
|
251
287
|
const opacityAttr = opacity !== null ? ` opacity="${opacity}"` : "";
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Approximate text fitting for card and footer copy: shrink a little, then
|
|
256
|
-
// ellipsize, so text never crosses its container border.
|
|
257
|
-
function fitLine(text, size, maxWidth, minSize = 10) {
|
|
258
|
-
const widthOf = (value, fontSize) => value.length * fontSize * 0.62;
|
|
259
|
-
let fitted = size;
|
|
260
|
-
while (widthOf(text, fitted) > maxWidth && fitted > minSize) fitted -= 0.5;
|
|
261
|
-
if (widthOf(text, fitted) <= maxWidth) return { text, size: fitted };
|
|
262
|
-
const capacity = Math.max(1, Math.floor(maxWidth / (fitted * 0.62)) - 1);
|
|
263
|
-
return { text: `${text.slice(0, capacity)}…`, size: fitted };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function linePath(points) {
|
|
267
|
-
return points
|
|
268
|
-
.map((point, index) => `${index === 0 ? "M" : "L"}${point.x.toFixed(2)},${point.y.toFixed(2)}`)
|
|
269
|
-
.join(" ");
|
|
288
|
+
const family = mono ? MONO_FAMILY : FONT_FAMILY;
|
|
289
|
+
return `<text x="${x}" y="${y}" fill="${fill}" font-family="${family}" font-size="${size}px" font-weight="${weight}" text-anchor="${anchor}"${spacingAttr}${opacityAttr}>${escapeXml(value)}</text>`;
|
|
270
290
|
}
|
|
271
291
|
|
|
272
|
-
function
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
292
|
+
export function svgRect(x, y, width, height, attrs = {}) {
|
|
293
|
+
const pieces = [
|
|
294
|
+
`x="${Number(x).toFixed(2)}"`,
|
|
295
|
+
`y="${Number(y).toFixed(2)}"`,
|
|
296
|
+
`width="${Math.max(0, Number(width)).toFixed(2)}"`,
|
|
297
|
+
`height="${Math.max(0, Number(height)).toFixed(2)}"`,
|
|
298
|
+
];
|
|
299
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
300
|
+
if (value === null || value === undefined) continue;
|
|
301
|
+
pieces.push(`${key}="${value}"`);
|
|
302
|
+
}
|
|
303
|
+
return `<rect ${pieces.join(" ")}/>`;
|
|
283
304
|
}
|
|
284
305
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
for (
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
306
|
+
// Fritsch–Carlson monotone cubic through the points; keeps the meter line
|
|
307
|
+
// smooth without overshooting between observations.
|
|
308
|
+
function monotonePath(points) {
|
|
309
|
+
const count = points.length;
|
|
310
|
+
if (count < 2) return "";
|
|
311
|
+
const round = (value) => Math.round(value * 100) / 100;
|
|
312
|
+
const dx = [];
|
|
313
|
+
const slope = [];
|
|
314
|
+
for (let index = 0; index < count - 1; index += 1) {
|
|
315
|
+
dx[index] = Math.max(0.01, points[index + 1].x - points[index].x);
|
|
316
|
+
slope[index] = (points[index + 1].y - points[index].y) / dx[index];
|
|
317
|
+
}
|
|
318
|
+
const tangent = [slope[0]];
|
|
319
|
+
for (let index = 1; index < count - 1; index += 1) {
|
|
320
|
+
tangent.push(
|
|
321
|
+
slope[index - 1] * slope[index] <= 0
|
|
322
|
+
? 0
|
|
323
|
+
: (slope[index - 1] + slope[index]) / 2,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
tangent.push(slope[count - 2]);
|
|
327
|
+
for (let index = 0; index < count - 1; index += 1) {
|
|
328
|
+
if (slope[index] === 0) {
|
|
329
|
+
tangent[index] = 0;
|
|
330
|
+
tangent[index + 1] = 0;
|
|
331
|
+
continue;
|
|
309
332
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
333
|
+
const alpha = tangent[index] / slope[index];
|
|
334
|
+
const beta = tangent[index + 1] / slope[index];
|
|
335
|
+
const magnitude = alpha * alpha + beta * beta;
|
|
336
|
+
if (magnitude > 9) {
|
|
337
|
+
const tau = 3 / Math.sqrt(magnitude);
|
|
338
|
+
tangent[index] = tau * alpha * slope[index];
|
|
339
|
+
tangent[index + 1] = tau * beta * slope[index];
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
let path = `M ${round(points[0].x)} ${round(points[0].y)}`;
|
|
343
|
+
for (let index = 0; index < count - 1; index += 1) {
|
|
344
|
+
const h = dx[index];
|
|
345
|
+
path += ` C ${round(points[index].x + h / 3)} ${round(points[index].y + (tangent[index] * h) / 3)}` +
|
|
346
|
+
` ${round(points[index + 1].x - h / 3)} ${round(points[index + 1].y - (tangent[index + 1] * h) / 3)}` +
|
|
347
|
+
` ${round(points[index + 1].x)} ${round(points[index + 1].y)}`;
|
|
316
348
|
}
|
|
317
|
-
return
|
|
349
|
+
return path;
|
|
318
350
|
}
|
|
319
351
|
|
|
320
352
|
function labelEvery(binCount) {
|
|
@@ -323,51 +355,27 @@ function labelEvery(binCount) {
|
|
|
323
355
|
return 3;
|
|
324
356
|
}
|
|
325
357
|
|
|
326
|
-
function
|
|
327
|
-
const
|
|
328
|
-
const
|
|
329
|
-
const chipHeight = small ? 19 : 23;
|
|
330
|
-
const chipWidth = String(value).length * (textSize * 0.62) + paddingX * 2;
|
|
331
|
-
const left = anchor === "middle" ? x - chipWidth / 2 : anchor === "end" ? x - chipWidth : x;
|
|
332
|
-
return [
|
|
333
|
-
`<rect x="${left.toFixed(2)}" y="${(y - chipHeight / 2).toFixed(2)}" width="${chipWidth.toFixed(2)}" height="${chipHeight}" rx="6" fill="${COLORS.chipFill}" stroke="${COLORS.line}" stroke-width="1.25"/>`,
|
|
334
|
-
svgText({
|
|
335
|
-
x: left + chipWidth / 2,
|
|
336
|
-
y: y + textSize * 0.36,
|
|
337
|
-
value,
|
|
338
|
-
fill: COLORS.line,
|
|
339
|
-
size: textSize,
|
|
340
|
-
weight: 650,
|
|
341
|
-
anchor: "middle",
|
|
342
|
-
}),
|
|
343
|
-
].join("\n");
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function priorRangeTotals(snapshot, bounds, days) {
|
|
347
|
-
const startMs = zonedMidnight(
|
|
348
|
-
shiftCalendarDate(bounds.startDateString, -days),
|
|
349
|
-
bounds.timeZone,
|
|
350
|
-
).getTime();
|
|
351
|
-
const endMs = bounds.start.getTime();
|
|
358
|
+
function fallbackProjectRows(snapshot, bounds) {
|
|
359
|
+
const startMs = bounds.start.getTime();
|
|
360
|
+
const endMs = bounds.end.getTime();
|
|
352
361
|
const totals = new Map();
|
|
353
|
-
for (const event of snapshot
|
|
362
|
+
for (const event of usageBucketsInRange(snapshot, startMs, endMs)) {
|
|
354
363
|
const timestampMs = new Date(event.timestamp).getTime();
|
|
355
|
-
if (!Number.isFinite(timestampMs)
|
|
356
|
-
continue;
|
|
357
|
-
}
|
|
364
|
+
if (!Number.isFinite(timestampMs)) continue;
|
|
358
365
|
const tokens = Math.max(0, Number(event.totalTokens) || 0);
|
|
359
366
|
if (!(tokens > 0)) continue;
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}
|
|
365
|
-
return value;
|
|
366
|
-
})();
|
|
367
|
-
void model;
|
|
368
|
-
totals.set(event.model, tokens);
|
|
367
|
+
const project = String(event.project || "Unlabelled activity")
|
|
368
|
+
.replace(/[\t\r\n]+/g, " ")
|
|
369
|
+
.trim() || "Unlabelled activity";
|
|
370
|
+
totals.set(project, (totals.get(project) ?? 0) + tokens);
|
|
369
371
|
}
|
|
370
|
-
return
|
|
372
|
+
return [...totals.entries()]
|
|
373
|
+
.map(([project, totalTokens]) => ({
|
|
374
|
+
project,
|
|
375
|
+
displayProject: project,
|
|
376
|
+
totalTokens,
|
|
377
|
+
}))
|
|
378
|
+
.sort((left, right) => right.totalTokens - left.totalTokens);
|
|
371
379
|
}
|
|
372
380
|
|
|
373
381
|
export function renderTrendImage({
|
|
@@ -376,14 +384,22 @@ export function renderTrendImage({
|
|
|
376
384
|
trend = buildUsageTrend(snapshot, bounds),
|
|
377
385
|
days = bounds.rangeDays ?? 7,
|
|
378
386
|
options = {},
|
|
387
|
+
projectRows = null,
|
|
379
388
|
}) {
|
|
380
389
|
const width = Math.max(900, Math.min(2_400, Number(options.imageWidth) || 1_280));
|
|
381
390
|
const outer = 32;
|
|
382
|
-
const
|
|
383
|
-
const
|
|
384
|
-
const plotWidth =
|
|
391
|
+
const plotLeft = 96;
|
|
392
|
+
const plotRight = width - 96;
|
|
393
|
+
const plotWidth = plotRight - plotLeft;
|
|
394
|
+
const contentRight = width - outer;
|
|
395
|
+
const contentWidth = width - outer * 2;
|
|
385
396
|
|
|
386
|
-
|
|
397
|
+
// Keep daily bars while they fit at the minimum readable width; aggregate
|
|
398
|
+
// longer windows into multi-day columns so bars and labels never overlap.
|
|
399
|
+
const actual = buildActualTokenBins(snapshot, bounds, days, plotWidth, {
|
|
400
|
+
minBinWidth: MIN_BAR_WIDTH,
|
|
401
|
+
preferDaily: true,
|
|
402
|
+
});
|
|
387
403
|
const burn = buildBurnDayBins(trend, bounds, { days, binSize: actual.binSize });
|
|
388
404
|
const meterUsable = Boolean(trend.available && burn.totalPercent > 0);
|
|
389
405
|
const percentMode = Boolean(options.drain) && meterUsable;
|
|
@@ -395,547 +411,1474 @@ export function renderTrendImage({
|
|
|
395
411
|
);
|
|
396
412
|
const hasLine = Boolean(trend.available && (trend.points ?? []).length > 1);
|
|
397
413
|
|
|
398
|
-
// Range totals for the stat cards.
|
|
399
414
|
const totalTokens = [...actual.totals.values()].reduce((sum, value) => sum + value, 0);
|
|
415
|
+
const fastTokens = [...(actual.fastTotals?.values() ?? [])].reduce(
|
|
416
|
+
(sum, value) => sum + value,
|
|
417
|
+
0,
|
|
418
|
+
);
|
|
419
|
+
const hasFast = !percentMode && fastTokens > 0;
|
|
420
|
+
|
|
400
421
|
const modelCards = [...actual.totals.entries()]
|
|
401
422
|
.filter(([, value]) => value > 0 && totalTokens > 0 && value / totalTokens >= 0.01)
|
|
402
423
|
.sort((left, right) => right[1] - left[1])
|
|
403
424
|
.slice(0, 3)
|
|
404
425
|
.map(([model, value]) => ({ model, tokens: value }));
|
|
405
|
-
const fastTokens = [...(actual.fastTotals?.values() ?? [])].reduce((sum, value) => sum + value, 0);
|
|
406
|
-
const hasFast = !percentMode && fastTokens > 0;
|
|
407
426
|
|
|
408
|
-
// Prior-period per-model totals
|
|
409
|
-
const prior = priorRangeTotals(snapshot, bounds, days);
|
|
410
|
-
const priorTotals = new Map();
|
|
411
|
-
for (const event of snapshot.events ?? []) {
|
|
412
|
-
const timestampMs = new Date(event.timestamp).getTime();
|
|
413
|
-
if (!Number.isFinite(timestampMs) || timestampMs < prior.startMs || timestampMs >= prior.endMs) {
|
|
414
|
-
continue;
|
|
415
|
-
}
|
|
416
|
-
const tokens = Math.max(0, Number(event.totalTokens) || 0);
|
|
417
|
-
if (!(tokens > 0)) continue;
|
|
418
|
-
const label = MODEL_ORDER.find((candidate) =>
|
|
419
|
-
String(event.model || "").toLowerCase().includes(candidate.toLowerCase().split(" ")[0]),
|
|
420
|
-
);
|
|
421
|
-
void label;
|
|
422
|
-
}
|
|
423
|
-
// Reuse the bin labeler for prior-period totals so model naming matches.
|
|
427
|
+
// Prior-period per-model totals feed the delta chips.
|
|
424
428
|
const priorBounds = {
|
|
425
429
|
...bounds,
|
|
426
430
|
startDateString: shiftCalendarDate(bounds.startDateString, -days),
|
|
427
431
|
endDateString: shiftCalendarDate(bounds.endDateString, -days),
|
|
428
|
-
start:
|
|
429
|
-
|
|
432
|
+
start: zonedMidnight(
|
|
433
|
+
shiftCalendarDate(bounds.startDateString, -days),
|
|
434
|
+
bounds.timeZone,
|
|
435
|
+
),
|
|
436
|
+
end: bounds.start,
|
|
430
437
|
};
|
|
431
|
-
const
|
|
432
|
-
|
|
438
|
+
const priorTotals = buildActualTokenBins(snapshot, priorBounds, days, plotWidth, {
|
|
439
|
+
minBinWidth: MIN_BAR_WIDTH,
|
|
440
|
+
preferDaily: true,
|
|
441
|
+
}).totals;
|
|
433
442
|
|
|
434
443
|
const latestQuotaPoint = [...(trend.points ?? [])]
|
|
435
444
|
.filter((point) => point.timestampMs <= bounds.end.getTime())
|
|
436
445
|
.at(-1);
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
446
|
+
const latestQuotaReadMs = Number.isFinite(trend.observedThroughMs)
|
|
447
|
+
? trend.observedThroughMs
|
|
448
|
+
: null;
|
|
449
|
+
const resetsInRange = trend.resets ?? [];
|
|
450
|
+
const weeklyObservationsAll = weeklyQuotaObservations(snapshot).filter(
|
|
451
|
+
(observation) => observation.timestampMs < bounds.end.getTime(),
|
|
452
|
+
);
|
|
453
|
+
const latestResetsAtSec = weeklyObservationsAll.at(-1)?.resetsAt ?? null;
|
|
454
|
+
|
|
455
|
+
const rows = projectRows ?? fallbackProjectRows(snapshot, bounds);
|
|
456
|
+
|
|
457
|
+
// Cache bins share the trend chart's bin size so both charts' columns stay
|
|
458
|
+
// vertically aligned.
|
|
459
|
+
const cacheData = buildCacheReportData(
|
|
460
|
+
snapshot,
|
|
461
|
+
bounds,
|
|
462
|
+
days,
|
|
463
|
+
plotWidth,
|
|
464
|
+
actual.binSize,
|
|
465
|
+
);
|
|
466
|
+
const hasCache = cacheData.inputTokens > 0;
|
|
467
|
+
const cacheModelRows = (() => {
|
|
468
|
+
if (!hasCache) return [];
|
|
469
|
+
const models = cacheData.models;
|
|
470
|
+
if (models.length <= 4) return models;
|
|
471
|
+
const rest = models.slice(3);
|
|
472
|
+
const restInput = rest.reduce((sum, model) => sum + model.inputTokens, 0);
|
|
473
|
+
const restCached = rest.reduce(
|
|
474
|
+
(sum, model) => sum + model.cachedInputTokens,
|
|
475
|
+
0,
|
|
476
|
+
);
|
|
477
|
+
return [...models.slice(0, 3), {
|
|
478
|
+
model: `${rest.length} other models`,
|
|
479
|
+
inputTokens: restInput,
|
|
480
|
+
cachedInputTokens: restCached,
|
|
481
|
+
rate: restInput > 0 ? (restCached / restInput) * 100 : null,
|
|
482
|
+
muted: true,
|
|
483
|
+
}];
|
|
484
|
+
})();
|
|
485
|
+
|
|
486
|
+
// ---- Pace & runway (computed early: its height shapes the top row) ----
|
|
487
|
+
const generatedAtMs = new Date(snapshot.generatedAt).getTime();
|
|
488
|
+
const paceLines = [];
|
|
489
|
+
let paceNote = null;
|
|
490
|
+
let paceRunwayBar = null;
|
|
491
|
+
const dailyAverage = totalTokens / Math.max(1, days);
|
|
492
|
+
if (hasLine && meterUsable && latestQuotaPoint && totalTokens > 0) {
|
|
493
|
+
const tokensPerPercent = totalTokens / burn.totalPercent;
|
|
494
|
+
const burnPerDay = dailyAverage / tokensPerPercent;
|
|
495
|
+
const runwayDays = burnPerDay > 0
|
|
496
|
+
? latestQuotaPoint.remainingPercent / burnPerDay
|
|
497
|
+
: null;
|
|
498
|
+
if (runwayDays !== null) {
|
|
499
|
+
paceLines.push({
|
|
500
|
+
value: `${runwayDays.toFixed(1)} days`,
|
|
501
|
+
size: 23,
|
|
502
|
+
weight: 800,
|
|
503
|
+
color: COLORS.line,
|
|
504
|
+
detail: "of meter left at this pace",
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
paceLines.push({
|
|
508
|
+
value: `${compact(dailyAverage)} / day`,
|
|
509
|
+
size: 17,
|
|
510
|
+
weight: 700,
|
|
511
|
+
color: COLORS.ink,
|
|
512
|
+
detail: `${days}-day average · ${burnPerDay.toFixed(1)}% of meter`,
|
|
513
|
+
});
|
|
514
|
+
paceLines.push({
|
|
515
|
+
value: `${compact(tokensPerPercent)} / 1%`,
|
|
516
|
+
size: 17,
|
|
517
|
+
weight: 700,
|
|
518
|
+
color: COLORS.ink,
|
|
519
|
+
detail: "tokens per meter point",
|
|
520
|
+
});
|
|
521
|
+
const daysToReset = latestResetsAtSec !== null && Number.isFinite(generatedAtMs)
|
|
522
|
+
? (latestResetsAtSec * 1_000 - generatedAtMs) / 86_400_000
|
|
523
|
+
: null;
|
|
524
|
+
if (runwayDays !== null && daysToReset !== null && daysToReset > 0) {
|
|
525
|
+
const resetIn = Math.max(1, Math.round(daysToReset));
|
|
526
|
+
const resetInLabel = `${resetIn} ${resetIn === 1 ? "day" : "days"}`;
|
|
527
|
+
paceRunwayBar = { runwayDays, daysToReset, resetInLabel };
|
|
528
|
+
const gap = runwayDays - daysToReset;
|
|
529
|
+
if (Math.abs(gap) <= 1.5) {
|
|
530
|
+
paceNote = `Next weekly reset in ${resetInLabel}. Current pace lands within ~${Math.max(1, Math.round(Math.abs(gap)))} day of it.`;
|
|
531
|
+
} else if (gap > 0) {
|
|
532
|
+
paceNote = `Next weekly reset in ${resetInLabel}. Current pace leaves ~${Math.round(gap)} days of headroom past it.`;
|
|
533
|
+
} else {
|
|
534
|
+
paceNote = `Next weekly reset in ${resetInLabel}. Current pace runs the meter out ~${Math.round(-gap)} days before it.`;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
} else {
|
|
538
|
+
paceLines.push({
|
|
539
|
+
value: `${compact(dailyAverage)} / day`,
|
|
540
|
+
size: 17,
|
|
541
|
+
weight: 700,
|
|
542
|
+
color: COLORS.ink,
|
|
543
|
+
detail: `${days}-day average`,
|
|
544
|
+
});
|
|
545
|
+
paceNote = "No usable weekly meter drain in this range, so runway cannot be estimated.";
|
|
546
|
+
}
|
|
440
547
|
|
|
441
548
|
// ---- Layout ----
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const
|
|
445
|
-
const
|
|
446
|
-
const
|
|
447
|
-
const
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const
|
|
549
|
+
// One uniform-height top band: a 2x2 quad of stat cells beside one unified
|
|
550
|
+
// weekly meter + pace panel.
|
|
551
|
+
const headerBaseline = 53;
|
|
552
|
+
const cardTop = 82;
|
|
553
|
+
const topGap = 24;
|
|
554
|
+
const hasMeterCard = Boolean(hasLine && latestQuotaPoint);
|
|
555
|
+
const statCardCount =
|
|
556
|
+
modelCards.length + (hasFast ? 1 : 0) + (percentMode ? 1 : 0);
|
|
557
|
+
const pacePanelWidth = hasMeterCard
|
|
558
|
+
? Math.min(560, Math.max(480, contentWidth * 0.55))
|
|
559
|
+
: 432;
|
|
560
|
+
const pacePanelX = contentRight - pacePanelWidth;
|
|
561
|
+
const paceTextX = pacePanelX + 18;
|
|
562
|
+
const paceInnerWidth = pacePanelWidth - 36;
|
|
563
|
+
const quadWidth = contentWidth - pacePanelWidth - topGap;
|
|
564
|
+
const quadColumns = statCardCount >= 2 ? 2 : 1;
|
|
565
|
+
const quadRows = Math.max(1, Math.ceil(statCardCount / quadColumns));
|
|
566
|
+
const paceNoteLines = [];
|
|
567
|
+
if (paceNote) {
|
|
568
|
+
let current = "";
|
|
569
|
+
for (const word of paceNote.split(" ")) {
|
|
570
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
571
|
+
if (textWidth(candidate, 11.5) > paceInnerWidth && current) {
|
|
572
|
+
paceNoteLines.push(current);
|
|
573
|
+
current = word;
|
|
574
|
+
} else {
|
|
575
|
+
current = candidate;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (current) paceNoteLines.push(current);
|
|
579
|
+
}
|
|
580
|
+
// Baseline offsets inside the unified panel: meter headline, optional
|
|
581
|
+
// runway timeline, the two-column stat pair, then the note.
|
|
582
|
+
const paceHeadlineBaseline = hasMeterCard ? 58 : 54;
|
|
583
|
+
const paceStatValueBaseline = paceHeadlineBaseline + (paceRunwayBar ? 62 : 34);
|
|
584
|
+
const paceStatsPresent = paceLines.length > (hasMeterCard ? 0 : 1);
|
|
585
|
+
const paceNoteStart =
|
|
586
|
+
(paceStatsPresent ? paceStatValueBaseline + 16 : paceHeadlineBaseline) + 24;
|
|
587
|
+
const topRowHeight = Math.max(
|
|
588
|
+
hasMeterCard ? 170 : 150,
|
|
589
|
+
paceNoteStart + (paceNoteLines.length - 1) * 16 + 14,
|
|
590
|
+
);
|
|
591
|
+
const chartBlockTop = cardTop + topRowHeight + 16;
|
|
592
|
+
const plotTop = chartBlockTop + 40;
|
|
593
|
+
const plotHeight = 430;
|
|
594
|
+
const plotBottom = plotTop + plotHeight;
|
|
595
|
+
const chartBlockBottom = plotBottom + 70;
|
|
596
|
+
const legendBaseline = chartBlockBottom + 25;
|
|
597
|
+
const cacheRuleY = legendBaseline + 23;
|
|
598
|
+
const cacheHeaderBaseline = cacheRuleY + 27;
|
|
599
|
+
const cachePlotTop = cacheHeaderBaseline + 34;
|
|
600
|
+
const cachePlotHeight = 128;
|
|
601
|
+
const cachePlotBottom = hasCache
|
|
602
|
+
? cachePlotTop + cachePlotHeight
|
|
603
|
+
: cacheHeaderBaseline + 26;
|
|
604
|
+
const bottomRuleY = cachePlotBottom + 30;
|
|
605
|
+
const bottomTop = bottomRuleY + 20;
|
|
606
|
+
const projectRowCount = Math.min(4, rows.length > 3 ? 4 : rows.length);
|
|
607
|
+
const bottomBlockHeight = Math.max(
|
|
608
|
+
29 + projectRowCount * 29,
|
|
609
|
+
29 + Math.max(1, cacheModelRows.length) * 30,
|
|
610
|
+
120,
|
|
611
|
+
);
|
|
612
|
+
const height = bottomTop + bottomBlockHeight + 34;
|
|
613
|
+
|
|
455
614
|
const yearLabel = bounds.endDateString.slice(0, 4);
|
|
456
|
-
const
|
|
615
|
+
const title = percentMode
|
|
616
|
+
? `TOKEN LEDGER · ${days}-DAY METER DRAIN`
|
|
617
|
+
: `TOKEN LEDGER · ${days}-DAY TREND`;
|
|
618
|
+
const subtitle = `${localDateLabel(bounds.startDateString, bounds.timeZone)} – ${localDateLabel(bounds.endDateString, bounds.timeZone)}, ${yearLabel} · ${bounds.timeZone}`;
|
|
457
619
|
const description = percentMode
|
|
458
|
-
? "Dark
|
|
459
|
-
: "Dark
|
|
620
|
+
? "Dark report card: compact actual-token stat cards beside pace and runway, stacked columns of observed weekly-meter drain with an explicitly estimated per-model split, the observed weekly meter remaining as an amber line with continuous reset boundaries, a compressed cache-rate-by-period strip, and top projects beside per-model cache rates."
|
|
621
|
+
: "Dark report card: compact model stat cards with week-over-week delta chips beside pace and runway, stacked columns of local token volume by model with fast-mode usage in a darker shade, the observed weekly meter remaining as a smoothed amber line with reset breaks and callout pills, a compressed cache-rate-by-period strip, and top projects beside per-model cache rates.";
|
|
460
622
|
|
|
461
623
|
const elements = [
|
|
462
|
-
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="trend-title trend-description">`,
|
|
463
|
-
`<title id="trend-title">${escapeXml(`Token Ledger · ${days}-day trend`)}</title>`,
|
|
624
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="trend-title trend-description" data-report-mode="${percentMode ? "meter-drain" : "actual-tokens"}">`,
|
|
625
|
+
`<title id="trend-title">${escapeXml(percentMode ? `Token Ledger · ${days}-day meter drain` : `Token Ledger · ${days}-day trend`)}</title>`,
|
|
464
626
|
`<desc id="trend-description">${escapeXml(description)}</desc>`,
|
|
627
|
+
`<defs><clipPath id="trend-plot-clip"><rect x="${plotLeft}" y="${plotTop}" width="${plotWidth}" height="${plotHeight}"/></clipPath></defs>`,
|
|
465
628
|
`<rect width="100%" height="100%" fill="${COLORS.background}"/>`,
|
|
466
|
-
svgText({
|
|
467
|
-
|
|
629
|
+
svgText({
|
|
630
|
+
x: outer,
|
|
631
|
+
y: headerBaseline,
|
|
632
|
+
value: title,
|
|
633
|
+
size: 27,
|
|
634
|
+
weight: 800,
|
|
635
|
+
spacing: "-0.27",
|
|
636
|
+
}),
|
|
637
|
+
svgText({
|
|
638
|
+
x: contentRight,
|
|
639
|
+
y: headerBaseline,
|
|
640
|
+
value: subtitle,
|
|
641
|
+
fill: COLORS.muted,
|
|
642
|
+
size: 14,
|
|
643
|
+
anchor: "end",
|
|
644
|
+
}),
|
|
468
645
|
];
|
|
469
646
|
|
|
470
|
-
// ----
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
body(x + 16, cardTop);
|
|
474
|
-
};
|
|
475
|
-
const deltaLine = (model, tokens) => {
|
|
476
|
-
const priorValue = priorTotals.get(model) ?? 0;
|
|
477
|
-
if (priorValue < 1_000_000) return `no prior ${days}d baseline`;
|
|
478
|
-
const ratio = tokens / priorValue;
|
|
479
|
-
if (ratio >= 5) return `${ratio.toFixed(1)}× vs prior ${days}d`;
|
|
480
|
-
const delta = (ratio - 1) * 100;
|
|
481
|
-
const signed = `${delta >= 0 ? "+" : "−"}${Math.abs(delta).toFixed(1)}%`;
|
|
482
|
-
return `${signed} vs prior ${days}d`;
|
|
483
|
-
};
|
|
484
|
-
const cardGap = 12;
|
|
485
|
-
const cardCount = modelCards.length + (hasFast ? 1 : 0) + (hasLine ? 1 : 0);
|
|
486
|
-
const keyCardScale = 1.45;
|
|
487
|
-
const unitWidth = (width - outer * 2 - cardGap * cardCount) / (cardCount + keyCardScale);
|
|
488
|
-
let cardX = outer;
|
|
647
|
+
// ---- KPI cards ----
|
|
648
|
+
const cards = [];
|
|
649
|
+
let meterCard = null;
|
|
489
650
|
for (const { model, tokens } of modelCards) {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
|
|
651
|
+
const share = totalTokens > 0 ? (tokens / totalTokens) * 100 : 0;
|
|
652
|
+
const priorValue = priorTotals.get(model) ?? 0;
|
|
653
|
+
let chip = null;
|
|
654
|
+
if (priorValue >= 1_000_000) {
|
|
655
|
+
const ratio = tokens / priorValue;
|
|
656
|
+
const delta = (ratio - 1) * 100;
|
|
657
|
+
chip = {
|
|
658
|
+
text: ratio >= 5
|
|
659
|
+
? `${ratio.toFixed(1)}×`
|
|
660
|
+
: `${delta >= 0 ? "+" : "−"}${Math.abs(delta).toFixed(1)}%`,
|
|
661
|
+
color: delta >= 0 ? COLORS.deltaUp : COLORS.deltaDown,
|
|
662
|
+
fill: delta >= 0 ? COLORS.deltaUpFill : COLORS.deltaDownFill,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
cards.push({
|
|
666
|
+
swatch: styleForModel(model),
|
|
667
|
+
label: percentMode ? `${model} · tokens` : model,
|
|
668
|
+
labelColor: COLORS.muted,
|
|
669
|
+
value: compact(tokens),
|
|
670
|
+
valueColor: COLORS.ink,
|
|
671
|
+
chip,
|
|
672
|
+
suffix: null,
|
|
673
|
+
track: COLORS.track,
|
|
674
|
+
fill: styleForModel(model),
|
|
675
|
+
barPercent: share,
|
|
676
|
+
caption: `${percent(share)} of ${percentMode ? "actual " : ""}tokens`,
|
|
677
|
+
captionShort: percent(share),
|
|
678
|
+
panel: COLORS.panel,
|
|
679
|
+
border: COLORS.panelBorder,
|
|
505
680
|
});
|
|
506
|
-
cardX += unitWidth + cardGap;
|
|
507
681
|
}
|
|
508
682
|
if (hasFast) {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
683
|
+
const fastShare = totalTokens > 0 ? (fastTokens / totalTokens) * 100 : 0;
|
|
684
|
+
cards.push({
|
|
685
|
+
swatch: FAST_MODE_LABEL_COLOR,
|
|
686
|
+
label: "Fast mode",
|
|
687
|
+
labelColor: COLORS.muted,
|
|
688
|
+
value: `${FAST_MODE_MULTIPLIER.toFixed(2)}×`,
|
|
689
|
+
valueColor: COLORS.ink,
|
|
690
|
+
chip: null,
|
|
691
|
+
suffix: "rate",
|
|
692
|
+
track: COLORS.track,
|
|
693
|
+
fill: FAST_MODE_LABEL_COLOR,
|
|
694
|
+
barPercent: fastShare,
|
|
695
|
+
caption: `${percent(fastShare)} of tokens · darker bar shade`,
|
|
696
|
+
captionShort: `${percent(fastShare)} of tokens`,
|
|
697
|
+
panel: COLORS.panel,
|
|
698
|
+
border: COLORS.panelBorder,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
if (percentMode) {
|
|
702
|
+
cards.push({
|
|
703
|
+
swatch: COLORS.line,
|
|
704
|
+
label: "Observed drain",
|
|
705
|
+
labelColor: COLORS.meterAxis,
|
|
706
|
+
value: `${burn.totalPercent.toFixed(1)} pts`,
|
|
707
|
+
valueColor: COLORS.line,
|
|
708
|
+
chip: null,
|
|
709
|
+
suffix: null,
|
|
710
|
+
track: "rgba(246,183,60,.16)",
|
|
711
|
+
fill: COLORS.line,
|
|
712
|
+
barPercent: burn.totalPercent,
|
|
713
|
+
caption: "observed total · model split estimated",
|
|
714
|
+
captionShort: "model split estimated",
|
|
715
|
+
panel: COLORS.panel,
|
|
716
|
+
border: COLORS.panelBorder,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
if (hasLine && latestQuotaPoint) {
|
|
720
|
+
const lastReset = resetsInRange.at(-1);
|
|
721
|
+
const resetCaption = lastReset
|
|
722
|
+
? `reset ${timestampDateLabel(lastReset.timestampMs, bounds.timeZone)}`
|
|
723
|
+
: latestResetsAtSec
|
|
724
|
+
? `resets ${timestampDateLabel(latestResetsAtSec * 1_000, bounds.timeZone)}`
|
|
725
|
+
: "no reset in range";
|
|
726
|
+
meterCard = ({
|
|
727
|
+
swatch: COLORS.line,
|
|
728
|
+
label: "Weekly meter",
|
|
729
|
+
labelColor: COLORS.meterAxis,
|
|
730
|
+
value: meterLabel(latestQuotaPoint.remainingPercent),
|
|
731
|
+
valueColor: COLORS.line,
|
|
732
|
+
chip: null,
|
|
733
|
+
suffix: "remaining",
|
|
734
|
+
track: "rgba(246,183,60,.2)",
|
|
735
|
+
fill: COLORS.line,
|
|
736
|
+
barPercent: latestQuotaPoint.remainingPercent,
|
|
737
|
+
caption: latestQuotaReadMs === null
|
|
738
|
+
? resetCaption
|
|
739
|
+
: `${resetCaption} · read ${timestampDateLabel(latestQuotaReadMs, bounds.timeZone)}`,
|
|
740
|
+
captionShort: resetCaption,
|
|
741
|
+
panel: COLORS.meterPanel,
|
|
742
|
+
border: COLORS.meterPanelBorder,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
if (cards.length) {
|
|
746
|
+
// A 2x2 quad of stat cells inside one panel with hairline dividers.
|
|
747
|
+
const cellWidth = quadWidth / quadColumns;
|
|
748
|
+
const cellHeight = topRowHeight / quadRows;
|
|
749
|
+
elements.push(svgRect(outer, cardTop, quadWidth, topRowHeight, {
|
|
750
|
+
rx: 7,
|
|
751
|
+
fill: COLORS.panel,
|
|
752
|
+
}));
|
|
753
|
+
if (quadColumns > 1) {
|
|
754
|
+
const dividerX = outer + cellWidth;
|
|
755
|
+
elements.push(`<line x1="${dividerX.toFixed(2)}" y1="${cardTop + 1}" x2="${dividerX.toFixed(2)}" y2="${cardTop + topRowHeight - 1}" stroke="${COLORS.panelBorder}" stroke-width="1"/>`);
|
|
756
|
+
}
|
|
757
|
+
if (quadRows > 1) {
|
|
758
|
+
const dividerY = cardTop + cellHeight;
|
|
759
|
+
elements.push(`<line x1="${outer + 1}" y1="${dividerY.toFixed(2)}" x2="${(outer + quadWidth - 1).toFixed(2)}" y2="${dividerY.toFixed(2)}" stroke="${COLORS.panelBorder}" stroke-width="1"/>`);
|
|
760
|
+
}
|
|
761
|
+
cards.forEach((card, index) => {
|
|
762
|
+
const cellX = outer + (index % quadColumns) * cellWidth;
|
|
763
|
+
const cellY = cardTop + Math.floor(index / quadColumns) * cellHeight;
|
|
764
|
+
const contentX = cellX + 17;
|
|
765
|
+
const innerWidth = cellWidth - 34;
|
|
766
|
+
const innerRight = contentX + innerWidth;
|
|
767
|
+
const labelText = card.label.toUpperCase();
|
|
768
|
+
// Each corner carries something: label top-left, delta chip top-right,
|
|
769
|
+
// value bottom-left, share caption bottom-right, bar along the bottom.
|
|
770
|
+
elements.push(`<circle cx="${(contentX + 3.5).toFixed(2)}" cy="${(cellY + 19).toFixed(2)}" r="3.5" fill="${card.swatch}"/>`);
|
|
518
771
|
elements.push(svgText({
|
|
519
|
-
x,
|
|
520
|
-
y:
|
|
521
|
-
value:
|
|
522
|
-
fill:
|
|
523
|
-
size:
|
|
772
|
+
x: contentX + 15,
|
|
773
|
+
y: cellY + 23,
|
|
774
|
+
value: labelText,
|
|
775
|
+
fill: card.labelColor,
|
|
776
|
+
size: 10.5,
|
|
777
|
+
spacing: ".9",
|
|
778
|
+
}));
|
|
779
|
+
const labelBaseline = cellY + 23;
|
|
780
|
+
if (card.chip) {
|
|
781
|
+
const chipTextWidth = textWidth(card.chip.text, 10, 700);
|
|
782
|
+
const chipX = innerRight - chipTextWidth - 10;
|
|
783
|
+
const labelEnd =
|
|
784
|
+
contentX +
|
|
785
|
+
15 +
|
|
786
|
+
textWidth(labelText, 10.5) +
|
|
787
|
+
Math.max(0, labelText.length - 1) * 0.9;
|
|
788
|
+
if (chipX >= labelEnd + 10) {
|
|
789
|
+
elements.push(svgRect(chipX, labelBaseline - 11, chipTextWidth + 10, 15, {
|
|
790
|
+
rx: 3,
|
|
791
|
+
fill: card.chip.fill,
|
|
792
|
+
}));
|
|
793
|
+
elements.push(svgText({
|
|
794
|
+
x: chipX + 5,
|
|
795
|
+
y: labelBaseline,
|
|
796
|
+
value: card.chip.text,
|
|
797
|
+
fill: card.chip.color,
|
|
798
|
+
size: 10,
|
|
799
|
+
weight: 700,
|
|
800
|
+
}));
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
const valueBaseline = cellY + cellHeight / 2 + 9;
|
|
804
|
+
elements.push(svgText({
|
|
805
|
+
x: contentX,
|
|
806
|
+
y: valueBaseline,
|
|
807
|
+
value: card.value,
|
|
808
|
+
fill: card.valueColor,
|
|
809
|
+
size: 21,
|
|
810
|
+
weight: 800,
|
|
811
|
+
spacing: "-0.5",
|
|
812
|
+
}));
|
|
813
|
+
let valueEnd = contentX + textWidth(card.value, 21, 800);
|
|
814
|
+
if (card.suffix) {
|
|
815
|
+
elements.push(svgText({
|
|
816
|
+
x: valueEnd + 10,
|
|
817
|
+
y: valueBaseline,
|
|
818
|
+
value: card.suffix,
|
|
819
|
+
fill: COLORS.secondary,
|
|
820
|
+
size: 10.5,
|
|
821
|
+
}));
|
|
822
|
+
valueEnd += 10 + textWidth(card.suffix, 10.5);
|
|
823
|
+
}
|
|
824
|
+
const captionAvail = innerRight - valueEnd - 16;
|
|
825
|
+
const caption = card.captionShort && textWidth(card.caption, 11) > captionAvail
|
|
826
|
+
? card.captionShort
|
|
827
|
+
: card.caption;
|
|
828
|
+
if (textWidth(caption, 11) <= captionAvail) {
|
|
829
|
+
elements.push(svgText({
|
|
830
|
+
x: innerRight,
|
|
831
|
+
y: valueBaseline,
|
|
832
|
+
value: caption,
|
|
833
|
+
fill: COLORS.secondary,
|
|
834
|
+
size: 11,
|
|
835
|
+
anchor: "end",
|
|
836
|
+
}));
|
|
837
|
+
}
|
|
838
|
+
const barY = cellY + cellHeight - 16;
|
|
839
|
+
elements.push(svgRect(contentX, barY, innerWidth, 3, {
|
|
840
|
+
rx: 1.5,
|
|
841
|
+
fill: card.track,
|
|
524
842
|
}));
|
|
843
|
+
const fillWidth = (Math.min(100, Math.max(0, card.barPercent)) / 100) * innerWidth;
|
|
844
|
+
if (fillWidth > 0) {
|
|
845
|
+
elements.push(svgRect(contentX, barY, fillWidth, 3, {
|
|
846
|
+
rx: 1.5,
|
|
847
|
+
fill: card.fill,
|
|
848
|
+
}));
|
|
849
|
+
}
|
|
525
850
|
});
|
|
526
|
-
|
|
851
|
+
elements.push(svgRect(outer, cardTop, quadWidth, topRowHeight, {
|
|
852
|
+
rx: 7,
|
|
853
|
+
fill: "none",
|
|
854
|
+
stroke: COLORS.panelBorder,
|
|
855
|
+
"stroke-width": 1,
|
|
856
|
+
}));
|
|
527
857
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
858
|
+
|
|
859
|
+
// ---- Unified weekly meter + pace panel (top right) ----
|
|
860
|
+
elements.push(svgRect(pacePanelX, cardTop, pacePanelWidth, topRowHeight, {
|
|
861
|
+
rx: 7,
|
|
862
|
+
fill: meterCard ? meterCard.panel : COLORS.panel,
|
|
863
|
+
stroke: meterCard ? meterCard.border : COLORS.panelBorder,
|
|
864
|
+
"stroke-width": 1,
|
|
865
|
+
}));
|
|
866
|
+
const paceRight = pacePanelX + pacePanelWidth - 18;
|
|
867
|
+
if (meterCard) {
|
|
868
|
+
elements.push(`<circle cx="${(paceTextX + 3.5).toFixed(2)}" cy="${cardTop + 19}" r="3.5" fill="${meterCard.swatch}"/>`);
|
|
869
|
+
}
|
|
870
|
+
elements.push(svgText({
|
|
871
|
+
x: paceTextX + (meterCard ? 15 : 0),
|
|
872
|
+
y: cardTop + 23,
|
|
873
|
+
value: meterCard ? "WEEKLY METER · PACE & RUNWAY" : "PACE & RUNWAY",
|
|
874
|
+
fill: meterCard ? meterCard.labelColor : COLORS.muted,
|
|
875
|
+
size: 10.5,
|
|
876
|
+
spacing: "1.2",
|
|
877
|
+
}));
|
|
878
|
+
if (meterCard) {
|
|
879
|
+
// Provenance rides the label row; the meter reading is the headline with
|
|
880
|
+
// the projected runway right-aligned beside it.
|
|
881
|
+
elements.push(svgText({
|
|
882
|
+
x: paceRight,
|
|
883
|
+
y: cardTop + 23,
|
|
884
|
+
value: meterCard.caption,
|
|
885
|
+
fill: COLORS.muted,
|
|
886
|
+
size: 10.5,
|
|
887
|
+
anchor: "end",
|
|
888
|
+
}));
|
|
889
|
+
elements.push(svgText({
|
|
890
|
+
x: paceTextX,
|
|
891
|
+
y: cardTop + paceHeadlineBaseline,
|
|
892
|
+
value: meterCard.value,
|
|
893
|
+
fill: meterCard.valueColor,
|
|
894
|
+
size: 28,
|
|
895
|
+
weight: 800,
|
|
896
|
+
spacing: "-0.6",
|
|
897
|
+
}));
|
|
898
|
+
elements.push(svgText({
|
|
899
|
+
x: paceTextX + textWidth(meterCard.value, 28, 800) + 10,
|
|
900
|
+
y: cardTop + paceHeadlineBaseline,
|
|
901
|
+
value: meterCard.suffix,
|
|
902
|
+
fill: COLORS.secondary,
|
|
903
|
+
size: 11,
|
|
904
|
+
}));
|
|
905
|
+
if (paceLines.length > 1) {
|
|
906
|
+
const runwayValue = paceLines[0].value;
|
|
907
|
+
const runwayDetail = "left at this pace";
|
|
908
|
+
const detailWidth = textWidth(runwayDetail, 11);
|
|
532
909
|
elements.push(svgText({
|
|
533
|
-
x,
|
|
534
|
-
y:
|
|
535
|
-
value:
|
|
536
|
-
fill:
|
|
537
|
-
size:
|
|
538
|
-
weight:
|
|
910
|
+
x: paceRight - detailWidth - 8,
|
|
911
|
+
y: cardTop + paceHeadlineBaseline,
|
|
912
|
+
value: runwayValue,
|
|
913
|
+
fill: paceLines[0].color,
|
|
914
|
+
size: 18,
|
|
915
|
+
weight: 800,
|
|
916
|
+
anchor: "end",
|
|
539
917
|
}));
|
|
540
|
-
const meterSub = fitLine(
|
|
541
|
-
latestQuotaPoint
|
|
542
|
-
? `remaining · ${localDateLabel(bounds.endDateString, bounds.timeZone)}`
|
|
543
|
-
: "no observations",
|
|
544
|
-
11.5,
|
|
545
|
-
unitWidth - 32,
|
|
546
|
-
);
|
|
547
918
|
elements.push(svgText({
|
|
548
|
-
x,
|
|
549
|
-
y:
|
|
550
|
-
value:
|
|
919
|
+
x: paceRight,
|
|
920
|
+
y: cardTop + paceHeadlineBaseline,
|
|
921
|
+
value: runwayDetail,
|
|
551
922
|
fill: COLORS.muted,
|
|
552
|
-
size:
|
|
923
|
+
size: 11,
|
|
924
|
+
anchor: "end",
|
|
553
925
|
}));
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
const keyCardWidth = unitWidth * keyCardScale;
|
|
558
|
-
card(cardX, keyCardWidth, (x, y) => {
|
|
559
|
-
// Mini stacked-bar glyph.
|
|
560
|
-
elements.push(`<rect x="${x}" y="${y + 24}" width="5" height="10" rx="1" fill="${styleForModel("Luna")}"/>`);
|
|
561
|
-
elements.push(`<rect x="${x}" y="${y + 17}" width="5" height="6" rx="1" fill="${styleForModel("Sol")}"/>`);
|
|
562
|
-
elements.push(`<rect x="${x + 7}" y="${y + 20}" width="5" height="14" rx="1" fill="${styleForModel("Luna")}"/>`);
|
|
563
|
-
const keyLineOne = fitLine(
|
|
564
|
-
percentMode
|
|
565
|
-
? "Bars = observed limit drain"
|
|
566
|
-
: "Bars = actual token volume",
|
|
567
|
-
12,
|
|
568
|
-
keyCardWidth - 54,
|
|
569
|
-
);
|
|
926
|
+
}
|
|
927
|
+
} else {
|
|
928
|
+
const paceHeadline = paceLines[0];
|
|
570
929
|
elements.push(svgText({
|
|
571
|
-
x:
|
|
572
|
-
y:
|
|
573
|
-
value:
|
|
574
|
-
fill:
|
|
575
|
-
size:
|
|
930
|
+
x: paceTextX,
|
|
931
|
+
y: cardTop + paceHeadlineBaseline,
|
|
932
|
+
value: paceHeadline.value,
|
|
933
|
+
fill: paceHeadline.color,
|
|
934
|
+
size: 26,
|
|
935
|
+
weight: 800,
|
|
936
|
+
spacing: "-0.52",
|
|
576
937
|
}));
|
|
577
|
-
elements.push(`<line x1="${x}" y1="${y + 56}" x2="${x + 12}" y2="${y + 56}" stroke="${COLORS.line}" stroke-width="2.5" stroke-linecap="round"/>`);
|
|
578
|
-
elements.push(`<circle cx="${x + 6}" cy="${y + 56}" r="2.5" fill="${COLORS.line}"/>`);
|
|
579
|
-
const keyLineTwo = fitLine(
|
|
580
|
-
"Line = weekly meter remaining (%)",
|
|
581
|
-
12,
|
|
582
|
-
keyCardWidth - 54,
|
|
583
|
-
);
|
|
584
938
|
elements.push(svgText({
|
|
585
|
-
x:
|
|
586
|
-
y:
|
|
587
|
-
value:
|
|
588
|
-
fill: COLORS.
|
|
589
|
-
size:
|
|
939
|
+
x: paceTextX + textWidth(paceHeadline.value, 26, 800) + 10,
|
|
940
|
+
y: cardTop + paceHeadlineBaseline,
|
|
941
|
+
value: paceHeadline.detail,
|
|
942
|
+
fill: COLORS.muted,
|
|
943
|
+
size: 12.5,
|
|
590
944
|
}));
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
945
|
+
}
|
|
946
|
+
if (paceRunwayBar) {
|
|
947
|
+
// Runway timeline: amber fill = days of meter left, tick = the next
|
|
948
|
+
// weekly reset, both on a shared day scale.
|
|
949
|
+
const scaleDays = Math.max(paceRunwayBar.runwayDays, paceRunwayBar.daysToReset) * 1.06;
|
|
950
|
+
const trackY = cardTop + paceHeadlineBaseline + 12;
|
|
951
|
+
elements.push(svgRect(paceTextX, trackY, paceInnerWidth, 5, {
|
|
952
|
+
rx: 2.5,
|
|
953
|
+
fill: "rgba(246,183,60,.14)",
|
|
954
|
+
}));
|
|
955
|
+
const runwayWidth = Math.min(1, paceRunwayBar.runwayDays / scaleDays) * paceInnerWidth;
|
|
956
|
+
if (runwayWidth > 0) {
|
|
957
|
+
elements.push(svgRect(paceTextX, trackY, runwayWidth, 5, {
|
|
958
|
+
rx: 2.5,
|
|
959
|
+
fill: COLORS.line,
|
|
960
|
+
}));
|
|
961
|
+
}
|
|
962
|
+
const tickX = paceTextX +
|
|
963
|
+
Math.min(1, paceRunwayBar.daysToReset / scaleDays) * paceInnerWidth;
|
|
964
|
+
elements.push(`<line x1="${tickX.toFixed(2)}" y1="${trackY - 3}" x2="${tickX.toFixed(2)}" y2="${trackY + 8}" stroke="${COLORS.secondary}" stroke-width="2"/>`);
|
|
965
|
+
elements.push(svgText({
|
|
966
|
+
x: paceTextX,
|
|
967
|
+
y: trackY + 22,
|
|
968
|
+
value: "now",
|
|
969
|
+
fill: COLORS.muted,
|
|
970
|
+
size: 10.5,
|
|
971
|
+
}));
|
|
972
|
+
const resetLabel = `reset in ${paceRunwayBar.resetInLabel}`;
|
|
973
|
+
const resetLabelWidth = textWidth(resetLabel, 10.5);
|
|
974
|
+
const nowLabelWidth = textWidth("now", 10.5);
|
|
975
|
+
const resetLabelX = Math.max(
|
|
976
|
+
paceTextX + nowLabelWidth + 8 + resetLabelWidth / 2,
|
|
977
|
+
Math.min(tickX, paceTextX + paceInnerWidth - resetLabelWidth / 2 - 2),
|
|
595
978
|
);
|
|
596
979
|
elements.push(svgText({
|
|
597
|
-
x:
|
|
598
|
-
y:
|
|
599
|
-
value:
|
|
980
|
+
x: resetLabelX,
|
|
981
|
+
y: trackY + 22,
|
|
982
|
+
value: resetLabel,
|
|
983
|
+
fill: COLORS.muted,
|
|
984
|
+
size: 10.5,
|
|
985
|
+
anchor: "middle",
|
|
986
|
+
}));
|
|
987
|
+
}
|
|
988
|
+
const paceStatLines = meterCard
|
|
989
|
+
? (paceLines.length > 1 ? paceLines.slice(1) : paceLines)
|
|
990
|
+
: paceLines.slice(1);
|
|
991
|
+
paceStatLines.forEach((line, index) => {
|
|
992
|
+
const columnX = paceTextX + index * (paceInnerWidth / 2 + 8);
|
|
993
|
+
elements.push(svgText({
|
|
994
|
+
x: columnX,
|
|
995
|
+
y: cardTop + paceStatValueBaseline,
|
|
996
|
+
value: line.value,
|
|
997
|
+
fill: line.color,
|
|
998
|
+
size: 17,
|
|
999
|
+
weight: 700,
|
|
1000
|
+
}));
|
|
1001
|
+
elements.push(svgText({
|
|
1002
|
+
x: columnX,
|
|
1003
|
+
y: cardTop + paceStatValueBaseline + 16,
|
|
1004
|
+
value: line.detail,
|
|
600
1005
|
fill: COLORS.muted,
|
|
601
|
-
size:
|
|
1006
|
+
size: 11,
|
|
602
1007
|
}));
|
|
603
1008
|
});
|
|
1009
|
+
let paceNoteBaseline = cardTop + paceNoteStart;
|
|
1010
|
+
for (const line of paceNoteLines) {
|
|
1011
|
+
elements.push(svgText({
|
|
1012
|
+
x: paceTextX,
|
|
1013
|
+
y: paceNoteBaseline,
|
|
1014
|
+
value: line,
|
|
1015
|
+
fill: COLORS.muted,
|
|
1016
|
+
size: 11.5,
|
|
1017
|
+
}));
|
|
1018
|
+
paceNoteBaseline += 16;
|
|
1019
|
+
}
|
|
604
1020
|
|
|
605
1021
|
// ---- Chart grid + axes ----
|
|
606
|
-
for (const fraction of [
|
|
607
|
-
const y =
|
|
608
|
-
elements.push(`<line x1="${plotLeft}" y1="${y.toFixed(2)}" x2="${
|
|
1022
|
+
for (const fraction of [1, 0.75, 0.5, 0.25, 0]) {
|
|
1023
|
+
const y = plotBottom - fraction * plotHeight;
|
|
1024
|
+
elements.push(`<line x1="${plotLeft}" y1="${y.toFixed(2)}" x2="${plotRight}" y2="${y.toFixed(2)}" stroke="${fraction === 0 ? COLORS.baseline : COLORS.grid}" stroke-width="1"/>`);
|
|
609
1025
|
elements.push(svgText({
|
|
610
|
-
x: plotLeft -
|
|
1026
|
+
x: plotLeft - 14,
|
|
611
1027
|
y: y + 4,
|
|
612
1028
|
value: percentMode
|
|
613
1029
|
? `${Number((maxBar * fraction).toFixed(1))}%`
|
|
614
|
-
:
|
|
615
|
-
|
|
616
|
-
|
|
1030
|
+
: fraction === 0
|
|
1031
|
+
? "0"
|
|
1032
|
+
: compact(maxBar * fraction),
|
|
1033
|
+
fill: COLORS.muted,
|
|
1034
|
+
size: 13,
|
|
617
1035
|
anchor: "end",
|
|
1036
|
+
mono: true,
|
|
618
1037
|
}));
|
|
619
1038
|
if (hasLine) {
|
|
620
1039
|
elements.push(svgText({
|
|
621
|
-
x:
|
|
1040
|
+
x: plotRight + 14,
|
|
622
1041
|
y: y + 4,
|
|
623
1042
|
value: `${Math.round(fraction * 100)}%`,
|
|
624
|
-
fill: COLORS.
|
|
625
|
-
size:
|
|
626
|
-
|
|
1043
|
+
fill: COLORS.meterAxis,
|
|
1044
|
+
size: 13,
|
|
1045
|
+
mono: true,
|
|
627
1046
|
}));
|
|
628
1047
|
}
|
|
629
1048
|
}
|
|
630
1049
|
elements.push(svgText({
|
|
631
|
-
x:
|
|
632
|
-
y:
|
|
633
|
-
value: percentMode
|
|
1050
|
+
x: plotLeft,
|
|
1051
|
+
y: chartBlockTop + 18,
|
|
1052
|
+
value: percentMode
|
|
1053
|
+
? "METER DRAIN · OBSERVED TOTAL, ESTIMATED MODEL SPLIT"
|
|
1054
|
+
: "TOKEN VOLUME · ACTUAL",
|
|
634
1055
|
fill: COLORS.leftAxis,
|
|
635
|
-
size:
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
spacing: "0.1em",
|
|
639
|
-
}).replace("<text ", `<text transform="rotate(-90 26 ${chartTop + chartHeight / 2})" `));
|
|
1056
|
+
size: 11.5,
|
|
1057
|
+
spacing: "1.25",
|
|
1058
|
+
}));
|
|
640
1059
|
if (hasLine) {
|
|
641
1060
|
elements.push(svgText({
|
|
642
|
-
x:
|
|
643
|
-
y:
|
|
644
|
-
value: "WEEKLY METER REMAINING
|
|
645
|
-
fill: COLORS.
|
|
646
|
-
size:
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
}).replace("<text ", `<text transform="rotate(90 ${width - 24} ${chartTop + chartHeight / 2})" `));
|
|
1061
|
+
x: plotRight,
|
|
1062
|
+
y: chartBlockTop + 18,
|
|
1063
|
+
value: "WEEKLY METER · REMAINING",
|
|
1064
|
+
fill: COLORS.meterAxis,
|
|
1065
|
+
size: 11.5,
|
|
1066
|
+
anchor: "end",
|
|
1067
|
+
spacing: "1.25",
|
|
1068
|
+
}));
|
|
651
1069
|
}
|
|
652
1070
|
|
|
653
1071
|
// ---- Bars ----
|
|
654
1072
|
const slotWidth = plotWidth / binCount;
|
|
655
|
-
const barWidth = Math.min(
|
|
656
|
-
const
|
|
1073
|
+
const barWidth = Math.min(74, Math.max(MIN_BAR_WIDTH, slotWidth * 0.6));
|
|
1074
|
+
const barGeometry = bars.map((bin, binIndex) => {
|
|
1075
|
+
const centerX = plotLeft + (binIndex + 0.5) * slotWidth;
|
|
1076
|
+
return {
|
|
1077
|
+
bin,
|
|
1078
|
+
centerX,
|
|
1079
|
+
x: centerX - barWidth / 2,
|
|
1080
|
+
topY: plotBottom - (binTotalOf(bin) / maxBar) * plotHeight,
|
|
1081
|
+
};
|
|
1082
|
+
});
|
|
657
1083
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
const binTotal = binTotalOf(bin);
|
|
1084
|
+
const segmentLabels = [];
|
|
1085
|
+
for (const { bin, centerX, x } of barGeometry) {
|
|
661
1086
|
const entries = sortedModelEntries(bin.values);
|
|
662
|
-
let
|
|
663
|
-
for (const [
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
const segmentHeight = Math.max(0, fullHeight - gap);
|
|
668
|
-
const y = chartBottom - cumulative - fullHeight;
|
|
1087
|
+
let y = plotBottom;
|
|
1088
|
+
for (const [model, value] of entries) {
|
|
1089
|
+
const segmentHeight = (value / maxBar) * plotHeight;
|
|
1090
|
+
y -= segmentHeight;
|
|
1091
|
+
if (segmentHeight <= 0.4) continue;
|
|
669
1092
|
const baseColor = styleForModel(model);
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
1093
|
+
const fastValue = percentMode ? 0 : (bin.fastValues?.get(model) ?? 0);
|
|
1094
|
+
const fastHeight = fastValue > 0 && value > 0
|
|
1095
|
+
? segmentHeight * Math.min(1, fastValue / value)
|
|
1096
|
+
: 0;
|
|
1097
|
+
elements.push(svgRect(x, y, barWidth, segmentHeight - fastHeight, {
|
|
1098
|
+
fill: baseColor,
|
|
1099
|
+
"data-series": "usage-bars",
|
|
1100
|
+
"data-model": model,
|
|
1101
|
+
"data-value": value,
|
|
1102
|
+
"data-unit": percentMode ? "meter-points" : "tokens",
|
|
1103
|
+
}));
|
|
1104
|
+
if (fastHeight > 0.5) {
|
|
1105
|
+
elements.push(svgRect(x, y + segmentHeight - fastHeight, barWidth, fastHeight, {
|
|
1106
|
+
fill: fastShade(baseColor),
|
|
1107
|
+
"data-series": "usage-bars",
|
|
1108
|
+
"data-model": model,
|
|
1109
|
+
"data-value": fastValue,
|
|
1110
|
+
"data-unit": "tokens",
|
|
1111
|
+
"data-tier": "fast",
|
|
1112
|
+
}));
|
|
1113
|
+
}
|
|
1114
|
+
const valueLabel = percentMode ? percent(value) : compact(value);
|
|
1115
|
+
const fits = (text, size) => textWidth(text, size, 700) <= barWidth - 6;
|
|
1116
|
+
if (segmentHeight >= 32 && fits(model, 13) && fits(valueLabel, 15)) {
|
|
1117
|
+
const segmentCenter = y + segmentHeight / 2;
|
|
1118
|
+
segmentLabels.push(svgText({
|
|
1119
|
+
x: centerX,
|
|
1120
|
+
y: segmentCenter - 5,
|
|
1121
|
+
value: model,
|
|
1122
|
+
fill: COLORS.onFill,
|
|
1123
|
+
size: 13,
|
|
1124
|
+
anchor: "middle",
|
|
1125
|
+
}));
|
|
1126
|
+
segmentLabels.push(svgText({
|
|
1127
|
+
x: centerX,
|
|
1128
|
+
y: segmentCenter + 13,
|
|
1129
|
+
value: valueLabel,
|
|
1130
|
+
fill: "#ffffff",
|
|
1131
|
+
size: 15,
|
|
1132
|
+
weight: 700,
|
|
1133
|
+
anchor: "middle",
|
|
1134
|
+
}));
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// ---- Meter line: per-cycle smoothed segments with reset breaks ----
|
|
1140
|
+
const yForRemaining = (value) =>
|
|
1141
|
+
plotTop + (1 - Math.max(0, Math.min(100, value)) / 100) * plotHeight;
|
|
1142
|
+
const xForTimestamp = (timestampMs) => {
|
|
1143
|
+
const span = bounds.end.getTime() - bounds.start.getTime();
|
|
1144
|
+
const ratio = span > 0 ? (timestampMs - bounds.start.getTime()) / span : 0;
|
|
1145
|
+
return plotLeft + Math.max(0, Math.min(1, ratio)) * plotWidth;
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
let resetMarks = [];
|
|
1149
|
+
let binDots = [];
|
|
1150
|
+
let pills = [];
|
|
1151
|
+
const lineSegments = [];
|
|
1152
|
+
if (hasLine) {
|
|
1153
|
+
const cycles = new Map();
|
|
1154
|
+
for (const point of trend.points ?? []) {
|
|
1155
|
+
const cycle = cycles.get(point.cycle) ?? [];
|
|
1156
|
+
cycle.push(point);
|
|
1157
|
+
cycles.set(point.cycle, cycle);
|
|
1158
|
+
}
|
|
1159
|
+
const orderedCycles = [...cycles.entries()].sort(
|
|
1160
|
+
(left, right) => left[1][0].timestampMs - right[1][0].timestampMs,
|
|
1161
|
+
);
|
|
1162
|
+
|
|
1163
|
+
resetMarks = resetsInRange
|
|
1164
|
+
.filter((reset) => reset.kind !== "start")
|
|
1165
|
+
.map((reset) => ({
|
|
1166
|
+
...reset,
|
|
1167
|
+
x: xForTimestamp(Math.max(bounds.start.getTime(), reset.timestampMs)),
|
|
1168
|
+
label: reset.kind === "weekly-expiry"
|
|
1169
|
+
? "RESET · 100%"
|
|
1170
|
+
: "RESTART · 100%",
|
|
1171
|
+
}));
|
|
1172
|
+
const resetByCycle = new Map(resetMarks.map((reset) => [reset.cycle, reset]));
|
|
1173
|
+
const resetLabels = (() => {
|
|
1174
|
+
const maximum = 4;
|
|
1175
|
+
if (resetMarks.length <= maximum) return resetMarks;
|
|
1176
|
+
const selected = new Map();
|
|
1177
|
+
const add = (reset) => {
|
|
1178
|
+
if (reset) selected.set(reset.cycle, reset);
|
|
1179
|
+
};
|
|
1180
|
+
const scheduled = resetMarks.filter(
|
|
1181
|
+
(reset) => reset.kind === "weekly-expiry",
|
|
1182
|
+
);
|
|
1183
|
+
if (scheduled.length >= maximum) {
|
|
1184
|
+
for (let index = 0; index < maximum; index += 1) {
|
|
1185
|
+
add(scheduled[Math.round((index / (maximum - 1)) * (scheduled.length - 1))]);
|
|
675
1186
|
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
}
|
|
1187
|
+
} else {
|
|
1188
|
+
scheduled.forEach(add);
|
|
1189
|
+
add(resetMarks[0]);
|
|
1190
|
+
add(resetMarks.findLast((reset) => reset.kind !== "weekly-expiry"));
|
|
1191
|
+
for (let index = 1; selected.size < maximum && index < resetMarks.length - 1; index += 1) {
|
|
1192
|
+
const candidateIndex = Math.round(
|
|
1193
|
+
(index / (maximum - 1)) * (resetMarks.length - 1),
|
|
1194
|
+
);
|
|
1195
|
+
add(resetMarks[candidateIndex]);
|
|
686
1196
|
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
1197
|
+
}
|
|
1198
|
+
return [...selected.values()]
|
|
1199
|
+
.sort((left, right) => left.x - right.x)
|
|
1200
|
+
.slice(-maximum);
|
|
1201
|
+
})();
|
|
1202
|
+
const labeledResetCycles = new Set(resetLabels.map((reset) => reset.cycle));
|
|
1203
|
+
|
|
1204
|
+
for (const [cycleIndex, [cycleId, cyclePoints]] of orderedCycles.entries()) {
|
|
1205
|
+
const points = cyclePoints.map((point) => ({
|
|
1206
|
+
x: xForTimestamp(point.timestampMs),
|
|
1207
|
+
y: yForRemaining(point.remainingPercent),
|
|
1208
|
+
remainingPercent: point.remainingPercent,
|
|
1209
|
+
timestampMs: point.timestampMs,
|
|
1210
|
+
}));
|
|
1211
|
+
const cycleReset = resetByCycle.get(cycleId);
|
|
1212
|
+
if (
|
|
1213
|
+
cycleReset &&
|
|
1214
|
+
points.length &&
|
|
1215
|
+
cycleReset.timestampMs < points[0].timestampMs
|
|
1216
|
+
) {
|
|
1217
|
+
points.unshift({
|
|
1218
|
+
x: cycleReset.x,
|
|
1219
|
+
y: yForRemaining(100),
|
|
1220
|
+
remainingPercent: 100,
|
|
1221
|
+
timestampMs: cycleReset.timestampMs,
|
|
1222
|
+
syntheticReset: true,
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// Carry the previous reading to the exact reset boundary. The former
|
|
1227
|
+
// renderer stopped at the last observation and restarted at a nudged
|
|
1228
|
+
// marker, leaving a visible gap that looked like a broken series.
|
|
1229
|
+
const nextCycleId = orderedCycles[cycleIndex + 1]?.[0];
|
|
1230
|
+
const nextReset = resetByCycle.get(nextCycleId);
|
|
1231
|
+
const lastPoint = points.at(-1);
|
|
1232
|
+
if (
|
|
1233
|
+
nextReset &&
|
|
1234
|
+
lastPoint &&
|
|
1235
|
+
lastPoint.timestampMs < nextReset.timestampMs
|
|
1236
|
+
) {
|
|
1237
|
+
points.push({
|
|
1238
|
+
...lastPoint,
|
|
1239
|
+
x: nextReset.x,
|
|
1240
|
+
timestampMs: nextReset.timestampMs,
|
|
1241
|
+
carriedToReset: true,
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// Thin to at most one point per 2px so the path stays light while the
|
|
1246
|
+
// spline still follows every meaningful movement.
|
|
1247
|
+
const thinned = [];
|
|
1248
|
+
for (const point of points) {
|
|
1249
|
+
const { x, y } = point;
|
|
1250
|
+
const previous = thinned.at(-1);
|
|
1251
|
+
if (previous && x - previous.x < 2) {
|
|
1252
|
+
if (previous.syntheticReset && Math.abs(previous.y - y) > 0.5) {
|
|
1253
|
+
thinned.push({ ...point, x: Math.max(x, previous.x + 0.75) });
|
|
1254
|
+
} else {
|
|
1255
|
+
Object.assign(previous, point);
|
|
716
1256
|
}
|
|
1257
|
+
} else {
|
|
1258
|
+
thinned.push({ ...point });
|
|
717
1259
|
}
|
|
718
1260
|
}
|
|
719
|
-
|
|
1261
|
+
const path = monotonePath(thinned);
|
|
1262
|
+
if (path) {
|
|
1263
|
+
elements.push(`<path d="${path}" fill="none" stroke="${COLORS.background}" stroke-width="5.5" stroke-linecap="round" stroke-linejoin="round" opacity=".88" clip-path="url(#trend-plot-clip)"/>`);
|
|
1264
|
+
elements.push(`<path d="${path}" fill="none" stroke="${COLORS.line}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" clip-path="url(#trend-plot-clip)" data-series="weekly-meter" data-cycle="${escapeXml(cycleId)}"/>`);
|
|
1265
|
+
lineSegments.push(thinned);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// One dot per labeled column: the last observation inside that column.
|
|
1270
|
+
const observed = (trend.points ?? []).filter((point) => point.observed);
|
|
1271
|
+
const step = labelEvery(binCount);
|
|
1272
|
+
const resetBinIndexes = new Set(resetMarks.map((reset) =>
|
|
1273
|
+
Math.max(0, Math.min(binCount - 1, Math.floor((reset.x - plotLeft) / slotWidth)))));
|
|
1274
|
+
for (let binIndex = 0; binIndex < binCount; binIndex += 1) {
|
|
1275
|
+
if (binIndex % step !== 0 && binIndex !== binCount - 1) continue;
|
|
1276
|
+
if (resetBinIndexes.has(binIndex)) continue;
|
|
1277
|
+
const binEndMs = zonedMidnight(
|
|
1278
|
+
bars[binIndex].endDateString,
|
|
1279
|
+
bounds.timeZone,
|
|
1280
|
+
).getTime();
|
|
1281
|
+
const binStartMs = zonedMidnight(
|
|
1282
|
+
bars[binIndex].startDateString,
|
|
1283
|
+
bounds.timeZone,
|
|
1284
|
+
).getTime();
|
|
1285
|
+
const point = observed.findLast(
|
|
1286
|
+
(candidate) =>
|
|
1287
|
+
candidate.timestampMs >= binStartMs && candidate.timestampMs < binEndMs,
|
|
1288
|
+
);
|
|
1289
|
+
if (!point) continue;
|
|
1290
|
+
binDots.push({
|
|
1291
|
+
binIndex,
|
|
1292
|
+
x: xForTimestamp(point.timestampMs),
|
|
1293
|
+
y: yForRemaining(point.remainingPercent),
|
|
1294
|
+
remainingPercent: point.remainingPercent,
|
|
1295
|
+
cycle: point.cycle,
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
for (const dot of binDots) {
|
|
1299
|
+
elements.push(`<circle cx="${dot.x.toFixed(2)}" cy="${dot.y.toFixed(2)}" r="3.5" fill="${COLORS.line}" stroke="${COLORS.background}" stroke-width="1.5"/>`);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// Stagger dense reset labels across lanes: a label joins the first lane
|
|
1303
|
+
// whose previous label sits far enough to its left.
|
|
1304
|
+
const laneRight = [];
|
|
1305
|
+
for (const reset of resetMarks) {
|
|
1306
|
+
const resetBinIndex = Math.max(
|
|
1307
|
+
0,
|
|
1308
|
+
Math.min(binCount - 1, Math.floor((reset.x - plotLeft) / slotWidth)),
|
|
1309
|
+
);
|
|
1310
|
+
const resetBar = barGeometry[resetBinIndex];
|
|
1311
|
+
const crossesBar = resetBar &&
|
|
1312
|
+
reset.x >= resetBar.x - 2 &&
|
|
1313
|
+
reset.x <= resetBar.x + barWidth + 2;
|
|
1314
|
+
const resetLineBottom = crossesBar
|
|
1315
|
+
? Math.max(plotTop + 36, resetBar.topY - 8)
|
|
1316
|
+
: plotBottom;
|
|
1317
|
+
elements.push(`<line x1="${reset.x.toFixed(2)}" y1="${plotTop}" x2="${reset.x.toFixed(2)}" y2="${resetLineBottom.toFixed(2)}" stroke="rgba(246,183,60,.48)" stroke-width="1.5" stroke-dasharray="5 6"/>`);
|
|
1318
|
+
if (!labeledResetCycles.has(reset.cycle)) continue;
|
|
1319
|
+
const labelWidth = textWidth(reset.label, 11, 700) + 14;
|
|
1320
|
+
const labelCenterX = Math.max(
|
|
1321
|
+
plotLeft + labelWidth / 2,
|
|
1322
|
+
Math.min(plotRight - labelWidth / 2, reset.x),
|
|
1323
|
+
);
|
|
1324
|
+
const labelLeft = labelCenterX - labelWidth / 2;
|
|
1325
|
+
let lane = laneRight.findIndex((right) => labelLeft - right >= 8);
|
|
1326
|
+
if (lane < 0) {
|
|
1327
|
+
lane = laneRight.length < 3
|
|
1328
|
+
? laneRight.length
|
|
1329
|
+
: laneRight.indexOf(Math.min(...laneRight));
|
|
1330
|
+
}
|
|
1331
|
+
laneRight[lane] = labelCenterX + labelWidth / 2;
|
|
1332
|
+
const labelBaseline = plotTop + 20 + lane * 21;
|
|
1333
|
+
elements.push(svgRect(
|
|
1334
|
+
labelCenterX - labelWidth / 2,
|
|
1335
|
+
labelBaseline - 14,
|
|
1336
|
+
labelWidth,
|
|
1337
|
+
19,
|
|
1338
|
+
{
|
|
1339
|
+
rx: 5,
|
|
1340
|
+
fill: COLORS.background,
|
|
1341
|
+
stroke: "rgba(246,183,60,.42)",
|
|
1342
|
+
"stroke-width": 1,
|
|
1343
|
+
},
|
|
1344
|
+
));
|
|
1345
|
+
elements.push(svgText({
|
|
1346
|
+
x: labelCenterX,
|
|
1347
|
+
y: labelBaseline,
|
|
1348
|
+
value: reset.label,
|
|
1349
|
+
fill: COLORS.line,
|
|
1350
|
+
size: 11,
|
|
1351
|
+
weight: 700,
|
|
1352
|
+
anchor: "middle",
|
|
1353
|
+
mono: true,
|
|
1354
|
+
}));
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// Keep the line readable with no more than two decision-useful labels:
|
|
1358
|
+
// the first reading after the latest reset (or the range start when no
|
|
1359
|
+
// reset exists) and the latest reading.
|
|
1360
|
+
const picked = [];
|
|
1361
|
+
const latestReset = resetMarks.at(-1);
|
|
1362
|
+
if (latestReset) {
|
|
1363
|
+
const afterReset = binDots.find((dot) => dot.x > latestReset.x + 2);
|
|
1364
|
+
if (afterReset) picked.push(afterReset);
|
|
1365
|
+
} else if (binDots.length) {
|
|
1366
|
+
picked.push(binDots[0]);
|
|
1367
|
+
}
|
|
1368
|
+
if (binDots.length > 1) picked.push(binDots.at(-1));
|
|
1369
|
+
const uniquePicks = [...new Map(picked.map((dot) => [dot.binIndex, dot])).values()];
|
|
1370
|
+
pills = uniquePicks.map((dot, pickIndex) => {
|
|
1371
|
+
const label = meterLabel(dot.remainingPercent);
|
|
1372
|
+
const pillWidth = textWidth(label, 12, 700) + 18;
|
|
1373
|
+
const preferLeft = pickIndex === uniquePicks.length - 1 ||
|
|
1374
|
+
dot.x + pillWidth + 16 > plotRight;
|
|
1375
|
+
let x = preferLeft ? dot.x - pillWidth - 13 : dot.x + 13;
|
|
1376
|
+
x = Math.max(plotLeft + 3, Math.min(plotRight - pillWidth - 3, x));
|
|
1377
|
+
let y = dot.y - 32;
|
|
1378
|
+
if (y < plotTop + 7) y = dot.y + 11;
|
|
1379
|
+
y = Math.max(plotTop + 7, Math.min(plotBottom - 31, y));
|
|
1380
|
+
return {
|
|
1381
|
+
x,
|
|
1382
|
+
y,
|
|
1383
|
+
w: pillWidth,
|
|
1384
|
+
h: 24,
|
|
1385
|
+
tx: x + pillWidth / 2,
|
|
1386
|
+
ty: y + 16,
|
|
1387
|
+
label,
|
|
1388
|
+
dotX: dot.x,
|
|
1389
|
+
dotY: dot.y,
|
|
1390
|
+
};
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// ---- Bar totals and day labels (drawn over the line like the labels) ----
|
|
1395
|
+
elements.push(...segmentLabels);
|
|
1396
|
+
// Where the line passes through a horizontal span at band height, from the
|
|
1397
|
+
// thinned polylines; keeps each column total clear of the amber stroke.
|
|
1398
|
+
const lineTopIfCrossing = (x0, x1, bandTop, bandBottom) => {
|
|
1399
|
+
let top = Infinity;
|
|
1400
|
+
for (const segment of lineSegments) {
|
|
1401
|
+
for (let index = 0; index < segment.length - 1; index += 1) {
|
|
1402
|
+
const from = segment[index];
|
|
1403
|
+
const to = segment[index + 1];
|
|
1404
|
+
if (to.x < x0 || from.x > x1) continue;
|
|
1405
|
+
const clip0 = Math.max(x0, from.x);
|
|
1406
|
+
const clip1 = Math.min(x1, to.x);
|
|
1407
|
+
if (clip1 < clip0) continue;
|
|
1408
|
+
const yAt = (x) =>
|
|
1409
|
+
from.y + (to.x === from.x ? 0 : ((x - from.x) / (to.x - from.x)) * (to.y - from.y));
|
|
1410
|
+
const yLow = Math.min(yAt(clip0), yAt(clip1));
|
|
1411
|
+
const yHigh = Math.max(yAt(clip0), yAt(clip1));
|
|
1412
|
+
if (yLow <= bandBottom && yHigh >= bandTop) top = Math.min(top, yLow);
|
|
1413
|
+
}
|
|
720
1414
|
}
|
|
721
|
-
|
|
1415
|
+
return top;
|
|
1416
|
+
};
|
|
1417
|
+
const labelStep = labelEvery(binCount);
|
|
1418
|
+
const isLabeledColumn = (binIndex) =>
|
|
1419
|
+
binIndex % labelStep === 0 || binIndex === binCount - 1;
|
|
1420
|
+
for (const [binIndex, { bin, centerX, topY }] of barGeometry.entries()) {
|
|
1421
|
+
const binTotal = binTotalOf(bin);
|
|
1422
|
+
// Dense windows only caption the columns that carry date labels; a total
|
|
1423
|
+
// on all 30 daily columns would overlap its neighbours.
|
|
1424
|
+
if (binTotal > 0 && isLabeledColumn(binIndex)) {
|
|
1425
|
+
// The total label sits in the band just above the stack; step it above
|
|
1426
|
+
// the line only when the line actually crosses that band.
|
|
1427
|
+
const lineTop = lineTopIfCrossing(
|
|
1428
|
+
centerX - barWidth / 2 - 6,
|
|
1429
|
+
centerX + barWidth / 2 + 6,
|
|
1430
|
+
topY - 32,
|
|
1431
|
+
topY + 8,
|
|
1432
|
+
);
|
|
1433
|
+
const clearedTop = Number.isFinite(lineTop) ? Math.min(topY, lineTop) : topY;
|
|
722
1434
|
elements.push(svgText({
|
|
723
|
-
x:
|
|
724
|
-
y:
|
|
1435
|
+
x: centerX,
|
|
1436
|
+
y: clearedTop - 13,
|
|
725
1437
|
value: percentMode
|
|
726
1438
|
? `${bin.approximate ? "≈" : ""}${percent(binTotal)}`
|
|
727
1439
|
: compact(binTotal),
|
|
728
1440
|
fill: COLORS.ink,
|
|
729
|
-
size:
|
|
730
|
-
weight:
|
|
1441
|
+
size: 16,
|
|
1442
|
+
weight: 700,
|
|
731
1443
|
anchor: "middle",
|
|
732
1444
|
}));
|
|
733
1445
|
}
|
|
734
|
-
|
|
735
|
-
if (binIndex % labelEvery(binCount) === 0 || binIndex === binCount - 1) {
|
|
1446
|
+
if (isLabeledColumn(binIndex)) {
|
|
736
1447
|
const weekday = actual.binSize === 1
|
|
737
1448
|
? localWeekdayLabel(bin.startDateString, bounds.timeZone).toUpperCase()
|
|
738
1449
|
: "";
|
|
739
1450
|
if (weekday) {
|
|
740
1451
|
elements.push(svgText({
|
|
741
|
-
x:
|
|
742
|
-
y:
|
|
1452
|
+
x: centerX,
|
|
1453
|
+
y: plotBottom + 32,
|
|
743
1454
|
value: weekday,
|
|
744
1455
|
fill: COLORS.muted,
|
|
745
|
-
size:
|
|
746
|
-
weight: 600,
|
|
1456
|
+
size: 13,
|
|
747
1457
|
anchor: "middle",
|
|
1458
|
+
spacing: "1.56",
|
|
748
1459
|
}));
|
|
749
1460
|
}
|
|
750
1461
|
elements.push(svgText({
|
|
751
|
-
x:
|
|
752
|
-
y:
|
|
1462
|
+
x: centerX,
|
|
1463
|
+
y: plotBottom + (weekday ? 54 : 40),
|
|
753
1464
|
value: binDateLabel(bin, bounds.timeZone),
|
|
754
1465
|
fill: COLORS.secondary,
|
|
755
|
-
size:
|
|
1466
|
+
size: 15,
|
|
756
1467
|
anchor: "middle",
|
|
757
1468
|
}));
|
|
758
1469
|
}
|
|
759
1470
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
bounds.timeZone,
|
|
781
|
-
).getTime();
|
|
782
|
-
const candidates = observationPoints.filter((point) => point.timestampMs < binEndMs);
|
|
783
|
-
const point = candidates.at(-1);
|
|
784
|
-
if (point) chipPoints.push(point);
|
|
785
|
-
}
|
|
786
|
-
const lastPoint = observationPoints.at(-1);
|
|
787
|
-
if (lastPoint) chipPoints.push(lastPoint);
|
|
788
|
-
const seen = new Set();
|
|
789
|
-
const barGeometry = (xValue) => {
|
|
790
|
-
const binIndex = Math.max(0, Math.min(binCount - 1,
|
|
791
|
-
Math.floor((xValue - plotLeft) / slotWidth)));
|
|
792
|
-
const barLeft = plotLeft + binIndex * slotWidth + (slotWidth - barWidth) / 2;
|
|
793
|
-
const total = binTotalOf(bars[binIndex]);
|
|
794
|
-
const topY = chartBottom - (total / maxBar) * chartHeight;
|
|
795
|
-
return { barLeft, barRight: barLeft + barWidth, topY, total };
|
|
796
|
-
};
|
|
797
|
-
for (const point of chipPoints) {
|
|
798
|
-
const key = `${point.x.toFixed(0)}`;
|
|
799
|
-
if (seen.has(key)) continue;
|
|
800
|
-
seen.add(key);
|
|
801
|
-
elements.push(`<circle cx="${point.x.toFixed(2)}" cy="${point.y.toFixed(2)}" r="4.5" fill="${COLORS.line}" stroke="${COLORS.background}" stroke-width="2"/>`);
|
|
802
|
-
const geometry = barGeometry(point.x);
|
|
803
|
-
const overBar = point.x >= geometry.barLeft - 8 && point.x <= geometry.barRight + 8;
|
|
804
|
-
const insideBar = overBar && point.y > geometry.topY - 6 && geometry.total > 0;
|
|
805
|
-
let chipX = point.x;
|
|
806
|
-
let chipY = point.y - 24;
|
|
807
|
-
let anchor = "middle";
|
|
808
|
-
if (insideBar) {
|
|
809
|
-
// Slide the chip into the slot gap beside the column.
|
|
810
|
-
const rightX = geometry.barRight + 12;
|
|
811
|
-
if (rightX + 64 <= plotLeft + plotWidth) {
|
|
812
|
-
chipX = rightX;
|
|
813
|
-
anchor = "start";
|
|
814
|
-
} else {
|
|
815
|
-
chipX = geometry.barLeft - 12;
|
|
816
|
-
anchor = "end";
|
|
817
|
-
}
|
|
818
|
-
chipY = point.y;
|
|
819
|
-
} else if (overBar && Math.abs(point.y - geometry.topY) < 60 && geometry.total > 0) {
|
|
820
|
-
// Keep clear of the column-total label just above the cap.
|
|
821
|
-
chipY = geometry.topY - 44;
|
|
822
|
-
} else if (point.y < chartTop + 44) {
|
|
823
|
-
chipY = point.y + 26;
|
|
824
|
-
}
|
|
825
|
-
elements.push(chip(chipX, chipY, percent(point.remainingPercent), { anchor }));
|
|
826
|
-
}
|
|
827
|
-
let previousRefillX = -Infinity;
|
|
828
|
-
let refillLane = 0;
|
|
829
|
-
for (const reset of quota.resetPoints) {
|
|
830
|
-
const label = reset.kind === "weekly-expiry" ? "RESET 100%" : "RESTART 100%";
|
|
831
|
-
// Stagger dense refill chips across two lanes so they stay legible.
|
|
832
|
-
refillLane = reset.x - previousRefillX < 112 ? (refillLane + 1) % 2 : 0;
|
|
833
|
-
previousRefillX = reset.x;
|
|
834
|
-
elements.push(chip(reset.x, chartTop - 18 - refillLane * 24, label, { small: true }));
|
|
835
|
-
}
|
|
1471
|
+
for (const pill of pills) {
|
|
1472
|
+
const leaderX = pill.x > pill.dotX ? pill.x : pill.x + pill.w;
|
|
1473
|
+
const leaderY = Math.max(pill.y + 6, Math.min(pill.y + pill.h - 6, pill.dotY));
|
|
1474
|
+
elements.push(`<line x1="${pill.dotX.toFixed(2)}" y1="${pill.dotY.toFixed(2)}" x2="${leaderX.toFixed(2)}" y2="${leaderY.toFixed(2)}" stroke="rgba(246,183,60,.58)" stroke-width="1"/>`);
|
|
1475
|
+
elements.push(svgRect(pill.x, pill.y, pill.w, pill.h, {
|
|
1476
|
+
rx: 5,
|
|
1477
|
+
fill: COLORS.background,
|
|
1478
|
+
stroke: COLORS.line,
|
|
1479
|
+
"stroke-width": 1,
|
|
1480
|
+
}));
|
|
1481
|
+
elements.push(svgText({
|
|
1482
|
+
x: pill.tx,
|
|
1483
|
+
y: pill.ty,
|
|
1484
|
+
value: pill.label,
|
|
1485
|
+
fill: COLORS.line,
|
|
1486
|
+
size: 12,
|
|
1487
|
+
weight: 700,
|
|
1488
|
+
anchor: "middle",
|
|
1489
|
+
mono: true,
|
|
1490
|
+
}));
|
|
836
1491
|
}
|
|
837
1492
|
|
|
838
|
-
// ---- Legend
|
|
1493
|
+
// ---- Legend row ----
|
|
839
1494
|
const legendModels = sortedModelEntries(
|
|
840
1495
|
percentMode ? burn.totals : actual.totals,
|
|
841
1496
|
).map(([model]) => model);
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
elements.push(`<rect x="${legendX}" y="${legendY - 11}" width="13" height="13" rx="3" fill="${part.swatch}"/>`);
|
|
846
|
-
elements.push(svgText({ x: legendX + 20, y: legendY, value: part.label, fill: COLORS.secondary, size: 12.5 }));
|
|
847
|
-
legendX += 20 + part.label.length * 7.4 + 28;
|
|
848
|
-
}
|
|
849
|
-
if (hasLine) {
|
|
850
|
-
elements.push(`<line x1="${legendX}" y1="${legendY - 5}" x2="${legendX + 22}" y2="${legendY - 5}" stroke="${COLORS.line}" stroke-width="2.75" stroke-linecap="round"/>`);
|
|
851
|
-
elements.push(`<circle cx="${legendX + 11}" cy="${legendY - 5}" r="3" fill="${COLORS.line}"/>`);
|
|
1497
|
+
let legendX = outer;
|
|
1498
|
+
const legendItem = (swatchMarkup, swatchWidth, label) => {
|
|
1499
|
+
elements.push(swatchMarkup);
|
|
852
1500
|
elements.push(svgText({
|
|
853
|
-
x: legendX +
|
|
854
|
-
y:
|
|
855
|
-
value:
|
|
1501
|
+
x: legendX + swatchWidth + 9,
|
|
1502
|
+
y: legendBaseline,
|
|
1503
|
+
value: label,
|
|
856
1504
|
fill: COLORS.secondary,
|
|
857
|
-
size:
|
|
1505
|
+
size: 13.5,
|
|
858
1506
|
}));
|
|
1507
|
+
legendX += swatchWidth + 9 + textWidth(label, 13.5) + 24;
|
|
1508
|
+
};
|
|
1509
|
+
for (const model of legendModels) {
|
|
1510
|
+
legendItem(
|
|
1511
|
+
svgRect(legendX, legendBaseline - 10, 13, 11, { fill: styleForModel(model) }),
|
|
1512
|
+
13,
|
|
1513
|
+
model,
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
if (hasFast && legendModels.length) {
|
|
1517
|
+
legendItem(
|
|
1518
|
+
svgRect(legendX, legendBaseline - 10, 13, 11, {
|
|
1519
|
+
fill: fastShade(styleForModel(legendModels[0])),
|
|
1520
|
+
}),
|
|
1521
|
+
13,
|
|
1522
|
+
"Darker shade = fast mode",
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
if (hasLine) {
|
|
1526
|
+
legendItem(
|
|
1527
|
+
svgRect(legendX, legendBaseline - 6, 20, 3, { fill: COLORS.line }),
|
|
1528
|
+
20,
|
|
1529
|
+
percentMode
|
|
1530
|
+
? "Amber = observed meter remaining · columns = estimated model split"
|
|
1531
|
+
: "Weekly meter remaining · observed",
|
|
1532
|
+
);
|
|
859
1533
|
}
|
|
860
1534
|
|
|
861
|
-
// ----
|
|
862
|
-
elements.push(`<
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
: "
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
: "
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
const drawIcon = (kind, x, y) => {
|
|
902
|
-
const stroke = COLORS.secondary;
|
|
903
|
-
if (kind === "bars") {
|
|
904
|
-
elements.push(`<rect x="${x}" y="${y + 8}" width="4" height="10" rx="1" fill="${stroke}"/>`);
|
|
905
|
-
elements.push(`<rect x="${x + 6}" y="${y + 3}" width="4" height="15" rx="1" fill="${stroke}"/>`);
|
|
906
|
-
elements.push(`<rect x="${x + 12}" y="${y + 11}" width="4" height="7" rx="1" fill="${stroke}"/>`);
|
|
907
|
-
} else if (kind === "card") {
|
|
908
|
-
elements.push(`<rect x="${x}" y="${y + 3}" width="17" height="14" rx="2" fill="none" stroke="${stroke}" stroke-width="1.5"/>`);
|
|
909
|
-
elements.push(`<line x1="${x}" y1="${y + 8}" x2="${x + 17}" y2="${y + 8}" stroke="${stroke}" stroke-width="1.5"/>`);
|
|
910
|
-
} else if (kind === "clock") {
|
|
911
|
-
elements.push(`<circle cx="${x + 8}" cy="${y + 10}" r="7.5" fill="none" stroke="${stroke}" stroke-width="1.5"/>`);
|
|
912
|
-
elements.push(`<path d="M${x + 8},${y + 6} L${x + 8},${y + 10} L${x + 11},${y + 12}" fill="none" stroke="${stroke}" stroke-width="1.5" stroke-linecap="round"/>`);
|
|
913
|
-
} else {
|
|
914
|
-
elements.push(`<rect x="${x}" y="${y + 4}" width="16" height="13" rx="2" fill="none" stroke="${stroke}" stroke-width="1.5"/>`);
|
|
915
|
-
elements.push(`<line x1="${x + 4}" y1="${y + 2}" x2="${x + 4}" y2="${y + 6}" stroke="${stroke}" stroke-width="1.5"/>`);
|
|
916
|
-
elements.push(`<line x1="${x + 12}" y1="${y + 2}" x2="${x + 12}" y2="${y + 6}" stroke="${stroke}" stroke-width="1.5"/>`);
|
|
1535
|
+
// ---- Cache rate by period (compressed strip) ----
|
|
1536
|
+
elements.push(`<line x1="${outer}" y1="${cacheRuleY}" x2="${contentRight}" y2="${cacheRuleY}" stroke="${COLORS.rule}" stroke-width="1"/>`);
|
|
1537
|
+
elements.push(svgText({
|
|
1538
|
+
x: outer,
|
|
1539
|
+
y: cacheHeaderBaseline,
|
|
1540
|
+
value: "CACHE RATE BY PERIOD",
|
|
1541
|
+
fill: COLORS.muted,
|
|
1542
|
+
size: 12,
|
|
1543
|
+
spacing: "1.32",
|
|
1544
|
+
}));
|
|
1545
|
+
if (hasCache) {
|
|
1546
|
+
const cacheLegendItems = [
|
|
1547
|
+
{ swatch: COLORS.cached, label: "Cached" },
|
|
1548
|
+
{ swatch: COLORS.uncached, label: "Uncached" },
|
|
1549
|
+
{
|
|
1550
|
+
swatch: null,
|
|
1551
|
+
label: `${percent(cacheData.rate)} weighted · ${compact(cacheData.cachedInputTokens)} of ${compact(cacheData.inputTokens)} input cached`,
|
|
1552
|
+
},
|
|
1553
|
+
];
|
|
1554
|
+
let cacheLegendX = contentRight - cacheLegendItems.reduce(
|
|
1555
|
+
(sum, item) =>
|
|
1556
|
+
sum + (item.swatch ? 20 : 0) + textWidth(item.label, 12.5) + 18,
|
|
1557
|
+
-18,
|
|
1558
|
+
);
|
|
1559
|
+
for (const item of cacheLegendItems) {
|
|
1560
|
+
if (item.swatch) {
|
|
1561
|
+
elements.push(svgRect(cacheLegendX, cacheHeaderBaseline - 10, 13, 11, {
|
|
1562
|
+
rx: 2,
|
|
1563
|
+
fill: item.swatch,
|
|
1564
|
+
}));
|
|
1565
|
+
cacheLegendX += 20;
|
|
1566
|
+
}
|
|
1567
|
+
elements.push(svgText({
|
|
1568
|
+
x: cacheLegendX,
|
|
1569
|
+
y: cacheHeaderBaseline,
|
|
1570
|
+
value: item.label,
|
|
1571
|
+
fill: COLORS.muted,
|
|
1572
|
+
size: 12.5,
|
|
1573
|
+
}));
|
|
1574
|
+
cacheLegendX += textWidth(item.label, 12.5) + 18;
|
|
917
1575
|
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1576
|
+
for (const value of [100, 50, 0]) {
|
|
1577
|
+
const y = cachePlotBottom - (value / 100) * cachePlotHeight;
|
|
1578
|
+
elements.push(`<line x1="${plotLeft}" y1="${y.toFixed(2)}" x2="${plotRight}" y2="${y.toFixed(2)}" stroke="${value === 0 ? COLORS.baseline : COLORS.grid}" stroke-width="1"/>`);
|
|
1579
|
+
elements.push(svgText({
|
|
1580
|
+
x: plotLeft - 14,
|
|
1581
|
+
y: y + 4,
|
|
1582
|
+
value: `${value}%`,
|
|
1583
|
+
fill: COLORS.muted,
|
|
1584
|
+
size: 11.5,
|
|
1585
|
+
anchor: "end",
|
|
1586
|
+
mono: true,
|
|
1587
|
+
}));
|
|
923
1588
|
}
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1589
|
+
const cacheSlotWidth = plotWidth / cacheData.binCount;
|
|
1590
|
+
const cacheBarWidth = Math.min(
|
|
1591
|
+
74,
|
|
1592
|
+
Math.max(MIN_BAR_WIDTH, cacheSlotWidth * 0.6),
|
|
1593
|
+
);
|
|
1594
|
+
const showCacheRateLabels = cacheSlotWidth >= 46 && cacheData.binCount <= 20;
|
|
1595
|
+
cacheData.bins.forEach((bin, binIndex) => {
|
|
1596
|
+
const centerX = plotLeft + (binIndex + 0.5) * cacheSlotWidth;
|
|
1597
|
+
const barX = centerX - cacheBarWidth / 2;
|
|
1598
|
+
if (Number.isFinite(bin.rate)) {
|
|
1599
|
+
elements.push(svgRect(barX, cachePlotTop, cacheBarWidth, cachePlotHeight, {
|
|
1600
|
+
rx: 3,
|
|
1601
|
+
fill: COLORS.uncached,
|
|
1602
|
+
opacity: ".88",
|
|
1603
|
+
}));
|
|
1604
|
+
const cachedHeight = cachePlotHeight * (bin.rate / 100);
|
|
1605
|
+
if (cachedHeight > 0) {
|
|
1606
|
+
elements.push(svgRect(
|
|
1607
|
+
barX,
|
|
1608
|
+
cachePlotBottom - cachedHeight,
|
|
1609
|
+
cacheBarWidth,
|
|
1610
|
+
cachedHeight,
|
|
1611
|
+
{ rx: 2, fill: COLORS.cached },
|
|
1612
|
+
));
|
|
1613
|
+
}
|
|
1614
|
+
if (showCacheRateLabels) {
|
|
1615
|
+
elements.push(svgText({
|
|
1616
|
+
x: centerX,
|
|
1617
|
+
y: cachePlotTop - 9,
|
|
1618
|
+
value: percent(bin.rate),
|
|
1619
|
+
fill: COLORS.secondary,
|
|
1620
|
+
size: 11.5,
|
|
1621
|
+
weight: 700,
|
|
1622
|
+
anchor: "middle",
|
|
1623
|
+
mono: true,
|
|
1624
|
+
}));
|
|
1625
|
+
}
|
|
1626
|
+
} else {
|
|
1627
|
+
// No measured input this period: an empty track with a midline dash.
|
|
1628
|
+
elements.push(svgRect(barX, cachePlotTop, cacheBarWidth, cachePlotHeight, {
|
|
1629
|
+
rx: 3,
|
|
1630
|
+
fill: COLORS.cacheTrack,
|
|
1631
|
+
stroke: COLORS.baseline,
|
|
1632
|
+
"stroke-width": 1,
|
|
1633
|
+
}));
|
|
1634
|
+
elements.push(`<line x1="${(barX + 5).toFixed(2)}" y1="${cachePlotTop + cachePlotHeight / 2}" x2="${(barX + cacheBarWidth - 5).toFixed(2)}" y2="${cachePlotTop + cachePlotHeight / 2}" stroke="${COLORS.muted}" stroke-width="1"/>`);
|
|
1635
|
+
}
|
|
1636
|
+
});
|
|
1637
|
+
if (Number.isFinite(cacheData.rate)) {
|
|
1638
|
+
const lineY = cachePlotBottom - (cacheData.rate / 100) * cachePlotHeight;
|
|
1639
|
+
elements.push(`<line x1="${plotLeft}" y1="${lineY.toFixed(2)}" x2="${plotRight}" y2="${lineY.toFixed(2)}" stroke="${COLORS.weighted}" stroke-width="1.6" stroke-dasharray="6 5"/>`);
|
|
928
1640
|
elements.push(svgText({
|
|
929
|
-
x:
|
|
930
|
-
y:
|
|
931
|
-
value:
|
|
932
|
-
fill:
|
|
933
|
-
size:
|
|
934
|
-
weight:
|
|
1641
|
+
x: plotRight + 8,
|
|
1642
|
+
y: lineY + 4,
|
|
1643
|
+
value: percent(cacheData.rate),
|
|
1644
|
+
fill: COLORS.weighted,
|
|
1645
|
+
size: 11.5,
|
|
1646
|
+
weight: 700,
|
|
1647
|
+
mono: true,
|
|
935
1648
|
}));
|
|
936
1649
|
}
|
|
1650
|
+
} else {
|
|
1651
|
+
elements.push(svgText({
|
|
1652
|
+
x: outer,
|
|
1653
|
+
y: cacheHeaderBaseline + 24,
|
|
1654
|
+
value: "No events with a usable input-token breakdown in this range.",
|
|
1655
|
+
fill: COLORS.secondary,
|
|
1656
|
+
size: 13,
|
|
1657
|
+
}));
|
|
937
1658
|
}
|
|
938
1659
|
|
|
1660
|
+
// ---- Top projects + cache rate by model ----
|
|
1661
|
+
elements.push(`<line x1="${outer}" y1="${bottomRuleY}" x2="${contentRight}" y2="${bottomRuleY}" stroke="${COLORS.rule}" stroke-width="1"/>`);
|
|
1662
|
+
const sectionBaseline = bottomTop + 10;
|
|
1663
|
+
const columnGap = 28;
|
|
1664
|
+
const modelColumnWidth = Math.min(
|
|
1665
|
+
368,
|
|
1666
|
+
Math.max(300, contentWidth * 0.32),
|
|
1667
|
+
);
|
|
1668
|
+
const leftColumnWidth =
|
|
1669
|
+
contentWidth - columnGap - 28 - modelColumnWidth;
|
|
1670
|
+
const leftColumnRight = outer + leftColumnWidth;
|
|
1671
|
+
const dividerX = leftColumnRight + columnGap;
|
|
1672
|
+
const modelColumnX = dividerX + 28;
|
|
1673
|
+
|
|
1674
|
+
const projectHeader = "WHERE IT WENT · TOP PROJECTS";
|
|
1675
|
+
elements.push(svgText({
|
|
1676
|
+
x: outer,
|
|
1677
|
+
y: sectionBaseline,
|
|
1678
|
+
value: projectHeader,
|
|
1679
|
+
fill: COLORS.muted,
|
|
1680
|
+
size: 12,
|
|
1681
|
+
spacing: "1.32",
|
|
1682
|
+
}));
|
|
1683
|
+
const topRows = rows.slice(0, 3);
|
|
1684
|
+
const restRows = rows.slice(3);
|
|
1685
|
+
const topTokens = topRows.reduce((sum, row) => sum + row.totalTokens, 0);
|
|
1686
|
+
const topShare = totalTokens > 0
|
|
1687
|
+
? percent((topTokens / totalTokens) * 100)
|
|
1688
|
+
: "—";
|
|
1689
|
+
const fullProjectSummary = `${rows.length} ${rows.length === 1 ? "project" : "projects"} active · top ${topRows.length} = ${topShare} of tokens`;
|
|
1690
|
+
const shortProjectSummary = `top ${topRows.length} = ${topShare}`;
|
|
1691
|
+
const projectHeaderWidth =
|
|
1692
|
+
textWidth(projectHeader, 12) +
|
|
1693
|
+
Math.max(0, projectHeader.length - 1) * 1.32;
|
|
1694
|
+
const projectSummary = textWidth(fullProjectSummary, 12.5) <=
|
|
1695
|
+
leftColumnRight - outer - projectHeaderWidth - 16
|
|
1696
|
+
? fullProjectSummary
|
|
1697
|
+
: shortProjectSummary;
|
|
1698
|
+
elements.push(svgText({
|
|
1699
|
+
x: leftColumnRight,
|
|
1700
|
+
y: sectionBaseline,
|
|
1701
|
+
value: projectSummary,
|
|
1702
|
+
fill: COLORS.muted,
|
|
1703
|
+
size: 12.5,
|
|
1704
|
+
anchor: "end",
|
|
1705
|
+
}));
|
|
1706
|
+
|
|
1707
|
+
const displayRows = topRows.map((row, index) => ({
|
|
1708
|
+
rank: String(index + 1).padStart(2, "0"),
|
|
1709
|
+
name: row.displayProject ?? row.project,
|
|
1710
|
+
tokens: row.totalTokens,
|
|
1711
|
+
fill: COLORS.leftAxis,
|
|
1712
|
+
muted: false,
|
|
1713
|
+
}));
|
|
1714
|
+
if (restRows.length) {
|
|
1715
|
+
displayRows.push({
|
|
1716
|
+
rank: null,
|
|
1717
|
+
name: restRows.length === 1
|
|
1718
|
+
? (restRows[0].displayProject ?? restRows[0].project)
|
|
1719
|
+
: `${restRows.length} other projects`,
|
|
1720
|
+
tokens: restRows.reduce((sum, row) => sum + row.totalTokens, 0),
|
|
1721
|
+
fill: COLORS.remainderBar,
|
|
1722
|
+
muted: true,
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
const rowGap = 12;
|
|
1726
|
+
const rankX = outer;
|
|
1727
|
+
const nameX = rankX + 22 + rowGap;
|
|
1728
|
+
const projectBarX = nameX + 190 + rowGap;
|
|
1729
|
+
const tokensRight = leftColumnRight - 62 - rowGap;
|
|
1730
|
+
const projectBarWidth = tokensRight - (86 + rowGap) - projectBarX;
|
|
1731
|
+
const projectNameWidth = projectBarX - nameX - rowGap;
|
|
1732
|
+
displayRows.forEach((row, index) => {
|
|
1733
|
+
const centerY = bottomTop + 29 + index * 29 + 9;
|
|
1734
|
+
const projectName = truncateText(
|
|
1735
|
+
row.name,
|
|
1736
|
+
projectNameWidth,
|
|
1737
|
+
15,
|
|
1738
|
+
row.muted ? 400 : 700,
|
|
1739
|
+
);
|
|
1740
|
+
if (row.rank) {
|
|
1741
|
+
elements.push(svgText({
|
|
1742
|
+
x: rankX,
|
|
1743
|
+
y: centerY + 5,
|
|
1744
|
+
value: row.rank,
|
|
1745
|
+
fill: COLORS.muted,
|
|
1746
|
+
size: 13,
|
|
1747
|
+
mono: true,
|
|
1748
|
+
}));
|
|
1749
|
+
}
|
|
1750
|
+
elements.push(svgText({
|
|
1751
|
+
x: nameX,
|
|
1752
|
+
y: centerY + 5,
|
|
1753
|
+
value: projectName,
|
|
1754
|
+
fill: row.muted ? COLORS.muted : COLORS.ink,
|
|
1755
|
+
size: 15,
|
|
1756
|
+
weight: row.muted ? 400 : 700,
|
|
1757
|
+
}));
|
|
1758
|
+
elements.push(svgRect(projectBarX, centerY - 5, projectBarWidth, 10, {
|
|
1759
|
+
rx: 2,
|
|
1760
|
+
fill: COLORS.projectTrack,
|
|
1761
|
+
}));
|
|
1762
|
+
const share = totalTokens > 0 ? (row.tokens / totalTokens) * 100 : 0;
|
|
1763
|
+
const fillWidth = (Math.min(100, share) / 100) * projectBarWidth;
|
|
1764
|
+
if (fillWidth > 0) {
|
|
1765
|
+
elements.push(svgRect(projectBarX, centerY - 5, fillWidth, 10, {
|
|
1766
|
+
rx: 2,
|
|
1767
|
+
fill: row.fill,
|
|
1768
|
+
}));
|
|
1769
|
+
}
|
|
1770
|
+
elements.push(svgText({
|
|
1771
|
+
x: tokensRight,
|
|
1772
|
+
y: centerY + 5,
|
|
1773
|
+
value: compact(row.tokens),
|
|
1774
|
+
fill: row.muted ? COLORS.secondary : COLORS.ink,
|
|
1775
|
+
size: 15,
|
|
1776
|
+
weight: 700,
|
|
1777
|
+
anchor: "end",
|
|
1778
|
+
}));
|
|
1779
|
+
elements.push(svgText({
|
|
1780
|
+
x: leftColumnRight,
|
|
1781
|
+
y: centerY + 5,
|
|
1782
|
+
value: percent(share),
|
|
1783
|
+
fill: COLORS.muted,
|
|
1784
|
+
size: 13.5,
|
|
1785
|
+
anchor: "end",
|
|
1786
|
+
}));
|
|
1787
|
+
});
|
|
1788
|
+
|
|
1789
|
+
elements.push(`<line x1="${dividerX}" y1="${bottomTop}" x2="${dividerX}" y2="${bottomTop + bottomBlockHeight}" stroke="${COLORS.rule}" stroke-width="1"/>`);
|
|
1790
|
+
elements.push(svgText({
|
|
1791
|
+
x: modelColumnX,
|
|
1792
|
+
y: sectionBaseline,
|
|
1793
|
+
value: "CACHE RATE BY MODEL",
|
|
1794
|
+
fill: COLORS.muted,
|
|
1795
|
+
size: 12,
|
|
1796
|
+
spacing: "1.32",
|
|
1797
|
+
}));
|
|
1798
|
+
if (cacheModelRows.length === 0) {
|
|
1799
|
+
elements.push(svgText({
|
|
1800
|
+
x: modelColumnX,
|
|
1801
|
+
y: bottomTop + 29 + 14,
|
|
1802
|
+
value: "No measured input to break out.",
|
|
1803
|
+
fill: COLORS.secondary,
|
|
1804
|
+
size: 13,
|
|
1805
|
+
}));
|
|
1806
|
+
}
|
|
1807
|
+
const compactModelColumns = modelColumnWidth < 340;
|
|
1808
|
+
const modelLabelSize = compactModelColumns ? 12.5 : 13.5;
|
|
1809
|
+
const modelRateSize = compactModelColumns ? 11.5 : 12.5;
|
|
1810
|
+
const widestModelLabel = cacheModelRows.reduce(
|
|
1811
|
+
(width, model) => Math.max(
|
|
1812
|
+
width,
|
|
1813
|
+
textWidth(model.model, modelLabelSize, 700),
|
|
1814
|
+
),
|
|
1815
|
+
0,
|
|
1816
|
+
);
|
|
1817
|
+
const minimumRateRight =
|
|
1818
|
+
modelColumnX +
|
|
1819
|
+
18 +
|
|
1820
|
+
widestModelLabel +
|
|
1821
|
+
8 +
|
|
1822
|
+
textWidth("100.0%", modelRateSize, 700);
|
|
1823
|
+
const modelRateRight = Math.max(
|
|
1824
|
+
modelColumnX + modelColumnWidth * 0.42,
|
|
1825
|
+
minimumRateRight,
|
|
1826
|
+
);
|
|
1827
|
+
const modelBarX = modelRateRight + 15;
|
|
1828
|
+
const modelInputReserve = Math.min(
|
|
1829
|
+
58,
|
|
1830
|
+
Math.max(48, modelColumnWidth * 0.16),
|
|
1831
|
+
);
|
|
1832
|
+
const modelBarWidth = contentRight - modelInputReserve - modelBarX;
|
|
1833
|
+
cacheModelRows.forEach((model, index) => {
|
|
1834
|
+
const centerY = bottomTop + 29 + index * 30 + 9;
|
|
1835
|
+
if (!model.muted) {
|
|
1836
|
+
elements.push(`<circle cx="${(modelColumnX + 5).toFixed(2)}" cy="${centerY}" r="4" fill="${styleForModel(model.model)}"/>`);
|
|
1837
|
+
}
|
|
1838
|
+
elements.push(svgText({
|
|
1839
|
+
x: modelColumnX + 18,
|
|
1840
|
+
y: centerY + 4,
|
|
1841
|
+
value: model.model,
|
|
1842
|
+
fill: model.muted ? COLORS.muted : COLORS.ink,
|
|
1843
|
+
size: modelLabelSize,
|
|
1844
|
+
weight: model.muted ? 400 : 700,
|
|
1845
|
+
}));
|
|
1846
|
+
elements.push(svgText({
|
|
1847
|
+
x: modelRateRight,
|
|
1848
|
+
y: centerY + 4,
|
|
1849
|
+
value: percent(model.rate),
|
|
1850
|
+
fill: COLORS.secondary,
|
|
1851
|
+
size: modelRateSize,
|
|
1852
|
+
weight: 700,
|
|
1853
|
+
anchor: "end",
|
|
1854
|
+
mono: true,
|
|
1855
|
+
}));
|
|
1856
|
+
elements.push(svgRect(modelBarX, centerY - 5, modelBarWidth, 10, {
|
|
1857
|
+
rx: 3,
|
|
1858
|
+
fill: COLORS.uncached,
|
|
1859
|
+
opacity: ".7",
|
|
1860
|
+
}));
|
|
1861
|
+
const rateFill = Number.isFinite(model.rate)
|
|
1862
|
+
? modelBarWidth * (model.rate / 100)
|
|
1863
|
+
: 0;
|
|
1864
|
+
if (rateFill > 0) {
|
|
1865
|
+
elements.push(svgRect(modelBarX, centerY - 5, rateFill, 10, {
|
|
1866
|
+
rx: 3,
|
|
1867
|
+
fill: COLORS.cached,
|
|
1868
|
+
}));
|
|
1869
|
+
}
|
|
1870
|
+
elements.push(svgText({
|
|
1871
|
+
x: contentRight,
|
|
1872
|
+
y: centerY + 4,
|
|
1873
|
+
value: compact(model.inputTokens),
|
|
1874
|
+
fill: COLORS.secondary,
|
|
1875
|
+
size: 12.5,
|
|
1876
|
+
weight: 700,
|
|
1877
|
+
anchor: "end",
|
|
1878
|
+
mono: true,
|
|
1879
|
+
}));
|
|
1880
|
+
});
|
|
1881
|
+
|
|
939
1882
|
elements.push("</svg>");
|
|
940
1883
|
return elements.join("\n");
|
|
941
1884
|
}
|