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