tledger 0.1.4 → 0.2.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 +122 -80
- package/bin/token-ledger-rates.mjs +62 -0
- package/bin/token-ledger-terminal.mjs +133 -257
- package/bin/token-ledger-trend-image.mjs +945 -0
- package/bin/token-ledger-trend-terminal.mjs +609 -0
- package/bin/token-ledger-trend.mjs +745 -0
- package/bin/token-ledger-tui.mjs +15 -21
- package/bin/token-ledger.mjs +408 -248
- package/lib/{token-ledger-collector.mjs → token-ledger-importer.mjs} +256 -409
- package/package.json +18 -14
- package/lib/token-ledger-models.mjs +0 -113
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
|
|
3
|
+
import sharp from "sharp";
|
|
4
|
+
|
|
5
|
+
import { buildBurnDayBins, buildUsageTrend } from "./token-ledger-trend.mjs";
|
|
6
|
+
import { creditsForUsage } from "./token-ledger-rates.mjs";
|
|
7
|
+
import { buildActualTokenBins } from "./token-ledger-trend-terminal.mjs";
|
|
8
|
+
|
|
9
|
+
const MODEL_ORDER = [
|
|
10
|
+
"Luna",
|
|
11
|
+
"Sol",
|
|
12
|
+
"Terra",
|
|
13
|
+
"GPT-5.5",
|
|
14
|
+
"GPT-5.4",
|
|
15
|
+
"Daybreak",
|
|
16
|
+
"Auto review",
|
|
17
|
+
"Other",
|
|
18
|
+
"Unknown",
|
|
19
|
+
"Unattributed",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
// Dark-surface categorical palette; the co-occurring set and the stack-order
|
|
23
|
+
// adjacency both pass CVD, normal-vision, and contrast checks on #0e1420.
|
|
24
|
+
export const TREND_IMAGE_MODEL_COLORS = {
|
|
25
|
+
Luna: "#3b82f6",
|
|
26
|
+
Sol: "#10a394",
|
|
27
|
+
Terra: "#8b7cf6",
|
|
28
|
+
"GPT-5.5": "#d55181",
|
|
29
|
+
"GPT-5.4": "#0891b2",
|
|
30
|
+
Daybreak: "#16a34a",
|
|
31
|
+
"Auto review": "#e5484d",
|
|
32
|
+
Other: "#64748b",
|
|
33
|
+
Unknown: "#64748b",
|
|
34
|
+
Unattributed: "#475569",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const COLORS = {
|
|
38
|
+
background: "#0e1420",
|
|
39
|
+
panel: "#151d2c",
|
|
40
|
+
panelBorder: "#273246",
|
|
41
|
+
ink: "#f2f5fa",
|
|
42
|
+
secondary: "#aeb8c9",
|
|
43
|
+
muted: "#77839a",
|
|
44
|
+
grid: "#1c2534",
|
|
45
|
+
baseline: "#33405a",
|
|
46
|
+
line: "#f6b73c",
|
|
47
|
+
chipFill: "#151d2c",
|
|
48
|
+
leftAxis: "#7ea2f0",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const FONT_FAMILY = "system-ui, -apple-system, 'Segoe UI', sans-serif";
|
|
52
|
+
const FAST_MODE_LABEL_COLOR = "#a78bfa";
|
|
53
|
+
|
|
54
|
+
function escapeXml(value) {
|
|
55
|
+
return String(value)
|
|
56
|
+
.replaceAll("&", "&")
|
|
57
|
+
.replaceAll("<", "<")
|
|
58
|
+
.replaceAll(">", ">")
|
|
59
|
+
.replaceAll('"', """)
|
|
60
|
+
.replaceAll("'", "'");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function compact(value, digits = 2) {
|
|
64
|
+
if (!Number.isFinite(value)) return "—";
|
|
65
|
+
const absolute = Math.abs(value);
|
|
66
|
+
for (const [divisor, suffix] of [
|
|
67
|
+
[1_000_000_000, "B"],
|
|
68
|
+
[1_000_000, "M"],
|
|
69
|
+
[1_000, "K"],
|
|
70
|
+
]) {
|
|
71
|
+
if (absolute >= divisor) {
|
|
72
|
+
const scaled = value / divisor;
|
|
73
|
+
const precision = scaled >= 100 ? 0 : scaled >= 10 ? 1 : digits;
|
|
74
|
+
return `${scaled.toFixed(precision)}${suffix}`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return Math.round(value).toLocaleString("en-US");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function percent(value) {
|
|
81
|
+
return `${Number(value).toFixed(value >= 10 ? 1 : 2)}%`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function niceCeiling(value) {
|
|
85
|
+
if (!(value > 0)) return 1;
|
|
86
|
+
const magnitude = 10 ** Math.floor(Math.log10(value));
|
|
87
|
+
const normalized = value / magnitude;
|
|
88
|
+
const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
|
89
|
+
return step * magnitude;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function modelSort(left, right) {
|
|
93
|
+
const leftIndex = MODEL_ORDER.indexOf(left);
|
|
94
|
+
const rightIndex = MODEL_ORDER.indexOf(right);
|
|
95
|
+
return (
|
|
96
|
+
(leftIndex < 0 ? MODEL_ORDER.length : leftIndex) -
|
|
97
|
+
(rightIndex < 0 ? MODEL_ORDER.length : rightIndex) ||
|
|
98
|
+
left.localeCompare(right)
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function styleForModel(model) {
|
|
103
|
+
return TREND_IMAGE_MODEL_COLORS[model] ?? TREND_IMAGE_MODEL_COLORS.Other;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Darker step of the same hue, used for the fast-mode share of a segment.
|
|
107
|
+
export function fastShade(hexColor) {
|
|
108
|
+
const match = /^#([0-9a-f]{6})$/i.exec(String(hexColor));
|
|
109
|
+
if (!match) return hexColor;
|
|
110
|
+
const channels = [0, 2, 4].map((offset) =>
|
|
111
|
+
Math.round(parseInt(match[1].slice(offset, offset + 2), 16) * 0.62),
|
|
112
|
+
);
|
|
113
|
+
return `#${channels.map((value) => value.toString(16).padStart(2, "0")).join("")}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sortedModelEntries(values) {
|
|
117
|
+
return [...values.entries()]
|
|
118
|
+
.filter(([, value]) => value > 0)
|
|
119
|
+
.sort(([left], [right]) => modelSort(left, right));
|
|
120
|
+
}
|
|
121
|
+
|
|
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
|
+
function dateParts(dateString) {
|
|
167
|
+
return dateString.split("-").map(Number);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function dateStringFromParts(year, month, day) {
|
|
171
|
+
return [year, month, day]
|
|
172
|
+
.map((value, index) =>
|
|
173
|
+
index === 0 ? String(value) : String(value).padStart(2, "0"),
|
|
174
|
+
)
|
|
175
|
+
.join("-");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function shiftCalendarDate(dateString, amount) {
|
|
179
|
+
const [year, month, day] = dateParts(dateString);
|
|
180
|
+
const date = new Date(Date.UTC(year, month - 1, day + amount));
|
|
181
|
+
return dateStringFromParts(
|
|
182
|
+
date.getUTCFullYear(),
|
|
183
|
+
date.getUTCMonth() + 1,
|
|
184
|
+
date.getUTCDate(),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function timeZoneOffsetMs(instant, timeZone) {
|
|
189
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
190
|
+
timeZone,
|
|
191
|
+
timeZoneName: "longOffset",
|
|
192
|
+
}).formatToParts(instant);
|
|
193
|
+
const value = parts.find((part) => part.type === "timeZoneName")?.value ?? "GMT";
|
|
194
|
+
if (value === "GMT") return 0;
|
|
195
|
+
const match = value.match(/^GMT([+-])(\d{2}):?(\d{2})?$/);
|
|
196
|
+
if (!match) return 0;
|
|
197
|
+
const minutes = Number(match[2]) * 60 + Number(match[3] || 0);
|
|
198
|
+
return (match[1] === "+" ? 1 : -1) * minutes * 60 * 1_000;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function zonedMidnight(dateString, timeZone) {
|
|
202
|
+
const [year, month, day] = dateParts(dateString);
|
|
203
|
+
const utcGuess = Date.UTC(year, month - 1, day);
|
|
204
|
+
const first = new Date(utcGuess - timeZoneOffsetMs(new Date(utcGuess), timeZone));
|
|
205
|
+
return new Date(first.getTime() - timeZoneOffsetMs(first, timeZone));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function localDateLabel(dateString, timeZone) {
|
|
209
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
210
|
+
timeZone,
|
|
211
|
+
month: "short",
|
|
212
|
+
day: "numeric",
|
|
213
|
+
}).format(zonedMidnight(dateString, timeZone));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function localWeekdayLabel(dateString, timeZone) {
|
|
217
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
218
|
+
timeZone,
|
|
219
|
+
weekday: "short",
|
|
220
|
+
}).format(zonedMidnight(dateString, timeZone));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function localDateTimeLabel(timestampMs, timeZone) {
|
|
224
|
+
if (!Number.isFinite(timestampMs)) return "unknown time";
|
|
225
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
226
|
+
timeZone,
|
|
227
|
+
dateStyle: "medium",
|
|
228
|
+
timeStyle: "short",
|
|
229
|
+
}).format(new Date(timestampMs));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function binDateLabel(bin, timeZone) {
|
|
233
|
+
const start = localDateLabel(bin.startDateString, timeZone);
|
|
234
|
+
const lastDate = shiftCalendarDate(bin.endDateString, -1);
|
|
235
|
+
if (lastDate === bin.startDateString) return start;
|
|
236
|
+
return `${start}–${localDateLabel(lastDate, timeZone).replace(/^[A-Za-z]+ /, "")}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function svgText({
|
|
240
|
+
x,
|
|
241
|
+
y,
|
|
242
|
+
value,
|
|
243
|
+
fill = COLORS.ink,
|
|
244
|
+
size = 12,
|
|
245
|
+
weight = 400,
|
|
246
|
+
anchor = "start",
|
|
247
|
+
spacing = null,
|
|
248
|
+
opacity = null,
|
|
249
|
+
}) {
|
|
250
|
+
const spacingAttr = spacing ? ` letter-spacing="${spacing}"` : "";
|
|
251
|
+
const opacityAttr = opacity !== null ? ` opacity="${opacity}"` : "";
|
|
252
|
+
return `<text x="${x}" y="${y}" fill="${fill}" font-family="${FONT_FAMILY}" font-size="${size}px" font-weight="${weight}" text-anchor="${anchor}"${spacingAttr}${opacityAttr}>${escapeXml(value)}</text>`;
|
|
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(" ");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function roundedTopRect(x, y, width, height, radius, fill) {
|
|
273
|
+
const r = Math.min(radius, width / 2, height);
|
|
274
|
+
return `<path d="M${x.toFixed(2)},${(y + height).toFixed(2)} L${x.toFixed(2)},${(y + r).toFixed(2)} Q${x.toFixed(2)},${y.toFixed(2)} ${(x + r).toFixed(2)},${y.toFixed(2)} L${(x + width - r).toFixed(2)},${y.toFixed(2)} Q${(x + width).toFixed(2)},${y.toFixed(2)} ${(x + width).toFixed(2)},${(y + r).toFixed(2)} L${(x + width).toFixed(2)},${(y + height).toFixed(2)} Z" fill="${fill}"/>`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function xPosition(timestampMs, bounds, plotLeft, plotWidth) {
|
|
278
|
+
const span = bounds.end.getTime() - bounds.start.getTime();
|
|
279
|
+
const ratio = span > 0
|
|
280
|
+
? (timestampMs - bounds.start.getTime()) / span
|
|
281
|
+
: 0;
|
|
282
|
+
return plotLeft + Math.max(0, Math.min(1, ratio)) * plotWidth;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function buildQuotaLine(trend, bounds, plotLeft, plotWidth, chartTop, chartHeight) {
|
|
286
|
+
const points = (trend.points ?? [])
|
|
287
|
+
.filter((point) => point.timestampMs >= bounds.start.getTime() && point.timestampMs <= bounds.end.getTime())
|
|
288
|
+
.sort((left, right) => left.timestampMs - right.timestampMs);
|
|
289
|
+
if (!points.length) return { points: [], resetPoints: [], yForRemaining: null };
|
|
290
|
+
|
|
291
|
+
const resetPoints = [];
|
|
292
|
+
const linePoints = [];
|
|
293
|
+
const resets = [...(trend.resets ?? [])].sort((left, right) => left.timestampMs - right.timestampMs);
|
|
294
|
+
let resetIndex = 0;
|
|
295
|
+
|
|
296
|
+
const yForRemaining = (value) =>
|
|
297
|
+
chartTop + chartHeight - (Math.max(0, Math.min(100, value)) / 100) * chartHeight;
|
|
298
|
+
for (const point of points) {
|
|
299
|
+
while (resetIndex < resets.length && resets[resetIndex].timestampMs <= point.timestampMs) {
|
|
300
|
+
const reset = resets[resetIndex];
|
|
301
|
+
if (reset.timestampMs >= bounds.start.getTime() && linePoints.length) {
|
|
302
|
+
const x = xPosition(reset.timestampMs, bounds, plotLeft, plotWidth);
|
|
303
|
+
const previous = linePoints.at(-1);
|
|
304
|
+
linePoints.push({ x, y: previous.y });
|
|
305
|
+
linePoints.push({ x, y: yForRemaining(100), reset: true });
|
|
306
|
+
resetPoints.push({ x, timestampMs: reset.timestampMs, kind: reset.kind });
|
|
307
|
+
}
|
|
308
|
+
resetIndex += 1;
|
|
309
|
+
}
|
|
310
|
+
linePoints.push({
|
|
311
|
+
x: xPosition(point.timestampMs, bounds, plotLeft, plotWidth),
|
|
312
|
+
y: yForRemaining(point.remainingPercent),
|
|
313
|
+
timestampMs: point.timestampMs,
|
|
314
|
+
remainingPercent: point.remainingPercent,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return { points: linePoints, resetPoints, yForRemaining };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function labelEvery(binCount) {
|
|
321
|
+
if (binCount <= 14) return 1;
|
|
322
|
+
if (binCount <= 20) return 2;
|
|
323
|
+
return 3;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function chip(x, y, value, { anchor = "middle", small = false } = {}) {
|
|
327
|
+
const textSize = small ? 10.5 : 12;
|
|
328
|
+
const paddingX = small ? 7 : 9;
|
|
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();
|
|
352
|
+
const totals = new Map();
|
|
353
|
+
for (const event of snapshot.events ?? []) {
|
|
354
|
+
const timestampMs = new Date(event.timestamp).getTime();
|
|
355
|
+
if (!Number.isFinite(timestampMs) || timestampMs < startMs || timestampMs >= endMs) {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const tokens = Math.max(0, Number(event.totalTokens) || 0);
|
|
359
|
+
if (!(tokens > 0)) continue;
|
|
360
|
+
const model = (() => {
|
|
361
|
+
const value = String(event.model || "unknown").trim().toLowerCase();
|
|
362
|
+
for (const label of MODEL_ORDER) {
|
|
363
|
+
if (label === "Other" || label === "Unknown" || label === "Unattributed") continue;
|
|
364
|
+
}
|
|
365
|
+
return value;
|
|
366
|
+
})();
|
|
367
|
+
void model;
|
|
368
|
+
totals.set(event.model, tokens);
|
|
369
|
+
}
|
|
370
|
+
return { startMs, endMs };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function renderTrendImage({
|
|
374
|
+
snapshot,
|
|
375
|
+
bounds,
|
|
376
|
+
trend = buildUsageTrend(snapshot, bounds),
|
|
377
|
+
days = bounds.rangeDays ?? 7,
|
|
378
|
+
options = {},
|
|
379
|
+
}) {
|
|
380
|
+
const width = Math.max(900, Math.min(2_400, Number(options.imageWidth) || 1_280));
|
|
381
|
+
const outer = 32;
|
|
382
|
+
const margin = { left: 84, right: 96 };
|
|
383
|
+
const plotLeft = margin.left;
|
|
384
|
+
const plotWidth = width - margin.left - margin.right;
|
|
385
|
+
|
|
386
|
+
const actual = buildActualTokenBins(snapshot, bounds, days, plotWidth);
|
|
387
|
+
const burn = buildBurnDayBins(trend, bounds, { days, binSize: actual.binSize });
|
|
388
|
+
const meterUsable = Boolean(trend.available && burn.totalPercent > 0);
|
|
389
|
+
const percentMode = Boolean(options.drain) && meterUsable;
|
|
390
|
+
const bars = percentMode ? burn.bins : actual.bins;
|
|
391
|
+
const binCount = actual.binCount;
|
|
392
|
+
const binTotalOf = (bin) => (percentMode ? bin.totalPercent : bin.totalTokens);
|
|
393
|
+
const maxBar = niceCeiling(
|
|
394
|
+
bars.reduce((maximum, bin) => Math.max(maximum, binTotalOf(bin)), 0),
|
|
395
|
+
);
|
|
396
|
+
const hasLine = Boolean(trend.available && (trend.points ?? []).length > 1);
|
|
397
|
+
|
|
398
|
+
// Range totals for the stat cards.
|
|
399
|
+
const totalTokens = [...actual.totals.values()].reduce((sum, value) => sum + value, 0);
|
|
400
|
+
const modelCards = [...actual.totals.entries()]
|
|
401
|
+
.filter(([, value]) => value > 0 && totalTokens > 0 && value / totalTokens >= 0.01)
|
|
402
|
+
.sort((left, right) => right[1] - left[1])
|
|
403
|
+
.slice(0, 3)
|
|
404
|
+
.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
|
+
|
|
408
|
+
// Prior-period per-model totals for the delta line.
|
|
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.
|
|
424
|
+
const priorBounds = {
|
|
425
|
+
...bounds,
|
|
426
|
+
startDateString: shiftCalendarDate(bounds.startDateString, -days),
|
|
427
|
+
endDateString: shiftCalendarDate(bounds.endDateString, -days),
|
|
428
|
+
start: new Date(prior.startMs),
|
|
429
|
+
end: new Date(prior.endMs),
|
|
430
|
+
};
|
|
431
|
+
const priorActual = buildActualTokenBins(snapshot, priorBounds, days, plotWidth);
|
|
432
|
+
for (const [model, value] of priorActual.totals) priorTotals.set(model, value);
|
|
433
|
+
|
|
434
|
+
const latestQuotaPoint = [...(trend.points ?? [])]
|
|
435
|
+
.filter((point) => point.timestampMs <= bounds.end.getTime())
|
|
436
|
+
.at(-1);
|
|
437
|
+
const rateCard = rateCardSummary(snapshot, bounds);
|
|
438
|
+
const expiries = (trend.resets ?? []).filter((reset) => reset.kind === "weekly-expiry").length;
|
|
439
|
+
const restarts = (trend.resets ?? []).filter((reset) => reset.kind !== "weekly-expiry").length;
|
|
440
|
+
|
|
441
|
+
// ---- Layout ----
|
|
442
|
+
const headerTop = 48;
|
|
443
|
+
const cardTop = 100;
|
|
444
|
+
const cardHeight = 100;
|
|
445
|
+
const chartTop = cardTop + cardHeight + 64;
|
|
446
|
+
const chartHeight = 470;
|
|
447
|
+
const chartBottom = chartTop + chartHeight;
|
|
448
|
+
const xLabelBand = 58;
|
|
449
|
+
const legendY = chartBottom + xLabelBand + 26;
|
|
450
|
+
const footerTop = legendY + 26;
|
|
451
|
+
const footerHeight = 96;
|
|
452
|
+
const height = footerTop + footerHeight + outer;
|
|
453
|
+
|
|
454
|
+
const title = `TOKEN LEDGER · ${days}-DAY TREND`;
|
|
455
|
+
const yearLabel = bounds.endDateString.slice(0, 4);
|
|
456
|
+
const subtitle = `${localDateLabel(bounds.startDateString, bounds.timeZone)} – ${localDateLabel(bounds.endDateString, bounds.timeZone)}, ${yearLabel} · ${bounds.timeZone}${latestQuotaPoint ? ` · Latest remaining: ${percent(latestQuotaPoint.remainingPercent)}` : ""}`;
|
|
457
|
+
const description = percentMode
|
|
458
|
+
? "Dark dashboard: stat cards for each model, then stacked columns of the observed weekly-limit percentage consumed per day split by model via rate-card credit weights, overlaid with the observed weekly meter remaining as an amber line with value chips."
|
|
459
|
+
: "Dark dashboard: stat cards for each model, then stacked columns of local token volume per day by model with per-segment token and share labels, overlaid with the observed weekly meter remaining as an amber line with value chips. Darker shades within a segment are fast-mode usage.";
|
|
460
|
+
|
|
461
|
+
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>`,
|
|
464
|
+
`<desc id="trend-description">${escapeXml(description)}</desc>`,
|
|
465
|
+
`<rect width="100%" height="100%" fill="${COLORS.background}"/>`,
|
|
466
|
+
svgText({ x: outer, y: headerTop, value: title, size: 26, weight: 750, spacing: "0.02em" }),
|
|
467
|
+
svgText({ x: outer, y: headerTop + 26, value: subtitle, fill: COLORS.secondary, size: 13 }),
|
|
468
|
+
];
|
|
469
|
+
|
|
470
|
+
// ---- Stat cards ----
|
|
471
|
+
const card = (x, cardWidth, body) => {
|
|
472
|
+
elements.push(`<rect x="${x.toFixed(2)}" y="${cardTop}" width="${cardWidth.toFixed(2)}" height="${cardHeight}" rx="10" fill="${COLORS.panel}" stroke="${COLORS.panelBorder}" stroke-width="1"/>`);
|
|
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;
|
|
489
|
+
for (const { model, tokens } of modelCards) {
|
|
490
|
+
card(cardX, unitWidth, (x, y) => {
|
|
491
|
+
elements.push(`<circle cx="${x + 6}" cy="${y + 25}" r="6" fill="${styleForModel(model)}"/>`);
|
|
492
|
+
elements.push(svgText({ x: x + 20, y: y + 30, value: model, fill: COLORS.ink, size: 14, weight: 650 }));
|
|
493
|
+
elements.push(svgText({ x, y: y + 58, value: compact(tokens), fill: COLORS.ink, size: 20, weight: 700 }));
|
|
494
|
+
elements.push(svgText({
|
|
495
|
+
x: x + unitWidth - 32,
|
|
496
|
+
y: y + 58,
|
|
497
|
+
value: totalTokens > 0 ? percent((tokens / totalTokens) * 100) : "—",
|
|
498
|
+
fill: COLORS.secondary,
|
|
499
|
+
size: 14,
|
|
500
|
+
weight: 600,
|
|
501
|
+
anchor: "end",
|
|
502
|
+
}));
|
|
503
|
+
const delta = fitLine(deltaLine(model, tokens), 11.5, unitWidth - 32);
|
|
504
|
+
elements.push(svgText({ x, y: y + 82, value: delta.text, fill: COLORS.muted, size: delta.size }));
|
|
505
|
+
});
|
|
506
|
+
cardX += unitWidth + cardGap;
|
|
507
|
+
}
|
|
508
|
+
if (hasFast) {
|
|
509
|
+
card(cardX, unitWidth, (x, y) => {
|
|
510
|
+
elements.push(`<circle cx="${x + 6}" cy="${y + 25}" r="6" fill="${FAST_MODE_LABEL_COLOR}"/>`);
|
|
511
|
+
elements.push(svgText({ x: x + 20, y: y + 30, value: "Fast Mode", fill: COLORS.ink, size: 14, weight: 650 }));
|
|
512
|
+
elements.push(svgText({ x, y: y + 58, value: "1.50× rate", fill: COLORS.ink, size: 20, weight: 700 }));
|
|
513
|
+
const fastSub = fitLine(
|
|
514
|
+
`${percent((fastTokens / Math.max(1, totalTokens)) * 100)} of tokens`,
|
|
515
|
+
11.5,
|
|
516
|
+
unitWidth - 32,
|
|
517
|
+
);
|
|
518
|
+
elements.push(svgText({
|
|
519
|
+
x,
|
|
520
|
+
y: y + 82,
|
|
521
|
+
value: fastSub.text,
|
|
522
|
+
fill: COLORS.muted,
|
|
523
|
+
size: fastSub.size,
|
|
524
|
+
}));
|
|
525
|
+
});
|
|
526
|
+
cardX += unitWidth + cardGap;
|
|
527
|
+
}
|
|
528
|
+
if (hasLine) {
|
|
529
|
+
card(cardX, unitWidth, (x, y) => {
|
|
530
|
+
elements.push(`<circle cx="${x + 6}" cy="${y + 25}" r="6" fill="${COLORS.line}"/>`);
|
|
531
|
+
elements.push(svgText({ x: x + 20, y: y + 30, value: "Weekly Meter", fill: COLORS.ink, size: 14, weight: 650 }));
|
|
532
|
+
elements.push(svgText({
|
|
533
|
+
x,
|
|
534
|
+
y: y + 58,
|
|
535
|
+
value: latestQuotaPoint ? percent(latestQuotaPoint.remainingPercent) : "—",
|
|
536
|
+
fill: COLORS.ink,
|
|
537
|
+
size: 20,
|
|
538
|
+
weight: 700,
|
|
539
|
+
}));
|
|
540
|
+
const meterSub = fitLine(
|
|
541
|
+
latestQuotaPoint
|
|
542
|
+
? `remaining · ${localDateLabel(bounds.endDateString, bounds.timeZone)}`
|
|
543
|
+
: "no observations",
|
|
544
|
+
11.5,
|
|
545
|
+
unitWidth - 32,
|
|
546
|
+
);
|
|
547
|
+
elements.push(svgText({
|
|
548
|
+
x,
|
|
549
|
+
y: y + 82,
|
|
550
|
+
value: meterSub.text,
|
|
551
|
+
fill: COLORS.muted,
|
|
552
|
+
size: meterSub.size,
|
|
553
|
+
}));
|
|
554
|
+
});
|
|
555
|
+
cardX += unitWidth + cardGap;
|
|
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
|
+
);
|
|
570
|
+
elements.push(svgText({
|
|
571
|
+
x: x + 22,
|
|
572
|
+
y: y + 30,
|
|
573
|
+
value: keyLineOne.text,
|
|
574
|
+
fill: COLORS.secondary,
|
|
575
|
+
size: keyLineOne.size,
|
|
576
|
+
}));
|
|
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
|
+
elements.push(svgText({
|
|
585
|
+
x: x + 22,
|
|
586
|
+
y: y + 60,
|
|
587
|
+
value: keyLineTwo.text,
|
|
588
|
+
fill: COLORS.secondary,
|
|
589
|
+
size: keyLineTwo.size,
|
|
590
|
+
}));
|
|
591
|
+
const keyLineThree = fitLine(
|
|
592
|
+
"Darker segment shade = fast mode",
|
|
593
|
+
11,
|
|
594
|
+
keyCardWidth - 54,
|
|
595
|
+
);
|
|
596
|
+
elements.push(svgText({
|
|
597
|
+
x: x + 22,
|
|
598
|
+
y: y + 82,
|
|
599
|
+
value: keyLineThree.text,
|
|
600
|
+
fill: COLORS.muted,
|
|
601
|
+
size: keyLineThree.size,
|
|
602
|
+
}));
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// ---- Chart grid + axes ----
|
|
606
|
+
for (const fraction of [0, 0.25, 0.5, 0.75, 1]) {
|
|
607
|
+
const y = chartBottom - fraction * chartHeight;
|
|
608
|
+
elements.push(`<line x1="${plotLeft}" y1="${y.toFixed(2)}" x2="${plotLeft + plotWidth}" y2="${y.toFixed(2)}" stroke="${fraction === 0 ? COLORS.baseline : COLORS.grid}" stroke-width="1"/>`);
|
|
609
|
+
elements.push(svgText({
|
|
610
|
+
x: plotLeft - 12,
|
|
611
|
+
y: y + 4,
|
|
612
|
+
value: percentMode
|
|
613
|
+
? `${Number((maxBar * fraction).toFixed(1))}%`
|
|
614
|
+
: compact(maxBar * fraction),
|
|
615
|
+
fill: COLORS.secondary,
|
|
616
|
+
size: 12,
|
|
617
|
+
anchor: "end",
|
|
618
|
+
}));
|
|
619
|
+
if (hasLine) {
|
|
620
|
+
elements.push(svgText({
|
|
621
|
+
x: plotLeft + plotWidth + 14,
|
|
622
|
+
y: y + 4,
|
|
623
|
+
value: `${Math.round(fraction * 100)}%`,
|
|
624
|
+
fill: COLORS.line,
|
|
625
|
+
size: 12,
|
|
626
|
+
weight: 600,
|
|
627
|
+
}));
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
elements.push(svgText({
|
|
631
|
+
x: 26,
|
|
632
|
+
y: chartTop + chartHeight / 2,
|
|
633
|
+
value: percentMode ? "OBSERVED LIMIT DRAIN" : "ACTUAL TOKEN VOLUME",
|
|
634
|
+
fill: COLORS.leftAxis,
|
|
635
|
+
size: 12,
|
|
636
|
+
weight: 650,
|
|
637
|
+
anchor: "middle",
|
|
638
|
+
spacing: "0.1em",
|
|
639
|
+
}).replace("<text ", `<text transform="rotate(-90 26 ${chartTop + chartHeight / 2})" `));
|
|
640
|
+
if (hasLine) {
|
|
641
|
+
elements.push(svgText({
|
|
642
|
+
x: width - 24,
|
|
643
|
+
y: chartTop + chartHeight / 2,
|
|
644
|
+
value: "WEEKLY METER REMAINING (%)",
|
|
645
|
+
fill: COLORS.line,
|
|
646
|
+
size: 12,
|
|
647
|
+
weight: 650,
|
|
648
|
+
anchor: "middle",
|
|
649
|
+
spacing: "0.1em",
|
|
650
|
+
}).replace("<text ", `<text transform="rotate(90 ${width - 24} ${chartTop + chartHeight / 2})" `));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ---- Bars ----
|
|
654
|
+
const slotWidth = plotWidth / binCount;
|
|
655
|
+
const barWidth = Math.min(104, Math.max(26, slotWidth * 0.62));
|
|
656
|
+
const segmentGap = 2;
|
|
657
|
+
|
|
658
|
+
for (const [binIndex, bin] of bars.entries()) {
|
|
659
|
+
const x = plotLeft + binIndex * slotWidth + (slotWidth - barWidth) / 2;
|
|
660
|
+
const binTotal = binTotalOf(bin);
|
|
661
|
+
const entries = sortedModelEntries(bin.values);
|
|
662
|
+
let cumulative = 0;
|
|
663
|
+
for (const [entryIndex, [model, value]] of entries.entries()) {
|
|
664
|
+
const isTop = entryIndex === entries.length - 1;
|
|
665
|
+
const fullHeight = (value / maxBar) * chartHeight;
|
|
666
|
+
const gap = entryIndex === 0 ? 0 : segmentGap;
|
|
667
|
+
const segmentHeight = Math.max(0, fullHeight - gap);
|
|
668
|
+
const y = chartBottom - cumulative - fullHeight;
|
|
669
|
+
const baseColor = styleForModel(model);
|
|
670
|
+
if (segmentHeight > 0.4) {
|
|
671
|
+
if (isTop) {
|
|
672
|
+
elements.push(roundedTopRect(x, y, barWidth, segmentHeight, 5, baseColor));
|
|
673
|
+
} else {
|
|
674
|
+
elements.push(`<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${barWidth.toFixed(2)}" height="${segmentHeight.toFixed(2)}" fill="${baseColor}"/>`);
|
|
675
|
+
}
|
|
676
|
+
const fastValue = percentMode ? 0 : (bin.fastValues?.get(model) ?? 0);
|
|
677
|
+
const fastHeight = fastValue > 0 && value > 0
|
|
678
|
+
? segmentHeight * Math.min(1, fastValue / value)
|
|
679
|
+
: 0;
|
|
680
|
+
if (fastHeight > 0.5) {
|
|
681
|
+
if (isTop) {
|
|
682
|
+
elements.push(roundedTopRect(x, y, barWidth, fastHeight, 5, fastShade(baseColor)));
|
|
683
|
+
} else {
|
|
684
|
+
elements.push(`<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${barWidth.toFixed(2)}" height="${fastHeight.toFixed(2)}" fill="${fastShade(baseColor)}"/>`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// Per-segment labels: model name first, then value and share of the
|
|
688
|
+
// column — each line only when it fits the segment.
|
|
689
|
+
const share = binTotal > 0 ? (value / binTotal) * 100 : 0;
|
|
690
|
+
const valueLabel = percentMode ? percent(value) : compact(value);
|
|
691
|
+
const fits = (text, size) => text.length * size * 0.6 <= barWidth - 8;
|
|
692
|
+
const candidates = [
|
|
693
|
+
{ value: model, size: 12, weight: 650, opacity: null },
|
|
694
|
+
{ value: valueLabel, size: 11.5, weight: 600, opacity: null },
|
|
695
|
+
{ value: `(${percent(share)})`, size: 10, weight: 400, opacity: 0.75 },
|
|
696
|
+
].filter((line) => fits(line.value, line.size));
|
|
697
|
+
const lineHeight = 15;
|
|
698
|
+
const maxLines = Math.min(
|
|
699
|
+
candidates.length,
|
|
700
|
+
Math.floor((segmentHeight - 6) / lineHeight),
|
|
701
|
+
);
|
|
702
|
+
if (maxLines > 0) {
|
|
703
|
+
const lines = candidates.slice(0, maxLines);
|
|
704
|
+
const blockTop = y + segmentHeight / 2 - ((lines.length - 1) * lineHeight) / 2;
|
|
705
|
+
for (const [lineIndex, line] of lines.entries()) {
|
|
706
|
+
elements.push(svgText({
|
|
707
|
+
x: x + barWidth / 2,
|
|
708
|
+
y: blockTop + lineIndex * lineHeight + 4,
|
|
709
|
+
value: line.value,
|
|
710
|
+
fill: "#ffffff",
|
|
711
|
+
size: line.size,
|
|
712
|
+
weight: line.weight,
|
|
713
|
+
anchor: "middle",
|
|
714
|
+
opacity: line.opacity,
|
|
715
|
+
}));
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
cumulative += fullHeight;
|
|
720
|
+
}
|
|
721
|
+
if (binTotal > 0) {
|
|
722
|
+
elements.push(svgText({
|
|
723
|
+
x: x + barWidth / 2,
|
|
724
|
+
y: chartBottom - (binTotal / maxBar) * chartHeight - 10,
|
|
725
|
+
value: percentMode
|
|
726
|
+
? `${bin.approximate ? "≈" : ""}${percent(binTotal)}`
|
|
727
|
+
: compact(binTotal),
|
|
728
|
+
fill: COLORS.ink,
|
|
729
|
+
size: 14.5,
|
|
730
|
+
weight: 650,
|
|
731
|
+
anchor: "middle",
|
|
732
|
+
}));
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (binIndex % labelEvery(binCount) === 0 || binIndex === binCount - 1) {
|
|
736
|
+
const weekday = actual.binSize === 1
|
|
737
|
+
? localWeekdayLabel(bin.startDateString, bounds.timeZone).toUpperCase()
|
|
738
|
+
: "";
|
|
739
|
+
if (weekday) {
|
|
740
|
+
elements.push(svgText({
|
|
741
|
+
x: x + barWidth / 2,
|
|
742
|
+
y: chartBottom + 24,
|
|
743
|
+
value: weekday,
|
|
744
|
+
fill: COLORS.muted,
|
|
745
|
+
size: 11,
|
|
746
|
+
weight: 600,
|
|
747
|
+
anchor: "middle",
|
|
748
|
+
}));
|
|
749
|
+
}
|
|
750
|
+
elements.push(svgText({
|
|
751
|
+
x: x + barWidth / 2,
|
|
752
|
+
y: chartBottom + (weekday ? 42 : 30),
|
|
753
|
+
value: binDateLabel(bin, bounds.timeZone),
|
|
754
|
+
fill: COLORS.secondary,
|
|
755
|
+
size: 12.5,
|
|
756
|
+
anchor: "middle",
|
|
757
|
+
}));
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// ---- Meter line, refill markers, chips ----
|
|
762
|
+
const quota = hasLine
|
|
763
|
+
? buildQuotaLine(trend, bounds, plotLeft, plotWidth, chartTop, chartHeight)
|
|
764
|
+
: { points: [], resetPoints: [] };
|
|
765
|
+
if (hasLine && quota.points.length > 1) {
|
|
766
|
+
for (const reset of quota.resetPoints) {
|
|
767
|
+
elements.push(`<line x1="${reset.x.toFixed(2)}" y1="${chartTop}" x2="${reset.x.toFixed(2)}" y2="${chartBottom}" stroke="${COLORS.baseline}" stroke-width="1.25" stroke-dasharray="5 5"/>`);
|
|
768
|
+
}
|
|
769
|
+
elements.push(`<path d="${linePath(quota.points)}" fill="none" stroke="${COLORS.line}" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"/>`);
|
|
770
|
+
|
|
771
|
+
// Chips: one meter reading per labeled column (the last observation in
|
|
772
|
+
// that column), plus a refill chip at each restart or reset.
|
|
773
|
+
const step = labelEvery(binCount);
|
|
774
|
+
const observationPoints = quota.points.filter((point) => point.timestampMs);
|
|
775
|
+
const chipPoints = [];
|
|
776
|
+
for (let binIndex = 0; binIndex < binCount; binIndex += 1) {
|
|
777
|
+
if (binIndex % step !== 0 && binIndex !== binCount - 1) continue;
|
|
778
|
+
const binEndMs = zonedMidnight(
|
|
779
|
+
bars[binIndex].endDateString,
|
|
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
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ---- Legend strip ----
|
|
839
|
+
const legendModels = sortedModelEntries(
|
|
840
|
+
percentMode ? burn.totals : actual.totals,
|
|
841
|
+
).map(([model]) => model);
|
|
842
|
+
const legendParts = legendModels.map((model) => ({ swatch: styleForModel(model), label: model }));
|
|
843
|
+
let legendX = plotLeft;
|
|
844
|
+
for (const part of legendParts) {
|
|
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}"/>`);
|
|
852
|
+
elements.push(svgText({
|
|
853
|
+
x: legendX + 30,
|
|
854
|
+
y: legendY,
|
|
855
|
+
value: "Observed weekly meter remaining (%)",
|
|
856
|
+
fill: COLORS.secondary,
|
|
857
|
+
size: 12.5,
|
|
858
|
+
}));
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ---- Footer strip ----
|
|
862
|
+
elements.push(`<rect x="${outer}" y="${footerTop}" width="${width - outer * 2}" height="${footerHeight}" rx="10" fill="${COLORS.panel}" stroke="${COLORS.panelBorder}" stroke-width="1"/>`);
|
|
863
|
+
const generatedAtMs = new Date(snapshot.generatedAt).getTime();
|
|
864
|
+
const meterTime = latestQuotaPoint
|
|
865
|
+
? localDateTimeLabel(latestQuotaPoint.timestampMs, bounds.timeZone)
|
|
866
|
+
: "unknown";
|
|
867
|
+
const snapshotTime = Number.isFinite(generatedAtMs)
|
|
868
|
+
? localDateTimeLabel(generatedAtMs, bounds.timeZone)
|
|
869
|
+
: "unknown";
|
|
870
|
+
const footerCells = [
|
|
871
|
+
{
|
|
872
|
+
icon: "bars",
|
|
873
|
+
lines: percentMode
|
|
874
|
+
? ["Bars = observed meter drops", `${percent(burn.totalPercent)} drained in range`, "split by rate-card credit weights"]
|
|
875
|
+
: meterUsable
|
|
876
|
+
? ["Bars show actual token volume", `meter dropped ${percent(burn.totalPercent)} in range`, "≈ = drop spread over meter gaps"]
|
|
877
|
+
: ["Bars show actual token volume", "no usable meter drain in range", ""],
|
|
878
|
+
},
|
|
879
|
+
{
|
|
880
|
+
icon: "card",
|
|
881
|
+
lines: [
|
|
882
|
+
"Rate-card estimate",
|
|
883
|
+
`${compact(rateCard.credits)} credits${hasFast ? " · fast ×1.5" : ""} · card ${trend.rateCardAsOf}`,
|
|
884
|
+
"estimate only · not the meter",
|
|
885
|
+
],
|
|
886
|
+
},
|
|
887
|
+
{
|
|
888
|
+
icon: "clock",
|
|
889
|
+
lines: [
|
|
890
|
+
`${expiries} weekly expir${expiries === 1 ? "y" : "ies"}, ${restarts} restart${restarts === 1 ? "" : "s"}`,
|
|
891
|
+
"restarts are provider-initiated",
|
|
892
|
+
"windows keyed by reset time",
|
|
893
|
+
],
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
icon: "calendar",
|
|
897
|
+
lines: ["Meter snapshots", `latest ${meterTime}`, `snapshot ${snapshotTime}`],
|
|
898
|
+
},
|
|
899
|
+
];
|
|
900
|
+
const cellWidth = (width - outer * 2) / footerCells.length;
|
|
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"/>`);
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
for (const [cellIndex, cell] of footerCells.entries()) {
|
|
920
|
+
const cellX = outer + cellIndex * cellWidth;
|
|
921
|
+
if (cellIndex > 0) {
|
|
922
|
+
elements.push(`<line x1="${cellX.toFixed(2)}" y1="${footerTop + 14}" x2="${cellX.toFixed(2)}" y2="${footerTop + footerHeight - 14}" stroke="${COLORS.panelBorder}" stroke-width="1"/>`);
|
|
923
|
+
}
|
|
924
|
+
drawIcon(cell.icon, cellX + 20, footerTop + 22);
|
|
925
|
+
for (const [lineIndex, line] of cell.lines.entries()) {
|
|
926
|
+
if (!line) continue;
|
|
927
|
+
const fitted = fitLine(line, lineIndex === 0 ? 12.5 : 11.5, cellWidth - 52 - 18);
|
|
928
|
+
elements.push(svgText({
|
|
929
|
+
x: cellX + 52,
|
|
930
|
+
y: footerTop + 32 + lineIndex * 20,
|
|
931
|
+
value: fitted.text,
|
|
932
|
+
fill: lineIndex === 0 ? COLORS.secondary : COLORS.muted,
|
|
933
|
+
size: fitted.size,
|
|
934
|
+
weight: lineIndex === 0 ? 600 : 400,
|
|
935
|
+
}));
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
elements.push("</svg>");
|
|
940
|
+
return elements.join("\n");
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
export async function writeTrendPng(svg, outputPath) {
|
|
944
|
+
await sharp(Buffer.from(svg, "utf8")).png().toFile(outputPath);
|
|
945
|
+
}
|