tledger 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +283 -155
- package/bin/token-ledger-cache-data.mjs +492 -0
- package/bin/token-ledger-cache-image.mjs +7 -1147
- package/bin/token-ledger-cache-sections.mjs +848 -0
- package/bin/token-ledger-cost-terminal.mjs +234 -0
- package/bin/token-ledger-image-layout.mjs +20 -0
- package/bin/token-ledger-image-primitives.mjs +192 -0
- package/bin/token-ledger-report-data.mjs +1159 -0
- package/bin/token-ledger-source-status.mjs +31 -0
- package/bin/token-ledger-terminal.mjs +245 -69
- package/bin/token-ledger-trend-image.mjs +2039 -1599
- package/bin/token-ledger-trend-terminal.mjs +273 -150
- package/bin/token-ledger-trend.mjs +262 -210
- package/bin/token-ledger-tui.mjs +180 -51
- package/bin/token-ledger.mjs +747 -194
- package/docs/durable-ledger-operations.md +198 -0
- package/docs/release-notes-0.4.0.md +41 -0
- package/docs/token-ledger-report-7-day.png +0 -0
- package/lib/token-ledger-calendar.mjs +225 -0
- package/lib/token-ledger-collection.mjs +100 -0
- package/lib/token-ledger-importer.mjs +3334 -480
- package/lib/token-ledger-labels.mjs +66 -0
- package/lib/token-ledger-ledger.mjs +6056 -0
- package/lib/token-ledger-quota-contract.mjs +38 -0
- package/lib/token-ledger-range-analysis.mjs +120 -0
- package/lib/token-ledger-rates.mjs +330 -0
- package/lib/token-ledger-snapshot.mjs +336 -35
- package/lib/token-ledger-terminal-text.mjs +11 -0
- package/lib/token-ledger-usage.mjs +339 -33
- package/package.json +13 -10
- package/bin/token-ledger-rates.mjs +0 -65
|
@@ -2,15 +2,39 @@ import { Buffer } from "node:buffer";
|
|
|
2
2
|
|
|
3
3
|
import sharp from "sharp";
|
|
4
4
|
|
|
5
|
+
import { buildBurnDayBins, buildUsageTrend } from "./token-ledger-trend.mjs";
|
|
6
|
+
import { chooseBinSize } from "./token-ledger-trend-terminal.mjs";
|
|
5
7
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} from "
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
calculateCodexPurchasedCredits,
|
|
9
|
+
codexCreditMultiplier,
|
|
10
|
+
isFastServiceTier,
|
|
11
|
+
} from "../lib/token-ledger-rates.mjs";
|
|
12
|
+
import {
|
|
13
|
+
compact,
|
|
14
|
+
escapeXml,
|
|
15
|
+
fastShade,
|
|
16
|
+
shiftCalendarDate,
|
|
17
|
+
svgRect,
|
|
18
|
+
svgText,
|
|
19
|
+
textWidth,
|
|
20
|
+
truncateText,
|
|
21
|
+
TREND_IMAGE_MODEL_COLORS,
|
|
22
|
+
} from "./token-ledger-image-primitives.mjs";
|
|
23
|
+
import {
|
|
24
|
+
buildTrendReportViewModel,
|
|
25
|
+
zonedMidnight,
|
|
26
|
+
} from "./token-ledger-report-data.mjs";
|
|
27
|
+
export {
|
|
28
|
+
compact,
|
|
29
|
+
escapeXml,
|
|
30
|
+
fastShade,
|
|
31
|
+
shiftCalendarDate,
|
|
32
|
+
svgRect,
|
|
33
|
+
svgText,
|
|
34
|
+
textWidth,
|
|
35
|
+
truncateText,
|
|
36
|
+
TREND_IMAGE_MODEL_COLORS,
|
|
37
|
+
};
|
|
14
38
|
|
|
15
39
|
const MODEL_ORDER = [
|
|
16
40
|
"Luna",
|
|
@@ -25,25 +49,11 @@ const MODEL_ORDER = [
|
|
|
25
49
|
"Unattributed",
|
|
26
50
|
];
|
|
27
51
|
|
|
28
|
-
// Dark-surface categorical palette; the co-occurring set and the stack-order
|
|
29
|
-
// adjacency both pass CVD, normal-vision, and contrast checks on #0e1420.
|
|
30
|
-
export const TREND_IMAGE_MODEL_COLORS = {
|
|
31
|
-
Luna: "#3b82f6",
|
|
32
|
-
Sol: "#10a394",
|
|
33
|
-
Terra: "#8b7cf6",
|
|
34
|
-
"GPT-5.5": "#d55181",
|
|
35
|
-
"GPT-5.4": "#0891b2",
|
|
36
|
-
Daybreak: "#16a34a",
|
|
37
|
-
"Auto review": "#e5484d",
|
|
38
|
-
Other: "#64748b",
|
|
39
|
-
Unknown: "#64748b",
|
|
40
|
-
Unattributed: "#475569",
|
|
41
|
-
};
|
|
42
|
-
|
|
43
52
|
const COLORS = {
|
|
44
53
|
background: "#0e1420",
|
|
45
54
|
panel: "#151d2c",
|
|
46
55
|
panelBorder: "#273246",
|
|
56
|
+
separator: "rgba(119,131,154,.22)",
|
|
47
57
|
meterPanel: "#1b1712",
|
|
48
58
|
meterPanelBorder: "rgba(246,183,60,.4)",
|
|
49
59
|
ink: "#f2f5fa",
|
|
@@ -51,81 +61,178 @@ const COLORS = {
|
|
|
51
61
|
muted: "#77839a",
|
|
52
62
|
grid: "#1c2534",
|
|
53
63
|
baseline: "#33405a",
|
|
54
|
-
rule: "rgba(255,255,255,.1)",
|
|
55
64
|
track: "rgba(255,255,255,.09)",
|
|
56
65
|
projectTrack: "rgba(255,255,255,.07)",
|
|
57
66
|
line: "#f6b73c",
|
|
58
67
|
meterAxis: "#cf9a37",
|
|
59
|
-
chipFill: "#151d2c",
|
|
60
68
|
leftAxis: "#7ea2f0",
|
|
69
|
+
cache: "#22c58f",
|
|
70
|
+
uncached: "#b0483f",
|
|
61
71
|
deltaUp: "#7fb37a",
|
|
62
|
-
deltaUpFill: "rgba(127,179,122,.14)",
|
|
63
72
|
deltaDown: "#e08a86",
|
|
64
|
-
|
|
73
|
+
warn: "#f0a35e",
|
|
65
74
|
remainderBar: "#475569",
|
|
66
75
|
onFill: "rgba(255,255,255,.82)",
|
|
67
|
-
cached: "#2ec4a1",
|
|
68
|
-
uncached: "#d88362",
|
|
69
|
-
weighted: "#c7d2e8",
|
|
70
|
-
cacheTrack: "#202a3a",
|
|
71
76
|
};
|
|
72
77
|
|
|
73
|
-
const FONT_FAMILY = "system-ui, -apple-system, 'Segoe UI', sans-serif";
|
|
74
|
-
const MONO_FAMILY = "ui-monospace, Menlo, monospace";
|
|
75
78
|
const FAST_MODE_LABEL_COLOR = "#a78bfa";
|
|
76
79
|
const MIN_BAR_WIDTH = 26;
|
|
80
|
+
const PROJECT_PANEL_ROW_COUNT = 5;
|
|
77
81
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
.replaceAll(">", ">")
|
|
83
|
-
.replaceAll('"', """)
|
|
84
|
-
.replaceAll("'", "'");
|
|
82
|
+
function pct(value) {
|
|
83
|
+
if (!Number.isFinite(value)) return "—";
|
|
84
|
+
if (value > 0 && value < 0.05) return "<0.1%";
|
|
85
|
+
return `${value.toFixed(1)}%`;
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
// Meter percentages read as whole numbers when they are whole ("0%", "62%").
|
|
89
|
+
function meterPct(value) {
|
|
88
90
|
if (!Number.isFinite(value)) return "—";
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return `${scaled.toFixed(precision)}${suffix}`;
|
|
107
|
-
}
|
|
108
|
-
return Math.round(value).toLocaleString("en-US");
|
|
91
|
+
const rounded = Math.round(value);
|
|
92
|
+
if (Math.abs(value - rounded) < 0.05) return `${rounded}%`;
|
|
93
|
+
return `${value.toFixed(1)}%`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Advance width of a letter-spaced label; SVG letter-spacing adds per glyph.
|
|
97
|
+
function spacedWidth(text, size, weight, spacing) {
|
|
98
|
+
return textWidth(text, size, weight) + (Number(spacing) || 0) * String(text).length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function deltaLabel(value) {
|
|
102
|
+
if (!Number.isFinite(value)) return "—";
|
|
103
|
+
return `${value >= 0 ? "+" : "−"}${Math.abs(value).toFixed(1)}%`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function approximateLabel(value, estimated) {
|
|
107
|
+
return estimated && value !== "—" ? `≈${value}` : value;
|
|
109
108
|
}
|
|
110
109
|
|
|
111
|
-
function
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
return
|
|
110
|
+
function durationLabel(ms) {
|
|
111
|
+
if (!Number.isFinite(ms) || ms <= 0) return "—";
|
|
112
|
+
const hours = ms / 3_600_000;
|
|
113
|
+
if (hours >= 36) return `${Math.round(ms / 86_400_000)} days`;
|
|
114
|
+
if (hours >= 21) return "1 day";
|
|
115
|
+
return `${Math.max(1, Math.round(hours))} ${Math.max(1, Math.round(hours)) === 1 ? "hour" : "hours"}`;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
function
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
function barTotalLabelPlacement({
|
|
119
|
+
centerX,
|
|
120
|
+
labelWidth,
|
|
121
|
+
slotLeft,
|
|
122
|
+
slotRight,
|
|
123
|
+
resetXs,
|
|
124
|
+
}) {
|
|
125
|
+
const resetPadding = 5;
|
|
126
|
+
const resetGap = 8;
|
|
127
|
+
const boundsFor = (x, anchor) => {
|
|
128
|
+
if (anchor === "end") return { left: x - labelWidth, right: x };
|
|
129
|
+
if (anchor === "start") return { left: x, right: x + labelWidth };
|
|
130
|
+
return { left: x - labelWidth / 2, right: x + labelWidth / 2 };
|
|
131
|
+
};
|
|
132
|
+
const isClear = ({ left, right }) =>
|
|
133
|
+
left >= slotLeft &&
|
|
134
|
+
right <= slotRight &&
|
|
135
|
+
!resetXs.some((resetX) =>
|
|
136
|
+
resetX >= left - resetPadding && resetX <= right + resetPadding
|
|
137
|
+
);
|
|
138
|
+
const centered = {
|
|
139
|
+
x: centerX,
|
|
140
|
+
anchor: "middle",
|
|
141
|
+
placement: "centered",
|
|
142
|
+
};
|
|
143
|
+
if (isClear(boundsFor(centered.x, centered.anchor))) return centered;
|
|
144
|
+
|
|
145
|
+
const candidates = [];
|
|
146
|
+
for (const resetX of resetXs) {
|
|
147
|
+
for (const candidate of [
|
|
148
|
+
{ x: resetX - resetGap, anchor: "end", placement: "reset-left" },
|
|
149
|
+
{ x: resetX + resetGap, anchor: "start", placement: "reset-right" },
|
|
150
|
+
]) {
|
|
151
|
+
const bounds = boundsFor(candidate.x, candidate.anchor);
|
|
152
|
+
if (!isClear(bounds)) continue;
|
|
153
|
+
candidates.push({
|
|
154
|
+
...candidate,
|
|
155
|
+
distance: Math.abs((bounds.left + bounds.right) / 2 - centerX),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
candidates.sort((left, right) => left.distance - right.distance);
|
|
160
|
+
if (candidates.length) {
|
|
161
|
+
return {
|
|
162
|
+
x: candidates[0].x,
|
|
163
|
+
anchor: candidates[0].anchor,
|
|
164
|
+
placement: candidates[0].placement,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return { ...centered, placement: "centered-over-reset" };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function fastRateSummary(snapshot, bounds, effectiveEndMs, events = null) {
|
|
171
|
+
let standardCardCredits = 0;
|
|
172
|
+
let fastCardCredits = 0;
|
|
173
|
+
let unratedTokens = 0;
|
|
174
|
+
const multipliers = new Set();
|
|
175
|
+
const sourceEvents = events ?? snapshot.events ?? [];
|
|
176
|
+
for (const event of sourceEvents) {
|
|
177
|
+
const timestampMs = new Date(event?.timestamp).getTime();
|
|
178
|
+
if (
|
|
179
|
+
!Number.isFinite(timestampMs) ||
|
|
180
|
+
timestampMs < bounds.start.getTime() ||
|
|
181
|
+
timestampMs >= effectiveEndMs ||
|
|
182
|
+
!isFastServiceTier(event?.serviceTier)
|
|
183
|
+
) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const tokens = Math.max(0, Number(event.totalTokens) || 0);
|
|
187
|
+
const model = event.rateCardModel ?? event.model;
|
|
188
|
+
const multiplier = codexCreditMultiplier(model, event.serviceTier);
|
|
189
|
+
const standardCredits = calculateCodexPurchasedCredits({
|
|
190
|
+
model,
|
|
191
|
+
serviceTier: null,
|
|
192
|
+
usage: event,
|
|
193
|
+
});
|
|
194
|
+
const fastCredits = calculateCodexPurchasedCredits({
|
|
195
|
+
model,
|
|
196
|
+
serviceTier: event.serviceTier,
|
|
197
|
+
usage: event,
|
|
198
|
+
});
|
|
199
|
+
if (
|
|
200
|
+
multiplier === null ||
|
|
201
|
+
!Number.isFinite(standardCredits) ||
|
|
202
|
+
!(standardCredits > 0) ||
|
|
203
|
+
!Number.isFinite(fastCredits)
|
|
204
|
+
) {
|
|
205
|
+
unratedTokens += tokens;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
standardCardCredits += standardCredits;
|
|
209
|
+
fastCardCredits += fastCredits;
|
|
210
|
+
multipliers.add(multiplier);
|
|
211
|
+
}
|
|
212
|
+
const sortedMultipliers = [...multipliers].sort((left, right) => left - right);
|
|
213
|
+
return {
|
|
214
|
+
unratedTokens,
|
|
215
|
+
effectiveMultiplier: standardCardCredits > 0
|
|
216
|
+
? fastCardCredits / standardCardCredits
|
|
217
|
+
: null,
|
|
218
|
+
minimumMultiplier: sortedMultipliers[0] ?? null,
|
|
219
|
+
maximumMultiplier: sortedMultipliers.at(-1) ?? null,
|
|
220
|
+
mixedMultipliers: sortedMultipliers.length > 1,
|
|
221
|
+
};
|
|
122
222
|
}
|
|
123
223
|
|
|
124
|
-
|
|
224
|
+
// Darker step of the same hue; retained for terminal parity and callers that
|
|
225
|
+
// still shade fast-mode swatches (the report itself uses the hatch pattern).
|
|
226
|
+
// Ceiling scale for the daily chart. Tighter than 1–2–5 so a 2.39B peak lands
|
|
227
|
+
// on a 3.00B axis instead of 5.00B.
|
|
228
|
+
const NICE_CEILING_STEPS = [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10];
|
|
229
|
+
export function reportCeiling(value) {
|
|
125
230
|
if (!(value > 0)) return 1;
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
const
|
|
231
|
+
const target = value * 1.12;
|
|
232
|
+
const magnitude = 10 ** Math.floor(Math.log10(target));
|
|
233
|
+
const normalized = target / magnitude;
|
|
234
|
+
const step =
|
|
235
|
+
NICE_CEILING_STEPS.find((candidate) => normalized <= candidate) ?? 10;
|
|
129
236
|
return step * magnitude;
|
|
130
237
|
}
|
|
131
238
|
|
|
@@ -143,62 +250,53 @@ function styleForModel(model) {
|
|
|
143
250
|
return TREND_IMAGE_MODEL_COLORS[model] ?? TREND_IMAGE_MODEL_COLORS.Other;
|
|
144
251
|
}
|
|
145
252
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
return
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function sortedModelEntries(values) {
|
|
157
|
-
return [...values.entries()]
|
|
158
|
-
.filter(([, value]) => value > 0)
|
|
159
|
-
.sort(([left], [right]) => modelSort(left, right));
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function dateParts(dateString) {
|
|
163
|
-
return dateString.split("-").map(Number);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function dateStringFromParts(year, month, day) {
|
|
167
|
-
return [year, month, day]
|
|
168
|
-
.map((value, index) =>
|
|
169
|
-
index === 0 ? String(value) : String(value).padStart(2, "0"),
|
|
170
|
-
)
|
|
171
|
-
.join("-");
|
|
253
|
+
function truncateToWidth(text, maxWidth, size, weight = 400) {
|
|
254
|
+
const value = String(text);
|
|
255
|
+
if (textWidth(value, size, weight) <= maxWidth) return value;
|
|
256
|
+
let kept = value;
|
|
257
|
+
while (kept.length > 1 && textWidth(`${kept}…`, size, weight) > maxWidth) {
|
|
258
|
+
kept = kept.slice(0, -1);
|
|
259
|
+
}
|
|
260
|
+
return `${kept.trimEnd()}…`;
|
|
172
261
|
}
|
|
173
262
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
date.getUTCDate(),
|
|
181
|
-
);
|
|
263
|
+
function fitTextSize(text, maxWidth, preferredSize, minimumSize, weight = 400) {
|
|
264
|
+
let size = preferredSize;
|
|
265
|
+
while (size > minimumSize && textWidth(text, size, weight) > maxWidth) {
|
|
266
|
+
size = Math.max(minimumSize, size - 0.25);
|
|
267
|
+
}
|
|
268
|
+
return size;
|
|
182
269
|
}
|
|
183
270
|
|
|
184
|
-
function
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
271
|
+
function svgLine(x1, y1, x2, y2, attrs = {}) {
|
|
272
|
+
const pieces = [
|
|
273
|
+
`x1="${Number(x1).toFixed(2)}"`,
|
|
274
|
+
`y1="${Number(y1).toFixed(2)}"`,
|
|
275
|
+
`x2="${Number(x2).toFixed(2)}"`,
|
|
276
|
+
`y2="${Number(y2).toFixed(2)}"`,
|
|
277
|
+
];
|
|
278
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
279
|
+
if (value === null || value === undefined) continue;
|
|
280
|
+
pieces.push(`${key}="${value}"`);
|
|
281
|
+
}
|
|
282
|
+
return `<line ${pieces.join(" ")}/>`;
|
|
195
283
|
}
|
|
196
284
|
|
|
197
|
-
function
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
285
|
+
function chip(x, y, label, { fill, stroke, color, size = 11.5, weight = 700, anchor = "start", mono = false }) {
|
|
286
|
+
const width = textWidth(label, size, weight) + 14;
|
|
287
|
+
const left = anchor === "middle" ? x - width / 2 : anchor === "end" ? x - width : x;
|
|
288
|
+
return {
|
|
289
|
+
width,
|
|
290
|
+
markup: [
|
|
291
|
+
svgRect(left, y - 13, width, 19, {
|
|
292
|
+
rx: 4,
|
|
293
|
+
fill: fill ?? "none",
|
|
294
|
+
stroke: stroke ?? null,
|
|
295
|
+
"stroke-width": stroke ? 1 : null,
|
|
296
|
+
}),
|
|
297
|
+
svgText({ x: left + 7, y: y + 1, value: label, fill: color, size, weight, mono }),
|
|
298
|
+
].join("\n"),
|
|
299
|
+
};
|
|
202
300
|
}
|
|
203
301
|
|
|
204
302
|
function localDateLabel(dateString, timeZone) {
|
|
@@ -216,1671 +314,2013 @@ function localWeekdayLabel(dateString, timeZone) {
|
|
|
216
314
|
}).format(zonedMidnight(dateString, timeZone));
|
|
217
315
|
}
|
|
218
316
|
|
|
219
|
-
function
|
|
220
|
-
if (!Number.isFinite(timestampMs)) return "unknown";
|
|
317
|
+
function shortDateTimeLabel(timestampMs, timeZone) {
|
|
318
|
+
if (!Number.isFinite(timestampMs)) return "unknown time";
|
|
221
319
|
return new Intl.DateTimeFormat("en-US", {
|
|
222
320
|
timeZone,
|
|
223
321
|
month: "short",
|
|
224
322
|
day: "numeric",
|
|
323
|
+
hour: "numeric",
|
|
324
|
+
minute: "2-digit",
|
|
325
|
+
}).format(new Date(timestampMs));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function timeOnlyLabel(timestampMs, timeZone) {
|
|
329
|
+
if (!Number.isFinite(timestampMs)) return "unknown time";
|
|
330
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
331
|
+
timeZone,
|
|
332
|
+
hour: "numeric",
|
|
333
|
+
minute: "2-digit",
|
|
225
334
|
}).format(new Date(timestampMs));
|
|
226
335
|
}
|
|
227
336
|
|
|
228
337
|
function binDateLabel(bin, timeZone) {
|
|
229
338
|
const start = localDateLabel(bin.startDateString, timeZone);
|
|
230
|
-
const lastDate =
|
|
339
|
+
const lastDate = bin.lastDateString;
|
|
231
340
|
if (lastDate === bin.startDateString) return start;
|
|
232
|
-
|
|
341
|
+
const end = localDateLabel(lastDate, timeZone);
|
|
342
|
+
if (bin.startDateString.slice(0, 4) !== lastDate.slice(0, 4)) {
|
|
343
|
+
return `${start} ${bin.startDateString.slice(0, 4)}–${end} ${lastDate.slice(0, 4)}`;
|
|
344
|
+
}
|
|
345
|
+
const [startMonth] = start.split(" ");
|
|
346
|
+
const [endMonth, endDay] = end.split(" ");
|
|
347
|
+
return startMonth === endMonth ? `${start}–${endDay}` : `${start}–${end}`;
|
|
233
348
|
}
|
|
234
349
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
for (const character of String(text)) {
|
|
240
|
-
if (/[il.,:;'|!]/.test(character)) units += 0.3;
|
|
241
|
-
else if (/[Ijtfr\-()[\] ]/.test(character)) units += 0.37;
|
|
242
|
-
else if (/[mwMW@%]/.test(character)) units += 0.92;
|
|
243
|
-
else if (/[A-Z]/.test(character)) units += 0.7;
|
|
244
|
-
else if (/[0-9+±×−]/.test(character)) units += 0.58;
|
|
245
|
-
else units += 0.55;
|
|
246
|
-
}
|
|
247
|
-
return units * size * (weight >= 700 ? 1.05 : 1);
|
|
350
|
+
function labelEvery(binCount) {
|
|
351
|
+
if (binCount <= 14) return 1;
|
|
352
|
+
if (binCount <= 20) return 2;
|
|
353
|
+
return 3;
|
|
248
354
|
}
|
|
249
355
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
356
|
+
// Keep the requested cadence, but drop a candidate when its actual label
|
|
357
|
+
// bounds would crowd the preceding label. The final bin is always retained so
|
|
358
|
+
// a partial marker and its through-time remain visible.
|
|
359
|
+
function selectDateLabelIndices(
|
|
360
|
+
bins,
|
|
361
|
+
{
|
|
362
|
+
timeZone,
|
|
363
|
+
slotWidth,
|
|
364
|
+
labelStep,
|
|
365
|
+
labelSize,
|
|
366
|
+
labelForBin = (bin) => binDateLabel(bin, timeZone),
|
|
367
|
+
gap = 8,
|
|
368
|
+
},
|
|
369
|
+
) {
|
|
370
|
+
const finalIndex = bins.length - 1;
|
|
371
|
+
if (finalIndex < 0 || !(slotWidth > 0)) return new Set();
|
|
372
|
+
const cadence = Math.max(1, Math.floor(Number(labelStep) || 1));
|
|
373
|
+
const candidates = [];
|
|
374
|
+
for (let index = 0; index < bins.length; index += 1) {
|
|
375
|
+
if (index % cadence !== 0 && index !== finalIndex) continue;
|
|
376
|
+
const width = textWidth(labelForBin(bins[index]), labelSize);
|
|
377
|
+
const center = (index + 0.5) * slotWidth;
|
|
378
|
+
candidates.push({
|
|
379
|
+
index,
|
|
380
|
+
left: center - width / 2,
|
|
381
|
+
right: center + width / 2,
|
|
382
|
+
});
|
|
257
383
|
}
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (
|
|
269
|
-
|
|
384
|
+
|
|
385
|
+
const selected = [];
|
|
386
|
+
for (const candidate of candidates) {
|
|
387
|
+
while (
|
|
388
|
+
candidate.index === finalIndex &&
|
|
389
|
+
selected.length > 0 &&
|
|
390
|
+
candidate.left - selected.at(-1).right < gap
|
|
391
|
+
) {
|
|
392
|
+
selected.pop();
|
|
393
|
+
}
|
|
394
|
+
if (
|
|
395
|
+
selected.length > 0 &&
|
|
396
|
+
candidate.left - selected.at(-1).right < gap
|
|
397
|
+
) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
selected.push(candidate);
|
|
270
401
|
}
|
|
271
|
-
return
|
|
402
|
+
return new Set(selected.map(({ index }) => index));
|
|
272
403
|
}
|
|
273
404
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
spacing = null,
|
|
283
|
-
opacity = null,
|
|
284
|
-
mono = false,
|
|
285
|
-
}) {
|
|
286
|
-
const spacingAttr = spacing ? ` letter-spacing="${spacing}"` : "";
|
|
287
|
-
const opacityAttr = opacity !== null ? ` opacity="${opacity}"` : "";
|
|
288
|
-
const family = mono ? MONO_FAMILY : FONT_FAMILY;
|
|
289
|
-
return `<text x="${x}" y="${y}" fill="${fill}" font-family="${family}" font-size="${size}px" font-weight="${weight}" text-anchor="${anchor}"${spacingAttr}${opacityAttr}>${escapeXml(value)}</text>`;
|
|
405
|
+
function hourLabel(timestampMs, timeZone, withZone = false) {
|
|
406
|
+
if (!Number.isFinite(timestampMs)) return "unknown time";
|
|
407
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
408
|
+
timeZone,
|
|
409
|
+
hour: "numeric",
|
|
410
|
+
minute: "2-digit",
|
|
411
|
+
...(withZone ? { timeZoneName: "short" } : {}),
|
|
412
|
+
}).format(new Date(timestampMs));
|
|
290
413
|
}
|
|
291
414
|
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
`height="${Math.max(0, Number(height)).toFixed(2)}"`,
|
|
298
|
-
];
|
|
299
|
-
for (const [key, value] of Object.entries(attrs)) {
|
|
300
|
-
if (value === null || value === undefined) continue;
|
|
301
|
-
pieces.push(`${key}="${value}"`);
|
|
415
|
+
function hourlyChartRows(rows, timeZone) {
|
|
416
|
+
const baseLabels = rows.map((row) => hourLabel(row.startMs, timeZone));
|
|
417
|
+
const labelCounts = new Map();
|
|
418
|
+
for (const label of baseLabels) {
|
|
419
|
+
labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
|
|
302
420
|
}
|
|
303
|
-
return
|
|
421
|
+
return rows.map((row, index) => ({
|
|
422
|
+
...row,
|
|
423
|
+
startDateString: row.dateString,
|
|
424
|
+
lastDateString: row.dateString,
|
|
425
|
+
hourStartMs: row.startMs,
|
|
426
|
+
hourEndMs: row.endMs,
|
|
427
|
+
hourLabel: labelCounts.get(baseLabels[index]) > 1
|
|
428
|
+
? hourLabel(row.startMs, timeZone, true)
|
|
429
|
+
: baseLabels[index],
|
|
430
|
+
}));
|
|
304
431
|
}
|
|
305
432
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const tau = 3 / Math.sqrt(magnitude);
|
|
338
|
-
tangent[index] = tau * alpha * slope[index];
|
|
339
|
-
tangent[index + 1] = tau * beta * slope[index];
|
|
433
|
+
export function buildBurnHourBins(trend, hourRows, startMs, endMs) {
|
|
434
|
+
const bins = hourRows.map((row) => ({
|
|
435
|
+
...row,
|
|
436
|
+
values: new Map(),
|
|
437
|
+
totalPercent: 0,
|
|
438
|
+
approximate: false,
|
|
439
|
+
}));
|
|
440
|
+
for (const interval of trend?.burnIntervals ?? []) {
|
|
441
|
+
const intervalStartMs = Number(interval.startMs);
|
|
442
|
+
const intervalEndMs = Number(interval.endMs);
|
|
443
|
+
if (!Number.isFinite(intervalStartMs) || !Number.isFinite(intervalEndMs)) continue;
|
|
444
|
+
const fullDurationMs = intervalEndMs - intervalStartMs;
|
|
445
|
+
if (!(fullDurationMs > 0)) continue;
|
|
446
|
+
const clippedStartMs = Math.max(startMs, intervalStartMs);
|
|
447
|
+
const clippedEndMs = Math.min(endMs, intervalEndMs);
|
|
448
|
+
if (!(clippedEndMs > clippedStartMs)) continue;
|
|
449
|
+
for (const bin of bins) {
|
|
450
|
+
const overlapMs = Math.min(bin.endMs, clippedEndMs) -
|
|
451
|
+
Math.max(bin.startMs, clippedStartMs);
|
|
452
|
+
if (!(overlapMs > 0)) continue;
|
|
453
|
+
// A burn interval can straddle the report window. Allocate only its
|
|
454
|
+
// in-window elapsed share; dividing by the clipped duration would
|
|
455
|
+
// incorrectly move the entire interval's drain into this chart.
|
|
456
|
+
const fraction = overlapMs / fullDurationMs;
|
|
457
|
+
if (interval.spansLongGap) bin.approximate = true;
|
|
458
|
+
for (const [model, burnPoints] of Object.entries(interval.contributions ?? {})) {
|
|
459
|
+
const share = Number(burnPoints) * fraction;
|
|
460
|
+
if (!(share > 0)) continue;
|
|
461
|
+
bin.values.set(model, (bin.values.get(model) ?? 0) + share);
|
|
462
|
+
bin.totalPercent += share;
|
|
463
|
+
}
|
|
340
464
|
}
|
|
341
465
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
466
|
+
const totals = new Map();
|
|
467
|
+
let totalPercent = 0;
|
|
468
|
+
for (const bin of bins) {
|
|
469
|
+
for (const [model, value] of bin.values) {
|
|
470
|
+
totals.set(model, (totals.get(model) ?? 0) + value);
|
|
471
|
+
totalPercent += value;
|
|
472
|
+
}
|
|
348
473
|
}
|
|
349
|
-
return
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
474
|
+
return {
|
|
475
|
+
bins,
|
|
476
|
+
totals,
|
|
477
|
+
totalPercent,
|
|
478
|
+
binSize: 1,
|
|
479
|
+
binCount: bins.length,
|
|
480
|
+
};
|
|
356
481
|
}
|
|
357
482
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
483
|
+
// Merge the view model's per-day rows into multi-day bins for narrow layouts.
|
|
484
|
+
function binDailyRows(daily, binSize) {
|
|
485
|
+
const bins = [];
|
|
486
|
+
for (let index = 0; index < daily.length; index += binSize) {
|
|
487
|
+
const rows = daily.slice(index, index + binSize);
|
|
488
|
+
const models = new Map();
|
|
489
|
+
for (const row of rows) {
|
|
490
|
+
for (const dayModel of row.models) {
|
|
491
|
+
const merged = models.get(dayModel.model) ?? {
|
|
492
|
+
model: dayModel.model,
|
|
493
|
+
totalTokens: 0,
|
|
494
|
+
normalTokens: 0,
|
|
495
|
+
fastTokens: 0,
|
|
496
|
+
unknownTokens: 0,
|
|
497
|
+
estimated: false,
|
|
498
|
+
};
|
|
499
|
+
merged.totalTokens += dayModel.totalTokens;
|
|
500
|
+
merged.normalTokens += dayModel.normalTokens;
|
|
501
|
+
merged.fastTokens += dayModel.fastTokens;
|
|
502
|
+
merged.unknownTokens += dayModel.unknownTokens;
|
|
503
|
+
merged.estimated ||= dayModel.estimated === true;
|
|
504
|
+
models.set(dayModel.model, merged);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
bins.push({
|
|
508
|
+
startDateString: rows[0].dateString,
|
|
509
|
+
lastDateString: rows.at(-1).dateString,
|
|
510
|
+
totalTokens: rows.reduce((sum, row) => sum + row.totalTokens, 0),
|
|
511
|
+
inputTokens: rows.reduce((sum, row) => sum + row.inputTokens, 0),
|
|
512
|
+
cachedInputTokens: rows.reduce(
|
|
513
|
+
(sum, row) => sum + row.cachedInputTokens,
|
|
514
|
+
0,
|
|
515
|
+
),
|
|
516
|
+
modelCalls: rows.reduce((sum, row) => sum + row.modelCalls, 0),
|
|
517
|
+
estimated: rows.some((row) => row.estimated),
|
|
518
|
+
partial: rows.some((row) => row.partial),
|
|
519
|
+
unobserved: rows.every((row) => row.observed === false),
|
|
520
|
+
models: [...models.values()].sort(
|
|
521
|
+
(left, right) => modelSort(left.model, right.model),
|
|
522
|
+
),
|
|
523
|
+
});
|
|
371
524
|
}
|
|
372
|
-
return
|
|
373
|
-
.map(([project, totalTokens]) => ({
|
|
374
|
-
project,
|
|
375
|
-
displayProject: project,
|
|
376
|
-
totalTokens,
|
|
377
|
-
}))
|
|
378
|
-
.sort((left, right) => right.totalTokens - left.totalTokens);
|
|
525
|
+
return bins;
|
|
379
526
|
}
|
|
380
527
|
|
|
381
528
|
export function renderTrendImage({
|
|
382
529
|
snapshot,
|
|
383
530
|
bounds,
|
|
384
|
-
trend =
|
|
531
|
+
trend = null,
|
|
385
532
|
days = bounds.rangeDays ?? 7,
|
|
386
533
|
options = {},
|
|
387
534
|
projectRows = null,
|
|
535
|
+
viewModel = null,
|
|
536
|
+
reportTimeMs = null,
|
|
537
|
+
sourceStatus = null,
|
|
538
|
+
analysis = null,
|
|
539
|
+
reportEvents = null,
|
|
388
540
|
}) {
|
|
389
541
|
const width = Math.max(900, Math.min(2_400, Number(options.imageWidth) || 1_280));
|
|
390
|
-
const outer =
|
|
391
|
-
const plotLeft = 96;
|
|
392
|
-
const plotRight = width - 96;
|
|
393
|
-
const plotWidth = plotRight - plotLeft;
|
|
542
|
+
const outer = 28;
|
|
394
543
|
const contentRight = width - outer;
|
|
395
544
|
const contentWidth = width - outer * 2;
|
|
545
|
+
const wide = width >= 1_100;
|
|
396
546
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
const percentMode = Boolean(options.drain) && meterUsable;
|
|
406
|
-
const bars = percentMode ? burn.bins : actual.bins;
|
|
407
|
-
const binCount = actual.binCount;
|
|
408
|
-
const binTotalOf = (bin) => (percentMode ? bin.totalPercent : bin.totalTokens);
|
|
409
|
-
const maxBar = niceCeiling(
|
|
410
|
-
bars.reduce((maximum, bin) => Math.max(maximum, binTotalOf(bin)), 0),
|
|
411
|
-
);
|
|
412
|
-
const hasLine = Boolean(trend.available && (trend.points ?? []).length > 1);
|
|
413
|
-
|
|
414
|
-
const totalTokens = [...actual.totals.values()].reduce((sum, value) => sum + value, 0);
|
|
415
|
-
const fastTokens = [...(actual.fastTotals?.values() ?? [])].reduce(
|
|
416
|
-
(sum, value) => sum + value,
|
|
417
|
-
0,
|
|
418
|
-
);
|
|
419
|
-
const hasFast = !percentMode && fastTokens > 0;
|
|
420
|
-
|
|
421
|
-
const modelCards = [...actual.totals.entries()]
|
|
422
|
-
.filter(([, value]) => value > 0 && totalTokens > 0 && value / totalTokens >= 0.01)
|
|
423
|
-
.sort((left, right) => right[1] - left[1])
|
|
424
|
-
.slice(0, 3)
|
|
425
|
-
.map(([model, value]) => ({ model, tokens: value }));
|
|
426
|
-
|
|
427
|
-
// Prior-period per-model totals feed the delta chips.
|
|
428
|
-
const priorBounds = {
|
|
429
|
-
...bounds,
|
|
430
|
-
startDateString: shiftCalendarDate(bounds.startDateString, -days),
|
|
431
|
-
endDateString: shiftCalendarDate(bounds.endDateString, -days),
|
|
432
|
-
start: zonedMidnight(
|
|
433
|
-
shiftCalendarDate(bounds.startDateString, -days),
|
|
434
|
-
bounds.timeZone,
|
|
435
|
-
),
|
|
436
|
-
end: bounds.start,
|
|
437
|
-
};
|
|
438
|
-
const priorTotals = buildActualTokenBins(snapshot, priorBounds, days, plotWidth, {
|
|
439
|
-
minBinWidth: MIN_BAR_WIDTH,
|
|
440
|
-
preferDaily: true,
|
|
441
|
-
}).totals;
|
|
442
|
-
|
|
443
|
-
const latestQuotaPoint = [...(trend.points ?? [])]
|
|
444
|
-
.filter((point) => point.timestampMs <= bounds.end.getTime())
|
|
445
|
-
.at(-1);
|
|
446
|
-
const latestQuotaReadMs = Number.isFinite(trend.observedThroughMs)
|
|
447
|
-
? trend.observedThroughMs
|
|
448
|
-
: null;
|
|
449
|
-
const resetsInRange = trend.resets ?? [];
|
|
450
|
-
const weeklyObservationsAll = weeklyQuotaObservations(snapshot).filter(
|
|
451
|
-
(observation) => observation.timestampMs < bounds.end.getTime(),
|
|
452
|
-
);
|
|
453
|
-
const latestResetsAtSec = weeklyObservationsAll.at(-1)?.resetsAt ?? null;
|
|
454
|
-
|
|
455
|
-
const rows = projectRows ?? fallbackProjectRows(snapshot, bounds);
|
|
456
|
-
|
|
457
|
-
// Cache bins share the trend chart's bin size so both charts' columns stay
|
|
458
|
-
// vertically aligned.
|
|
459
|
-
const cacheData = buildCacheReportData(
|
|
547
|
+
const effectiveReportTimeMs = Number.isFinite(reportTimeMs)
|
|
548
|
+
? reportTimeMs
|
|
549
|
+
: Number.isFinite(options.reportTimeMs)
|
|
550
|
+
? options.reportTimeMs
|
|
551
|
+
: null;
|
|
552
|
+
const effectiveSourceStatus = sourceStatus ?? options.sourceStatus ??
|
|
553
|
+
"verified-current";
|
|
554
|
+
const vm = viewModel ?? buildTrendReportViewModel({
|
|
460
555
|
snapshot,
|
|
461
556
|
bounds,
|
|
462
557
|
days,
|
|
463
|
-
|
|
464
|
-
|
|
558
|
+
reportTimeMs: effectiveReportTimeMs,
|
|
559
|
+
sourceStatus: effectiveSourceStatus,
|
|
560
|
+
projectRows,
|
|
561
|
+
events: reportEvents ?? analysis?.currentEvents ?? null,
|
|
562
|
+
priorEvents: analysis?.priorEvents ?? null,
|
|
563
|
+
});
|
|
564
|
+
const { summary, meter, meta } = vm;
|
|
565
|
+
const timeZone = meta.timeZone;
|
|
566
|
+
const stale = meta.sourceStatus === "stale-fallback";
|
|
567
|
+
const verified = meta.sourceStatus === "verified-current";
|
|
568
|
+
const fastRates = fastRateSummary(
|
|
569
|
+
snapshot,
|
|
570
|
+
bounds,
|
|
571
|
+
meta.effectiveEndMs,
|
|
572
|
+
reportEvents ?? analysis?.currentEvents,
|
|
465
573
|
);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
const rest = models.slice(3);
|
|
472
|
-
const restInput = rest.reduce((sum, model) => sum + model.inputTokens, 0);
|
|
473
|
-
const restCached = rest.reduce(
|
|
474
|
-
(sum, model) => sum + model.cachedInputTokens,
|
|
475
|
-
0,
|
|
476
|
-
);
|
|
477
|
-
return [...models.slice(0, 3), {
|
|
478
|
-
model: `${rest.length} other models`,
|
|
479
|
-
inputTokens: restInput,
|
|
480
|
-
cachedInputTokens: restCached,
|
|
481
|
-
rate: restInput > 0 ? (restCached / restInput) * 100 : null,
|
|
482
|
-
muted: true,
|
|
483
|
-
}];
|
|
484
|
-
})();
|
|
485
|
-
|
|
486
|
-
// ---- Pace & runway (computed early: its height shapes the top row) ----
|
|
487
|
-
const generatedAtMs = new Date(snapshot.generatedAt).getTime();
|
|
488
|
-
const paceLines = [];
|
|
489
|
-
let paceNote = null;
|
|
490
|
-
let paceRunwayBar = null;
|
|
491
|
-
const dailyAverage = totalTokens / Math.max(1, days);
|
|
492
|
-
if (hasLine && meterUsable && latestQuotaPoint && totalTokens > 0) {
|
|
493
|
-
const tokensPerPercent = totalTokens / burn.totalPercent;
|
|
494
|
-
const burnPerDay = dailyAverage / tokensPerPercent;
|
|
495
|
-
const runwayDays = burnPerDay > 0
|
|
496
|
-
? latestQuotaPoint.remainingPercent / burnPerDay
|
|
497
|
-
: null;
|
|
498
|
-
if (runwayDays !== null) {
|
|
499
|
-
paceLines.push({
|
|
500
|
-
value: `${runwayDays.toFixed(1)} days`,
|
|
501
|
-
size: 23,
|
|
502
|
-
weight: 800,
|
|
503
|
-
color: COLORS.line,
|
|
504
|
-
detail: "of meter left at this pace",
|
|
505
|
-
});
|
|
506
|
-
}
|
|
507
|
-
paceLines.push({
|
|
508
|
-
value: `${compact(dailyAverage)} / day`,
|
|
509
|
-
size: 17,
|
|
510
|
-
weight: 700,
|
|
511
|
-
color: COLORS.ink,
|
|
512
|
-
detail: `${days}-day average · ${burnPerDay.toFixed(1)}% of meter`,
|
|
513
|
-
});
|
|
514
|
-
paceLines.push({
|
|
515
|
-
value: `${compact(tokensPerPercent)} / 1%`,
|
|
516
|
-
size: 17,
|
|
517
|
-
weight: 700,
|
|
518
|
-
color: COLORS.ink,
|
|
519
|
-
detail: "tokens per meter point",
|
|
520
|
-
});
|
|
521
|
-
const daysToReset = latestResetsAtSec !== null && Number.isFinite(generatedAtMs)
|
|
522
|
-
? (latestResetsAtSec * 1_000 - generatedAtMs) / 86_400_000
|
|
523
|
-
: null;
|
|
524
|
-
if (runwayDays !== null && daysToReset !== null && daysToReset > 0) {
|
|
525
|
-
const resetIn = Math.max(1, Math.round(daysToReset));
|
|
526
|
-
const resetInLabel = `${resetIn} ${resetIn === 1 ? "day" : "days"}`;
|
|
527
|
-
paceRunwayBar = { runwayDays, daysToReset, resetInLabel };
|
|
528
|
-
const gap = runwayDays - daysToReset;
|
|
529
|
-
if (Math.abs(gap) <= 1.5) {
|
|
530
|
-
paceNote = `Next weekly reset in ${resetInLabel}. Current pace lands within ~${Math.max(1, Math.round(Math.abs(gap)))} day of it.`;
|
|
531
|
-
} else if (gap > 0) {
|
|
532
|
-
paceNote = `Next weekly reset in ${resetInLabel}. Current pace leaves ~${Math.round(gap)} days of headroom past it.`;
|
|
533
|
-
} else {
|
|
534
|
-
paceNote = `Next weekly reset in ${resetInLabel}. Current pace runs the meter out ~${Math.round(-gap)} days before it.`;
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
} else {
|
|
538
|
-
paceLines.push({
|
|
539
|
-
value: `${compact(dailyAverage)} / day`,
|
|
540
|
-
size: 17,
|
|
541
|
-
weight: 700,
|
|
542
|
-
color: COLORS.ink,
|
|
543
|
-
detail: `${days}-day average`,
|
|
544
|
-
});
|
|
545
|
-
paceNote = "No usable weekly meter drain in this range, so runway cannot be estimated.";
|
|
546
|
-
}
|
|
574
|
+
// Drain mode swaps the main chart to observed meter-drain columns; every
|
|
575
|
+
// other panel keeps actual-token semantics.
|
|
576
|
+
const drainTrend = options.drain
|
|
577
|
+
? trend ?? buildUsageTrend(snapshot, bounds)
|
|
578
|
+
: null;
|
|
547
579
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
for (const word of paceNote.split(" ")) {
|
|
570
|
-
const candidate = current ? `${current} ${word}` : word;
|
|
571
|
-
if (textWidth(candidate, 11.5) > paceInnerWidth && current) {
|
|
572
|
-
paceNoteLines.push(current);
|
|
573
|
-
current = word;
|
|
574
|
-
} else {
|
|
575
|
-
current = candidate;
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
if (current) paceNoteLines.push(current);
|
|
579
|
-
}
|
|
580
|
-
// Baseline offsets inside the unified panel: meter headline, optional
|
|
581
|
-
// runway timeline, the two-column stat pair, then the note.
|
|
582
|
-
const paceHeadlineBaseline = hasMeterCard ? 58 : 54;
|
|
583
|
-
const paceStatValueBaseline = paceHeadlineBaseline + (paceRunwayBar ? 62 : 34);
|
|
584
|
-
const paceStatsPresent = paceLines.length > (hasMeterCard ? 0 : 1);
|
|
585
|
-
const paceNoteStart =
|
|
586
|
-
(paceStatsPresent ? paceStatValueBaseline + 16 : paceHeadlineBaseline) + 24;
|
|
587
|
-
const topRowHeight = Math.max(
|
|
588
|
-
hasMeterCard ? 170 : 150,
|
|
589
|
-
paceNoteStart + (paceNoteLines.length - 1) * 16 + 14,
|
|
590
|
-
);
|
|
591
|
-
const chartBlockTop = cardTop + topRowHeight + 16;
|
|
592
|
-
const plotTop = chartBlockTop + 40;
|
|
593
|
-
const plotHeight = 430;
|
|
594
|
-
const plotBottom = plotTop + plotHeight;
|
|
595
|
-
const chartBlockBottom = plotBottom + 70;
|
|
596
|
-
const legendBaseline = chartBlockBottom + 25;
|
|
597
|
-
const cacheRuleY = legendBaseline + 23;
|
|
598
|
-
const cacheHeaderBaseline = cacheRuleY + 27;
|
|
599
|
-
const cachePlotTop = cacheHeaderBaseline + 34;
|
|
600
|
-
const cachePlotHeight = 128;
|
|
601
|
-
const cachePlotBottom = hasCache
|
|
602
|
-
? cachePlotTop + cachePlotHeight
|
|
603
|
-
: cacheHeaderBaseline + 26;
|
|
604
|
-
const bottomRuleY = cachePlotBottom + 30;
|
|
605
|
-
const bottomTop = bottomRuleY + 20;
|
|
606
|
-
const projectRowCount = Math.min(4, rows.length > 3 ? 4 : rows.length);
|
|
607
|
-
const bottomBlockHeight = Math.max(
|
|
608
|
-
29 + projectRowCount * 29,
|
|
609
|
-
29 + Math.max(1, cacheModelRows.length) * 30,
|
|
610
|
-
120,
|
|
611
|
-
);
|
|
612
|
-
const height = bottomTop + bottomBlockHeight + 34;
|
|
613
|
-
|
|
614
|
-
const yearLabel = bounds.endDateString.slice(0, 4);
|
|
615
|
-
const title = percentMode
|
|
616
|
-
? `TOKEN LEDGER · ${days}-DAY METER DRAIN`
|
|
617
|
-
: `TOKEN LEDGER · ${days}-DAY TREND`;
|
|
618
|
-
const subtitle = `${localDateLabel(bounds.startDateString, bounds.timeZone)} – ${localDateLabel(bounds.endDateString, bounds.timeZone)}, ${yearLabel} · ${bounds.timeZone}`;
|
|
619
|
-
const description = percentMode
|
|
620
|
-
? "Dark report card: compact actual-token stat cards beside pace and runway, stacked columns of observed weekly-meter drain with an explicitly estimated per-model split, the observed weekly meter remaining as an amber line with continuous reset boundaries, a compressed cache-rate-by-period strip, and top projects beside per-model cache rates."
|
|
621
|
-
: "Dark report card: compact model stat cards with week-over-week delta chips beside pace and runway, stacked columns of local token volume by model with fast-mode usage in a darker shade, the observed weekly meter remaining as a smoothed amber line with reset breaks and callout pills, a compressed cache-rate-by-period strip, and top projects beside per-model cache rates.";
|
|
622
|
-
|
|
623
|
-
const elements = [
|
|
624
|
-
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="trend-title trend-description" data-report-mode="${percentMode ? "meter-drain" : "actual-tokens"}">`,
|
|
625
|
-
`<title id="trend-title">${escapeXml(percentMode ? `Token Ledger · ${days}-day meter drain` : `Token Ledger · ${days}-day trend`)}</title>`,
|
|
626
|
-
`<desc id="trend-description">${escapeXml(description)}</desc>`,
|
|
627
|
-
`<defs><clipPath id="trend-plot-clip"><rect x="${plotLeft}" y="${plotTop}" width="${plotWidth}" height="${plotHeight}"/></clipPath></defs>`,
|
|
628
|
-
`<rect width="100%" height="100%" fill="${COLORS.background}"/>`,
|
|
629
|
-
svgText({
|
|
580
|
+
const elements = [];
|
|
581
|
+
const defs = [
|
|
582
|
+
"<defs>",
|
|
583
|
+
// Fast-mode tokens keep the model color and add this hatch so they stay
|
|
584
|
+
// visible in grayscale without inventing extra bar height.
|
|
585
|
+
'<pattern id="fast-mode-hatch" patternUnits="userSpaceOnUse" width="7" height="7" patternTransform="rotate(45)">',
|
|
586
|
+
'<line x1="0" y1="0" x2="0" y2="7" stroke="rgba(14,20,32,.48)" stroke-width="1.6"/>',
|
|
587
|
+
"</pattern>",
|
|
588
|
+
"</defs>",
|
|
589
|
+
].join("\n");
|
|
590
|
+
|
|
591
|
+
// ---------------------------------------------------------------- header
|
|
592
|
+
function buildHeaderSection() {
|
|
593
|
+
const yearLabel = meta.endDateString.slice(0, 4);
|
|
594
|
+
const title = `TOKEN LEDGER · ${meta.rangeDays}-DAY TREND`;
|
|
595
|
+
const subtitle = [
|
|
596
|
+
`${localDateLabel(meta.startDateString, timeZone)} – ${localDateLabel(meta.endDateString, timeZone)}, ${yearLabel}`,
|
|
597
|
+
timeZone,
|
|
598
|
+
vm.provenance.historyScope,
|
|
599
|
+
].filter(Boolean).join(" · ");
|
|
600
|
+
elements.push(svgText({
|
|
630
601
|
x: outer,
|
|
631
|
-
y:
|
|
602
|
+
y: 46,
|
|
632
603
|
value: title,
|
|
633
|
-
size:
|
|
604
|
+
size: 26,
|
|
634
605
|
weight: 800,
|
|
635
|
-
spacing: "-0.
|
|
636
|
-
})
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
606
|
+
spacing: "-0.26",
|
|
607
|
+
}));
|
|
608
|
+
// The subtitle sits beside the title when the right-hand provenance block
|
|
609
|
+
// leaves room; otherwise it wraps beneath the title.
|
|
610
|
+
const subtitleX = outer + textWidth(title, 26, 800) + 18;
|
|
611
|
+
const subtitleInline =
|
|
612
|
+
subtitleX + textWidth(subtitle, 13.5) < contentRight - 270;
|
|
613
|
+
elements.push(svgText({
|
|
614
|
+
x: subtitleInline ? subtitleX : outer,
|
|
615
|
+
y: subtitleInline ? 46 : 68,
|
|
640
616
|
value: subtitle,
|
|
641
617
|
fill: COLORS.muted,
|
|
642
|
-
size:
|
|
618
|
+
size: 13.5,
|
|
619
|
+
}));
|
|
620
|
+
|
|
621
|
+
const generatedLabel = shortDateTimeLabel(
|
|
622
|
+
vm.provenance.snapshotGeneratedAtMs,
|
|
623
|
+
timeZone,
|
|
624
|
+
);
|
|
625
|
+
const throughLine = verified
|
|
626
|
+
? `Report through ${shortDateTimeLabel(meta.reportThroughMs, timeZone)}`
|
|
627
|
+
: `Snapshot generated ${generatedLabel}`;
|
|
628
|
+
elements.push(svgText({
|
|
629
|
+
x: contentRight,
|
|
630
|
+
y: 34,
|
|
631
|
+
value: throughLine,
|
|
632
|
+
fill: COLORS.secondary,
|
|
633
|
+
size: 12.5,
|
|
643
634
|
anchor: "end",
|
|
644
|
-
})
|
|
645
|
-
|
|
635
|
+
}));
|
|
636
|
+
elements.push(svgText({
|
|
637
|
+
x: contentRight,
|
|
638
|
+
y: 54,
|
|
639
|
+
value: meter.lastObservedAtMs !== null
|
|
640
|
+
? `Meter last observed ${shortDateTimeLabel(meter.lastObservedAtMs, timeZone)}`
|
|
641
|
+
: "No weekly meter observation",
|
|
642
|
+
fill: COLORS.muted,
|
|
643
|
+
size: 12.5,
|
|
644
|
+
anchor: "end",
|
|
645
|
+
}));
|
|
646
|
+
return 82;
|
|
647
|
+
}
|
|
646
648
|
|
|
647
|
-
//
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
for (const { model, tokens } of modelCards) {
|
|
651
|
-
const share = totalTokens > 0 ? (tokens / totalTokens) * 100 : 0;
|
|
652
|
-
const priorValue = priorTotals.get(model) ?? 0;
|
|
653
|
-
let chip = null;
|
|
654
|
-
if (priorValue >= 1_000_000) {
|
|
655
|
-
const ratio = tokens / priorValue;
|
|
656
|
-
const delta = (ratio - 1) * 100;
|
|
657
|
-
chip = {
|
|
658
|
-
text: ratio >= 5
|
|
659
|
-
? `${ratio.toFixed(1)}×`
|
|
660
|
-
: `${delta >= 0 ? "+" : "−"}${Math.abs(delta).toFixed(1)}%`,
|
|
661
|
-
color: delta >= 0 ? COLORS.deltaUp : COLORS.deltaDown,
|
|
662
|
-
fill: delta >= 0 ? COLORS.deltaUpFill : COLORS.deltaDownFill,
|
|
663
|
-
};
|
|
664
|
-
}
|
|
649
|
+
// -------------------------------------------------------------- KPI cards
|
|
650
|
+
function compactCards() {
|
|
651
|
+
const cards = [];
|
|
665
652
|
cards.push({
|
|
666
|
-
|
|
667
|
-
label:
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
653
|
+
accent: COLORS.leftAxis,
|
|
654
|
+
label: "TOTAL USAGE",
|
|
655
|
+
value: approximateLabel(compact(summary.totalTokens), summary.estimated),
|
|
656
|
+
unit: "tokens",
|
|
657
|
+
sub: summary.totalDeltaPercent !== null
|
|
658
|
+
? {
|
|
659
|
+
text: approximateLabel(
|
|
660
|
+
deltaLabel(summary.totalDeltaPercent),
|
|
661
|
+
summary.totalDeltaEstimated,
|
|
662
|
+
),
|
|
663
|
+
color: summary.totalDeltaPercent >= 0 ? COLORS.deltaUp : COLORS.deltaDown,
|
|
664
|
+
weight: 700,
|
|
665
|
+
}
|
|
666
|
+
: { text: "no prior-period baseline", color: COLORS.muted },
|
|
667
|
+
caption: summary.totalDeltaPercent !== null
|
|
668
|
+
? "vs prior equivalent period"
|
|
669
|
+
: null,
|
|
670
|
+
sparkline: vm.daily
|
|
671
|
+
.filter((row) => row.observed !== false)
|
|
672
|
+
.map((row) => row.totalTokens),
|
|
680
673
|
});
|
|
681
|
-
|
|
682
|
-
if (hasFast) {
|
|
683
|
-
const fastShare = totalTokens > 0 ? (fastTokens / totalTokens) * 100 : 0;
|
|
674
|
+
const cacheKnown = summary.inputTokens > 0;
|
|
684
675
|
cards.push({
|
|
685
|
-
|
|
686
|
-
label: "
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
676
|
+
accent: COLORS.cache,
|
|
677
|
+
label: "CACHE EFFICIENCY",
|
|
678
|
+
value: cacheKnown
|
|
679
|
+
? approximateLabel(pct(summary.cacheRatePercent), summary.estimated)
|
|
680
|
+
: "—",
|
|
681
|
+
unit: cacheKnown ? "input-weighted" : null,
|
|
682
|
+
sub: cacheKnown
|
|
683
|
+
? {
|
|
684
|
+
text: `${approximateLabel(compact(summary.cachedInputTokens), summary.estimated)} of ${approximateLabel(compact(summary.inputTokens), summary.estimated)} input cached`,
|
|
685
|
+
color: COLORS.secondary,
|
|
686
|
+
}
|
|
687
|
+
: { text: "No measured input-token breakdown", color: COLORS.muted },
|
|
688
|
+
bar: cacheKnown
|
|
689
|
+
? { fraction: summary.cacheRatePercent / 100, fill: COLORS.cache }
|
|
690
|
+
: null,
|
|
699
691
|
});
|
|
700
|
-
|
|
701
|
-
|
|
692
|
+
const hasFast = summary.fastTokens > 0;
|
|
693
|
+
const hasUnknownTier = summary.unknownTokens > 0;
|
|
694
|
+
const confirmedFastShare = approximateLabel(
|
|
695
|
+
pct(summary.fastSharePercent),
|
|
696
|
+
summary.estimated,
|
|
697
|
+
);
|
|
698
|
+
const fastRateShare = hasFast &&
|
|
699
|
+
fastRates.effectiveMultiplier !== null &&
|
|
700
|
+
fastRates.unratedTokens === 0
|
|
701
|
+
? ` · ${fastRates.effectiveMultiplier.toFixed(2)}× avg`
|
|
702
|
+
: "";
|
|
703
|
+
const unknownTierCaption = hasUnknownTier
|
|
704
|
+
? `${approximateLabel(pct(summary.unknownSharePercent), summary.estimated)} unknown${
|
|
705
|
+
hasFast ? " · hatch=fast" : ""
|
|
706
|
+
}`
|
|
707
|
+
: null;
|
|
702
708
|
cards.push({
|
|
703
|
-
|
|
704
|
-
label: "
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
709
|
+
accent: FAST_MODE_LABEL_COLOR,
|
|
710
|
+
label: "FAST MODE USAGE",
|
|
711
|
+
value: approximateLabel(compact(summary.fastTokens), summary.fastEstimated),
|
|
712
|
+
unit: "tokens",
|
|
713
|
+
sub: {
|
|
714
|
+
text: `${confirmedFastShare} confirmed fast`,
|
|
715
|
+
optionalSuffix: fastRateShare,
|
|
716
|
+
color: hasFast || hasUnknownTier ? COLORS.secondary : COLORS.muted,
|
|
717
|
+
},
|
|
718
|
+
bar: hasFast
|
|
719
|
+
? { fraction: summary.fastSharePercent / 100, fill: FAST_MODE_LABEL_COLOR }
|
|
720
|
+
: null,
|
|
721
|
+
caption: unknownTierCaption ?? (hasFast
|
|
722
|
+
? fastRates.effectiveMultiplier === null
|
|
723
|
+
? "Fast credit rate: UNRATED"
|
|
724
|
+
: fastRates.unratedTokens > 0
|
|
725
|
+
? "Some fast usage is unrated"
|
|
726
|
+
: "Hatched = confirmed fast"
|
|
727
|
+
: null),
|
|
717
728
|
});
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
:
|
|
724
|
-
?
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
valueColor: COLORS.line,
|
|
732
|
-
chip: null,
|
|
733
|
-
suffix: "remaining",
|
|
734
|
-
track: "rgba(246,183,60,.2)",
|
|
735
|
-
fill: COLORS.line,
|
|
736
|
-
barPercent: latestQuotaPoint.remainingPercent,
|
|
737
|
-
caption: latestQuotaReadMs === null
|
|
738
|
-
? resetCaption
|
|
739
|
-
: `${resetCaption} · read ${timestampDateLabel(latestQuotaReadMs, bounds.timeZone)}`,
|
|
740
|
-
captionShort: resetCaption,
|
|
741
|
-
panel: COLORS.meterPanel,
|
|
742
|
-
border: COLORS.meterPanelBorder,
|
|
729
|
+
cards.push({
|
|
730
|
+
accent: COLORS.secondary,
|
|
731
|
+
label: "PROJECTS",
|
|
732
|
+
value: String(summary.activeProjects),
|
|
733
|
+
unit: "active",
|
|
734
|
+
sub: summary.topFourProjectSharePercent !== null
|
|
735
|
+
? {
|
|
736
|
+
text: `Top ${Math.min(4, vm.projects.length)} = ${approximateLabel(pct(summary.topFourProjectSharePercent), summary.estimated)}`,
|
|
737
|
+
color: COLORS.secondary,
|
|
738
|
+
}
|
|
739
|
+
: { text: "no project activity", color: COLORS.muted },
|
|
740
|
+
histogram: [...vm.projects.map((row) => row.sharePercent),
|
|
741
|
+
vm.projectRemainder.sharePercent].filter((share) => share > 0),
|
|
743
742
|
});
|
|
743
|
+
return cards;
|
|
744
744
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
fill:
|
|
745
|
+
|
|
746
|
+
function drawCompactCard(card, x, y, cardWidth, cardHeight) {
|
|
747
|
+
elements.push(svgText({
|
|
748
|
+
x: x + 16,
|
|
749
|
+
y: y + 25,
|
|
750
|
+
value: card.label,
|
|
751
|
+
fill: card.accent,
|
|
752
|
+
size: 12,
|
|
753
|
+
weight: 600,
|
|
754
|
+
spacing: "1.08",
|
|
752
755
|
}));
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
756
|
+
const valueBaseline = y + 62;
|
|
757
|
+
const innerWidth = cardWidth - 32;
|
|
758
|
+
const valueSize = 30;
|
|
759
|
+
const unitSize = 12.5;
|
|
760
|
+
const inlineUnitGap = 11;
|
|
761
|
+
const valueWidth = textWidth(card.value, valueSize, 800);
|
|
762
|
+
const unitInline = !card.unit ||
|
|
763
|
+
valueWidth + inlineUnitGap + textWidth(card.unit, unitSize) <= innerWidth;
|
|
764
|
+
elements.push(svgText({
|
|
765
|
+
x: x + 16,
|
|
766
|
+
y: valueBaseline,
|
|
767
|
+
value: card.value,
|
|
768
|
+
size: valueSize,
|
|
769
|
+
weight: 800,
|
|
770
|
+
spacing: "-0.6",
|
|
771
|
+
}));
|
|
772
|
+
if (card.unit) {
|
|
773
|
+
const placement = unitInline ? "inline" : "stacked";
|
|
774
|
+
elements.push(`<g data-role="kpi-unit" data-placement="${placement}">`);
|
|
771
775
|
elements.push(svgText({
|
|
772
|
-
x:
|
|
773
|
-
y:
|
|
774
|
-
value:
|
|
775
|
-
fill:
|
|
776
|
-
size:
|
|
777
|
-
spacing: ".9",
|
|
776
|
+
x: unitInline ? x + 16 + valueWidth + inlineUnitGap : x + 16,
|
|
777
|
+
y: unitInline ? valueBaseline : y + 81,
|
|
778
|
+
value: card.unit,
|
|
779
|
+
fill: COLORS.muted,
|
|
780
|
+
size: unitInline ? unitSize : 11.5,
|
|
778
781
|
}));
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
value: card.chip.text,
|
|
797
|
-
fill: card.chip.color,
|
|
798
|
-
size: 10,
|
|
799
|
-
weight: 700,
|
|
800
|
-
}));
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
const valueBaseline = cellY + cellHeight / 2 + 9;
|
|
782
|
+
elements.push("</g>");
|
|
783
|
+
}
|
|
784
|
+
if (card.sub) {
|
|
785
|
+
const subWidth = innerWidth;
|
|
786
|
+
const fullSub = card.sub.text + (card.sub.optionalSuffix || "");
|
|
787
|
+
const subText = textWidth(fullSub, 11.5, card.sub.weight ?? 400) <= subWidth
|
|
788
|
+
? fullSub
|
|
789
|
+
: card.sub.text;
|
|
790
|
+
const subSize = fitTextSize(
|
|
791
|
+
subText,
|
|
792
|
+
subWidth,
|
|
793
|
+
12.5,
|
|
794
|
+
11.5,
|
|
795
|
+
card.sub.weight ?? 400,
|
|
796
|
+
);
|
|
797
|
+
const subBaseline = unitInline ? y + 88 : y + 96;
|
|
798
|
+
elements.push(`<g data-role="kpi-sub" data-placement="${unitInline ? "inline" : "stacked"}" data-baseline="${subBaseline}">`);
|
|
804
799
|
elements.push(svgText({
|
|
805
|
-
x:
|
|
806
|
-
y:
|
|
807
|
-
value:
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
800
|
+
x: x + 16,
|
|
801
|
+
y: subBaseline,
|
|
802
|
+
value: truncateToWidth(
|
|
803
|
+
subText,
|
|
804
|
+
subWidth,
|
|
805
|
+
subSize,
|
|
806
|
+
card.sub.weight ?? 400,
|
|
807
|
+
),
|
|
808
|
+
fill: card.sub.color,
|
|
809
|
+
size: subSize,
|
|
810
|
+
weight: card.sub.weight ?? 400,
|
|
812
811
|
}));
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
const captionAvail = innerRight - valueEnd - 16;
|
|
825
|
-
const caption = card.captionShort && textWidth(card.caption, 11) > captionAvail
|
|
826
|
-
? card.captionShort
|
|
827
|
-
: card.caption;
|
|
828
|
-
if (textWidth(caption, 11) <= captionAvail) {
|
|
829
|
-
elements.push(svgText({
|
|
830
|
-
x: innerRight,
|
|
831
|
-
y: valueBaseline,
|
|
832
|
-
value: caption,
|
|
833
|
-
fill: COLORS.secondary,
|
|
834
|
-
size: 11,
|
|
835
|
-
anchor: "end",
|
|
836
|
-
}));
|
|
837
|
-
}
|
|
838
|
-
const barY = cellY + cellHeight - 16;
|
|
839
|
-
elements.push(svgRect(contentX, barY, innerWidth, 3, {
|
|
840
|
-
rx: 1.5,
|
|
841
|
-
fill: card.track,
|
|
812
|
+
elements.push("</g>");
|
|
813
|
+
}
|
|
814
|
+
if (card.caption) {
|
|
815
|
+
const captionBaseline = unitInline ? y + 107 : y + 112;
|
|
816
|
+
elements.push(`<g data-role="kpi-caption" data-placement="${unitInline ? "inline" : "stacked"}" data-baseline="${captionBaseline}">`);
|
|
817
|
+
elements.push(svgText({
|
|
818
|
+
x: x + 16,
|
|
819
|
+
y: captionBaseline,
|
|
820
|
+
value: truncateToWidth(card.caption, cardWidth - 32, 12),
|
|
821
|
+
fill: COLORS.muted,
|
|
822
|
+
size: 12,
|
|
842
823
|
}));
|
|
843
|
-
|
|
824
|
+
elements.push("</g>");
|
|
825
|
+
}
|
|
826
|
+
if (card.bar) {
|
|
827
|
+
const barY = y + cardHeight - 22;
|
|
828
|
+
const barWidth = cardWidth - 32;
|
|
829
|
+
elements.push(svgRect(x + 16, barY, barWidth, 5, { rx: 2.5, fill: COLORS.track }));
|
|
830
|
+
const fillWidth = Math.max(0, Math.min(1, card.bar.fraction)) * barWidth;
|
|
844
831
|
if (fillWidth > 0) {
|
|
845
|
-
elements.push(svgRect(
|
|
846
|
-
rx: 1.5,
|
|
847
|
-
fill: card.fill,
|
|
848
|
-
}));
|
|
832
|
+
elements.push(svgRect(x + 16, barY, fillWidth, 5, { rx: 2.5, fill: card.bar.fill }));
|
|
849
833
|
}
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
834
|
+
}
|
|
835
|
+
if (card.sparkline && card.sparkline.length > 1 && card.sparkline.some((value) => value > 0)) {
|
|
836
|
+
const sparkWidth = Math.min(84, cardWidth * 0.34);
|
|
837
|
+
const sparkHeight = 26;
|
|
838
|
+
const sparkLeft = x + cardWidth - sparkWidth - 14;
|
|
839
|
+
const sparkTop = y + 16;
|
|
840
|
+
const maxValue = Math.max(...card.sparkline);
|
|
841
|
+
const points = card.sparkline.map((value, index) => {
|
|
842
|
+
const px = sparkLeft + (index / (card.sparkline.length - 1)) * sparkWidth;
|
|
843
|
+
const py = sparkTop + (1 - (maxValue > 0 ? value / maxValue : 0)) * sparkHeight;
|
|
844
|
+
return `${px.toFixed(1)},${py.toFixed(1)}`;
|
|
845
|
+
});
|
|
846
|
+
elements.push(`<polyline points="${points.join(" ")}" fill="none" stroke="${COLORS.leftAxis}" stroke-width="1.6" stroke-linejoin="round"/>`);
|
|
847
|
+
}
|
|
848
|
+
if (card.histogram && card.histogram.length) {
|
|
849
|
+
const shares = card.histogram.slice(0, 7);
|
|
850
|
+
const maxShare = Math.max(...shares);
|
|
851
|
+
const histWidth = Math.min(86, cardWidth * 0.34);
|
|
852
|
+
const slot = histWidth / shares.length;
|
|
853
|
+
const histBottom = y + cardHeight - 20;
|
|
854
|
+
shares.forEach((share, index) => {
|
|
855
|
+
const barHeight = maxShare > 0 ? Math.max(2, (share / maxShare) * 30) : 2;
|
|
856
|
+
elements.push(svgRect(
|
|
857
|
+
x + cardWidth - 14 - histWidth + index * slot,
|
|
858
|
+
histBottom - barHeight,
|
|
859
|
+
Math.max(2, slot - 3),
|
|
860
|
+
barHeight,
|
|
861
|
+
{ fill: COLORS.leftAxis, opacity: 0.85, rx: 1 },
|
|
862
|
+
));
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function addVerticalDivider(x, y, height, role) {
|
|
868
|
+
elements.push(svgLine(x, y + 2, x, y + height - 2, {
|
|
869
|
+
stroke: COLORS.separator,
|
|
855
870
|
"stroke-width": 1,
|
|
871
|
+
"data-role": role,
|
|
856
872
|
}));
|
|
857
873
|
}
|
|
858
874
|
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
}));
|
|
866
|
-
const paceRight = pacePanelX + pacePanelWidth - 18;
|
|
867
|
-
if (meterCard) {
|
|
868
|
-
elements.push(`<circle cx="${(paceTextX + 3.5).toFixed(2)}" cy="${cardTop + 19}" r="3.5" fill="${meterCard.swatch}"/>`);
|
|
869
|
-
}
|
|
870
|
-
elements.push(svgText({
|
|
871
|
-
x: paceTextX + (meterCard ? 15 : 0),
|
|
872
|
-
y: cardTop + 23,
|
|
873
|
-
value: meterCard ? "WEEKLY METER · PACE & RUNWAY" : "PACE & RUNWAY",
|
|
874
|
-
fill: meterCard ? meterCard.labelColor : COLORS.muted,
|
|
875
|
-
size: 10.5,
|
|
876
|
-
spacing: "1.2",
|
|
877
|
-
}));
|
|
878
|
-
if (meterCard) {
|
|
879
|
-
// Provenance rides the label row; the meter reading is the headline with
|
|
880
|
-
// the projected runway right-aligned beside it.
|
|
881
|
-
elements.push(svgText({
|
|
882
|
-
x: paceRight,
|
|
883
|
-
y: cardTop + 23,
|
|
884
|
-
value: meterCard.caption,
|
|
885
|
-
fill: COLORS.muted,
|
|
886
|
-
size: 10.5,
|
|
887
|
-
anchor: "end",
|
|
888
|
-
}));
|
|
889
|
-
elements.push(svgText({
|
|
890
|
-
x: paceTextX,
|
|
891
|
-
y: cardTop + paceHeadlineBaseline,
|
|
892
|
-
value: meterCard.value,
|
|
893
|
-
fill: meterCard.valueColor,
|
|
894
|
-
size: 28,
|
|
895
|
-
weight: 800,
|
|
896
|
-
spacing: "-0.6",
|
|
875
|
+
function drawWeeklyCard(x, y, cardWidth, cardHeight) {
|
|
876
|
+
elements.push(svgRect(x, y, cardWidth, cardHeight, {
|
|
877
|
+
rx: 8,
|
|
878
|
+
fill: COLORS.meterPanel,
|
|
879
|
+
stroke: COLORS.meterPanelBorder,
|
|
880
|
+
"stroke-width": 1,
|
|
897
881
|
}));
|
|
882
|
+
let labelX = x + 16;
|
|
898
883
|
elements.push(svgText({
|
|
899
|
-
x:
|
|
900
|
-
y:
|
|
901
|
-
value:
|
|
902
|
-
fill: COLORS.
|
|
903
|
-
size:
|
|
884
|
+
x: labelX,
|
|
885
|
+
y: y + 25,
|
|
886
|
+
value: "WEEKLY LIMIT",
|
|
887
|
+
fill: COLORS.meterAxis,
|
|
888
|
+
size: 12,
|
|
889
|
+
weight: 600,
|
|
890
|
+
spacing: "1.08",
|
|
904
891
|
}));
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
892
|
+
labelX += textWidth("WEEKLY LIMIT", 12, 600) + 22;
|
|
893
|
+
if (stale) {
|
|
894
|
+
elements.push(chip(labelX, y + 21, "STALE SNAPSHOT", {
|
|
895
|
+
fill: "rgba(246,183,60,.16)",
|
|
896
|
+
stroke: COLORS.line,
|
|
897
|
+
color: COLORS.line,
|
|
898
|
+
size: 10.5,
|
|
899
|
+
}).markup);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const status = meter.status;
|
|
903
|
+
const valueBaseline = y + 60;
|
|
904
|
+
const rightX = x + cardWidth - 16;
|
|
905
|
+
|
|
906
|
+
if (status === "unavailable") {
|
|
909
907
|
elements.push(svgText({
|
|
910
|
-
x:
|
|
911
|
-
y:
|
|
912
|
-
value:
|
|
913
|
-
fill:
|
|
914
|
-
size:
|
|
908
|
+
x: x + 16,
|
|
909
|
+
y: valueBaseline,
|
|
910
|
+
value: "NO OBSERVATION",
|
|
911
|
+
fill: COLORS.line,
|
|
912
|
+
size: 24,
|
|
915
913
|
weight: 800,
|
|
916
|
-
|
|
914
|
+
spacing: "-0.24",
|
|
917
915
|
}));
|
|
918
916
|
elements.push(svgText({
|
|
919
|
-
x:
|
|
920
|
-
y:
|
|
921
|
-
value:
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
917
|
+
x: x + 16,
|
|
918
|
+
y: y + 86,
|
|
919
|
+
value: truncateToWidth(
|
|
920
|
+
"No account-wide weekly-limit reading was found",
|
|
921
|
+
cardWidth - 32,
|
|
922
|
+
12.5,
|
|
923
|
+
),
|
|
924
|
+
fill: COLORS.secondary,
|
|
925
|
+
size: 12.5,
|
|
925
926
|
}));
|
|
927
|
+
return;
|
|
926
928
|
}
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
}));
|
|
945
|
-
}
|
|
946
|
-
if (paceRunwayBar) {
|
|
947
|
-
// Runway timeline: amber fill = days of meter left, tick = the next
|
|
948
|
-
// weekly reset, both on a shared day scale.
|
|
949
|
-
const scaleDays = Math.max(paceRunwayBar.runwayDays, paceRunwayBar.daysToReset) * 1.06;
|
|
950
|
-
const trackY = cardTop + paceHeadlineBaseline + 12;
|
|
951
|
-
elements.push(svgRect(paceTextX, trackY, paceInnerWidth, 5, {
|
|
952
|
-
rx: 2.5,
|
|
953
|
-
fill: "rgba(246,183,60,.14)",
|
|
954
|
-
}));
|
|
955
|
-
const runwayWidth = Math.min(1, paceRunwayBar.runwayDays / scaleDays) * paceInnerWidth;
|
|
956
|
-
if (runwayWidth > 0) {
|
|
957
|
-
elements.push(svgRect(paceTextX, trackY, runwayWidth, 5, {
|
|
958
|
-
rx: 2.5,
|
|
929
|
+
|
|
930
|
+
// Right column: time to the scheduled reset.
|
|
931
|
+
if (meter.resetInMs !== null) {
|
|
932
|
+
elements.push(svgText({
|
|
933
|
+
x: rightX,
|
|
934
|
+
y: y + 25,
|
|
935
|
+
value: "RESETS IN",
|
|
936
|
+
fill: COLORS.meterAxis,
|
|
937
|
+
size: 11,
|
|
938
|
+
weight: 600,
|
|
939
|
+
spacing: "1",
|
|
940
|
+
anchor: "end",
|
|
941
|
+
}));
|
|
942
|
+
elements.push(svgText({
|
|
943
|
+
x: rightX,
|
|
944
|
+
y: y + 50,
|
|
945
|
+
value: durationLabel(meter.resetInMs).toUpperCase(),
|
|
959
946
|
fill: COLORS.line,
|
|
947
|
+
size: 21,
|
|
948
|
+
weight: 800,
|
|
949
|
+
anchor: "end",
|
|
950
|
+
spacing: "-0.2",
|
|
960
951
|
}));
|
|
961
952
|
}
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
953
|
+
|
|
954
|
+
let subLine;
|
|
955
|
+
if (status === "exhausted") {
|
|
956
|
+
elements.push(svgText({
|
|
957
|
+
x: x + 16,
|
|
958
|
+
y: valueBaseline,
|
|
959
|
+
value: "EXHAUSTED",
|
|
960
|
+
fill: COLORS.line,
|
|
961
|
+
size: 26,
|
|
962
|
+
weight: 800,
|
|
963
|
+
spacing: "-0.26",
|
|
964
|
+
}));
|
|
965
|
+
subLine = meter.firstExhaustedObservedAtMs !== null && meter.resetsAtMs !== null
|
|
966
|
+
? `Reached 0% about ${durationLabel(meter.resetsAtMs - meter.firstExhaustedObservedAtMs)} before reset`
|
|
967
|
+
: "Latest reading reports 0% remaining";
|
|
968
|
+
} else {
|
|
969
|
+
elements.push(svgText({
|
|
970
|
+
x: x + 16,
|
|
971
|
+
y: valueBaseline,
|
|
972
|
+
value: meterPct(meter.remainingPercent),
|
|
973
|
+
fill: COLORS.line,
|
|
974
|
+
size: 26,
|
|
975
|
+
weight: 800,
|
|
976
|
+
spacing: "-0.26",
|
|
977
|
+
}));
|
|
978
|
+
elements.push(svgText({
|
|
979
|
+
x: x + 16 + textWidth(meterPct(meter.remainingPercent), 26, 800) + 12,
|
|
980
|
+
y: valueBaseline,
|
|
981
|
+
value: "remaining",
|
|
982
|
+
fill: COLORS.secondary,
|
|
983
|
+
size: 12.5,
|
|
984
|
+
}));
|
|
985
|
+
if (status === "at-risk" && meter.runwayDays !== null && meter.resetInMs !== null) {
|
|
986
|
+
subLine = `At this pace: 0% about ${durationLabel(meter.resetInMs - meter.runwayDays * 86_400_000)} before reset`;
|
|
987
|
+
} else if (meter.runwayDays !== null) {
|
|
988
|
+
subLine = `${meter.runwayDays.toFixed(1)} days of runway at this pace`;
|
|
989
|
+
} else {
|
|
990
|
+
subLine = "Runway unavailable · no usable meter drain in the active cycle";
|
|
991
|
+
}
|
|
992
|
+
}
|
|
965
993
|
elements.push(svgText({
|
|
966
|
-
x:
|
|
967
|
-
y:
|
|
968
|
-
value:
|
|
969
|
-
fill: COLORS.
|
|
970
|
-
size:
|
|
994
|
+
x: x + 16,
|
|
995
|
+
y: y + 91,
|
|
996
|
+
value: truncateToWidth(subLine, cardWidth - 32, 12.5),
|
|
997
|
+
fill: status === "at-risk" ? COLORS.warn : COLORS.secondary,
|
|
998
|
+
size: 12.5,
|
|
971
999
|
}));
|
|
972
|
-
|
|
973
|
-
const
|
|
974
|
-
const
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1000
|
+
|
|
1001
|
+
const barY = y + cardHeight - 20;
|
|
1002
|
+
const barWidth = cardWidth - 32;
|
|
1003
|
+
elements.push(svgRect(x + 16, barY, barWidth, 5, { rx: 2.5, fill: "rgba(246,183,60,.2)" }));
|
|
1004
|
+
const fillWidth = (Math.max(0, Math.min(100, meter.remainingPercent)) / 100) * barWidth;
|
|
1005
|
+
if (fillWidth > 0) {
|
|
1006
|
+
elements.push(svgRect(x + 16, barY, fillWidth, 5, { rx: 2.5, fill: COLORS.line }));
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function buildKpiSection(top) {
|
|
1011
|
+
const cards = compactCards();
|
|
1012
|
+
const cardHeight = 140;
|
|
1013
|
+
const gap = 12;
|
|
1014
|
+
if (wide) {
|
|
1015
|
+
// Weekly card takes ~1.55 compact-card widths on one row.
|
|
1016
|
+
const unit = (contentWidth - gap * 4) / (4 + 1.55);
|
|
1017
|
+
for (let index = 1; index < cards.length; index += 1) {
|
|
1018
|
+
addVerticalDivider(
|
|
1019
|
+
outer + index * (unit + gap) - gap / 2,
|
|
1020
|
+
top,
|
|
1021
|
+
cardHeight,
|
|
1022
|
+
"kpi-column-divider",
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
cards.forEach((card, index) => {
|
|
1026
|
+
drawCompactCard(card, outer + index * (unit + gap), top, unit, cardHeight);
|
|
1027
|
+
});
|
|
1028
|
+
drawWeeklyCard(outer + 4 * (unit + gap), top, unit * 1.55, cardHeight);
|
|
1029
|
+
return top + cardHeight;
|
|
1030
|
+
}
|
|
1031
|
+
const half = (contentWidth - gap) / 2;
|
|
1032
|
+
addVerticalDivider(
|
|
1033
|
+
outer + half + gap / 2,
|
|
1034
|
+
top,
|
|
1035
|
+
cardHeight * 2 + gap,
|
|
1036
|
+
"kpi-column-divider",
|
|
978
1037
|
);
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1038
|
+
cards.forEach((card, index) => {
|
|
1039
|
+
const row = Math.floor(index / 2);
|
|
1040
|
+
const column = index % 2;
|
|
1041
|
+
drawCompactCard(card, outer + column * (half + gap), top + row * (cardHeight + gap), half, cardHeight);
|
|
1042
|
+
});
|
|
1043
|
+
const weeklyTop = top + 2 * (cardHeight + gap);
|
|
1044
|
+
drawWeeklyCard(outer, weeklyTop, contentWidth, cardHeight);
|
|
1045
|
+
return weeklyTop + cardHeight;
|
|
987
1046
|
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
const
|
|
1047
|
+
|
|
1048
|
+
// ------------------------------------------------------------- model mix
|
|
1049
|
+
function buildModelMixSection(top) {
|
|
1050
|
+
const sectionHeight = 28;
|
|
1051
|
+
const labelBaseline = top + 19;
|
|
993
1052
|
elements.push(svgText({
|
|
994
|
-
x:
|
|
995
|
-
y:
|
|
996
|
-
value:
|
|
997
|
-
fill:
|
|
998
|
-
size:
|
|
999
|
-
weight:
|
|
1053
|
+
x: outer + 16,
|
|
1054
|
+
y: labelBaseline,
|
|
1055
|
+
value: "MODEL MIX",
|
|
1056
|
+
fill: COLORS.leftAxis,
|
|
1057
|
+
size: 11.5,
|
|
1058
|
+
weight: 600,
|
|
1059
|
+
spacing: "1.08",
|
|
1000
1060
|
}));
|
|
1001
1061
|
elements.push(svgText({
|
|
1002
|
-
x:
|
|
1003
|
-
y:
|
|
1004
|
-
value:
|
|
1062
|
+
x: outer + 16 + spacedWidth("MODEL MIX", 11.5, 600, 1.08) + 8,
|
|
1063
|
+
y: labelBaseline,
|
|
1064
|
+
value: "(by tokens)",
|
|
1005
1065
|
fill: COLORS.muted,
|
|
1006
1066
|
size: 11,
|
|
1007
1067
|
}));
|
|
1008
|
-
});
|
|
1009
|
-
let paceNoteBaseline = cardTop + paceNoteStart;
|
|
1010
|
-
for (const line of paceNoteLines) {
|
|
1011
|
-
elements.push(svgText({
|
|
1012
|
-
x: paceTextX,
|
|
1013
|
-
y: paceNoteBaseline,
|
|
1014
|
-
value: line,
|
|
1015
|
-
fill: COLORS.muted,
|
|
1016
|
-
size: 11.5,
|
|
1017
|
-
}));
|
|
1018
|
-
paceNoteBaseline += 16;
|
|
1019
|
-
}
|
|
1020
1068
|
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
const y = plotBottom - fraction * plotHeight;
|
|
1024
|
-
elements.push(`<line x1="${plotLeft}" y1="${y.toFixed(2)}" x2="${plotRight}" y2="${y.toFixed(2)}" stroke="${fraction === 0 ? COLORS.baseline : COLORS.grid}" stroke-width="1"/>`);
|
|
1025
|
-
elements.push(svgText({
|
|
1026
|
-
x: plotLeft - 14,
|
|
1027
|
-
y: y + 4,
|
|
1028
|
-
value: percentMode
|
|
1029
|
-
? `${Number((maxBar * fraction).toFixed(1))}%`
|
|
1030
|
-
: fraction === 0
|
|
1031
|
-
? "0"
|
|
1032
|
-
: compact(maxBar * fraction),
|
|
1033
|
-
fill: COLORS.muted,
|
|
1034
|
-
size: 13,
|
|
1035
|
-
anchor: "end",
|
|
1036
|
-
mono: true,
|
|
1037
|
-
}));
|
|
1038
|
-
if (hasLine) {
|
|
1069
|
+
const rows = vm.models.filter((row) => row.totalTokens > 0);
|
|
1070
|
+
if (!rows.length || !(summary.totalTokens > 0)) {
|
|
1039
1071
|
elements.push(svgText({
|
|
1040
|
-
x:
|
|
1041
|
-
y:
|
|
1042
|
-
value:
|
|
1043
|
-
fill: COLORS.
|
|
1044
|
-
size:
|
|
1045
|
-
|
|
1072
|
+
x: contentRight - 16,
|
|
1073
|
+
y: labelBaseline,
|
|
1074
|
+
value: "no usage in range",
|
|
1075
|
+
fill: COLORS.muted,
|
|
1076
|
+
size: 12.5,
|
|
1077
|
+
anchor: "end",
|
|
1046
1078
|
}));
|
|
1079
|
+
return top + sectionHeight;
|
|
1047
1080
|
}
|
|
1048
|
-
}
|
|
1049
|
-
elements.push(svgText({
|
|
1050
|
-
x: plotLeft,
|
|
1051
|
-
y: chartBlockTop + 18,
|
|
1052
|
-
value: percentMode
|
|
1053
|
-
? "METER DRAIN · OBSERVED TOTAL, ESTIMATED MODEL SPLIT"
|
|
1054
|
-
: "TOKEN VOLUME · ACTUAL",
|
|
1055
|
-
fill: COLORS.leftAxis,
|
|
1056
|
-
size: 11.5,
|
|
1057
|
-
spacing: "1.25",
|
|
1058
|
-
}));
|
|
1059
|
-
if (hasLine) {
|
|
1060
|
-
elements.push(svgText({
|
|
1061
|
-
x: plotRight,
|
|
1062
|
-
y: chartBlockTop + 18,
|
|
1063
|
-
value: "WEEKLY METER · REMAINING",
|
|
1064
|
-
fill: COLORS.meterAxis,
|
|
1065
|
-
size: 11.5,
|
|
1066
|
-
anchor: "end",
|
|
1067
|
-
spacing: "1.25",
|
|
1068
|
-
}));
|
|
1069
|
-
}
|
|
1070
1081
|
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
const
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
y -= segmentHeight;
|
|
1091
|
-
if (segmentHeight <= 0.4) continue;
|
|
1092
|
-
const baseColor = styleForModel(model);
|
|
1093
|
-
const fastValue = percentMode ? 0 : (bin.fastValues?.get(model) ?? 0);
|
|
1094
|
-
const fastHeight = fastValue > 0 && value > 0
|
|
1095
|
-
? segmentHeight * Math.min(1, fastValue / value)
|
|
1096
|
-
: 0;
|
|
1097
|
-
elements.push(svgRect(x, y, barWidth, segmentHeight - fastHeight, {
|
|
1098
|
-
fill: baseColor,
|
|
1099
|
-
"data-series": "usage-bars",
|
|
1100
|
-
"data-model": model,
|
|
1101
|
-
"data-value": value,
|
|
1102
|
-
"data-unit": percentMode ? "meter-points" : "tokens",
|
|
1103
|
-
}));
|
|
1104
|
-
if (fastHeight > 0.5) {
|
|
1105
|
-
elements.push(svgRect(x, y + segmentHeight - fastHeight, barWidth, fastHeight, {
|
|
1106
|
-
fill: fastShade(baseColor),
|
|
1107
|
-
"data-series": "usage-bars",
|
|
1108
|
-
"data-model": model,
|
|
1109
|
-
"data-value": fastValue,
|
|
1110
|
-
"data-unit": "tokens",
|
|
1111
|
-
"data-tier": "fast",
|
|
1112
|
-
}));
|
|
1082
|
+
// Segments that are too narrow for an inside label move to an external
|
|
1083
|
+
// caption at the right end of the strip.
|
|
1084
|
+
const barLeft = outer + 170;
|
|
1085
|
+
const external = [];
|
|
1086
|
+
const externalRows = [];
|
|
1087
|
+
let barRight = contentRight - 16;
|
|
1088
|
+
const segmentLabel = (row) =>
|
|
1089
|
+
`${row.model} ${approximateLabel(pct(row.sharePercent), summary.estimated)} (${approximateLabel(compact(row.totalTokens), row.estimated)})`;
|
|
1090
|
+
for (const row of [...rows].reverse()) {
|
|
1091
|
+
const share = row.totalTokens / summary.totalTokens;
|
|
1092
|
+
const estimatedWidth = share * (barRight - barLeft);
|
|
1093
|
+
if (
|
|
1094
|
+
estimatedWidth < textWidth(segmentLabel(row), 12, 600) + 18 &&
|
|
1095
|
+
externalRows.length < 2 &&
|
|
1096
|
+
rows.length > 1
|
|
1097
|
+
) {
|
|
1098
|
+
externalRows.unshift(row);
|
|
1099
|
+
} else {
|
|
1100
|
+
break;
|
|
1113
1101
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1102
|
+
}
|
|
1103
|
+
for (const row of externalRows) external.push(segmentLabel(row));
|
|
1104
|
+
if (external.length) {
|
|
1105
|
+
const caption = external.join(" · ");
|
|
1106
|
+
barRight -= textWidth(caption, 11, 500) + 16;
|
|
1107
|
+
elements.push(svgText({
|
|
1108
|
+
x: contentRight - 16,
|
|
1109
|
+
y: labelBaseline,
|
|
1110
|
+
value: caption,
|
|
1111
|
+
fill: COLORS.secondary,
|
|
1112
|
+
size: 11,
|
|
1113
|
+
weight: 500,
|
|
1114
|
+
anchor: "end",
|
|
1115
|
+
}));
|
|
1116
|
+
}
|
|
1117
|
+
const barY = top + 7;
|
|
1118
|
+
const barHeight = 14;
|
|
1119
|
+
const unlabeledRows = [];
|
|
1120
|
+
let cursor = barLeft;
|
|
1121
|
+
const barWidth = Math.max(60, barRight - barLeft);
|
|
1122
|
+
rows.forEach((row, index) => {
|
|
1123
|
+
const share = row.totalTokens / summary.totalTokens;
|
|
1124
|
+
const segmentWidth = share * barWidth;
|
|
1125
|
+
elements.push(svgRect(cursor, barY, segmentWidth, barHeight, {
|
|
1126
|
+
fill: styleForModel(row.model),
|
|
1127
|
+
rx: index === 0 || index === rows.length - 1 ? 3 : null,
|
|
1128
|
+
}));
|
|
1129
|
+
const label = segmentLabel(row);
|
|
1130
|
+
if (!externalRows.includes(row) && textWidth(label, 10.5, 600) + 14 <= segmentWidth) {
|
|
1131
|
+
elements.push(svgText({
|
|
1132
|
+
x: cursor + segmentWidth / 2,
|
|
1133
|
+
y: barY + 10.5,
|
|
1134
|
+
value: label,
|
|
1130
1135
|
fill: "#ffffff",
|
|
1131
|
-
size:
|
|
1132
|
-
weight:
|
|
1136
|
+
size: 10.5,
|
|
1137
|
+
weight: 600,
|
|
1133
1138
|
anchor: "middle",
|
|
1134
1139
|
}));
|
|
1135
1140
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
// ---- Meter line: per-cycle smoothed segments with reset breaks ----
|
|
1140
|
-
const yForRemaining = (value) =>
|
|
1141
|
-
plotTop + (1 - Math.max(0, Math.min(100, value)) / 100) * plotHeight;
|
|
1142
|
-
const xForTimestamp = (timestampMs) => {
|
|
1143
|
-
const span = bounds.end.getTime() - bounds.start.getTime();
|
|
1144
|
-
const ratio = span > 0 ? (timestampMs - bounds.start.getTime()) / span : 0;
|
|
1145
|
-
return plotLeft + Math.max(0, Math.min(1, ratio)) * plotWidth;
|
|
1146
|
-
};
|
|
1147
|
-
|
|
1148
|
-
let resetMarks = [];
|
|
1149
|
-
let binDots = [];
|
|
1150
|
-
let pills = [];
|
|
1151
|
-
const lineSegments = [];
|
|
1152
|
-
if (hasLine) {
|
|
1153
|
-
const cycles = new Map();
|
|
1154
|
-
for (const point of trend.points ?? []) {
|
|
1155
|
-
const cycle = cycles.get(point.cycle) ?? [];
|
|
1156
|
-
cycle.push(point);
|
|
1157
|
-
cycles.set(point.cycle, cycle);
|
|
1158
|
-
}
|
|
1159
|
-
const orderedCycles = [...cycles.entries()].sort(
|
|
1160
|
-
(left, right) => left[1][0].timestampMs - right[1][0].timestampMs,
|
|
1161
|
-
);
|
|
1162
|
-
|
|
1163
|
-
resetMarks = resetsInRange
|
|
1164
|
-
.filter((reset) => reset.kind !== "start")
|
|
1165
|
-
.map((reset) => ({
|
|
1166
|
-
...reset,
|
|
1167
|
-
x: xForTimestamp(Math.max(bounds.start.getTime(), reset.timestampMs)),
|
|
1168
|
-
label: reset.kind === "weekly-expiry"
|
|
1169
|
-
? "RESET · 100%"
|
|
1170
|
-
: "RESTART · 100%",
|
|
1171
|
-
}));
|
|
1172
|
-
const resetByCycle = new Map(resetMarks.map((reset) => [reset.cycle, reset]));
|
|
1173
|
-
const resetLabels = (() => {
|
|
1174
|
-
const maximum = 4;
|
|
1175
|
-
if (resetMarks.length <= maximum) return resetMarks;
|
|
1176
|
-
const selected = new Map();
|
|
1177
|
-
const add = (reset) => {
|
|
1178
|
-
if (reset) selected.set(reset.cycle, reset);
|
|
1179
|
-
};
|
|
1180
|
-
const scheduled = resetMarks.filter(
|
|
1181
|
-
(reset) => reset.kind === "weekly-expiry",
|
|
1182
|
-
);
|
|
1183
|
-
if (scheduled.length >= maximum) {
|
|
1184
|
-
for (let index = 0; index < maximum; index += 1) {
|
|
1185
|
-
add(scheduled[Math.round((index / (maximum - 1)) * (scheduled.length - 1))]);
|
|
1186
|
-
}
|
|
1187
|
-
} else {
|
|
1188
|
-
scheduled.forEach(add);
|
|
1189
|
-
add(resetMarks[0]);
|
|
1190
|
-
add(resetMarks.findLast((reset) => reset.kind !== "weekly-expiry"));
|
|
1191
|
-
for (let index = 1; selected.size < maximum && index < resetMarks.length - 1; index += 1) {
|
|
1192
|
-
const candidateIndex = Math.round(
|
|
1193
|
-
(index / (maximum - 1)) * (resetMarks.length - 1),
|
|
1194
|
-
);
|
|
1195
|
-
add(resetMarks[candidateIndex]);
|
|
1196
|
-
}
|
|
1141
|
+
if (!externalRows.includes(row) && textWidth(label, 10.5, 600) + 14 > segmentWidth) {
|
|
1142
|
+
unlabeledRows.push(row);
|
|
1197
1143
|
}
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
const
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
remainingPercent: point.remainingPercent,
|
|
1209
|
-
timestampMs: point.timestampMs,
|
|
1210
|
-
}));
|
|
1211
|
-
const cycleReset = resetByCycle.get(cycleId);
|
|
1212
|
-
if (
|
|
1213
|
-
cycleReset &&
|
|
1214
|
-
points.length &&
|
|
1215
|
-
cycleReset.timestampMs < points[0].timestampMs
|
|
1216
|
-
) {
|
|
1217
|
-
points.unshift({
|
|
1218
|
-
x: cycleReset.x,
|
|
1219
|
-
y: yForRemaining(100),
|
|
1220
|
-
remainingPercent: 100,
|
|
1221
|
-
timestampMs: cycleReset.timestampMs,
|
|
1222
|
-
syntheticReset: true,
|
|
1223
|
-
});
|
|
1144
|
+
cursor += segmentWidth;
|
|
1145
|
+
});
|
|
1146
|
+
let captionX = barLeft;
|
|
1147
|
+
let captionRow = 0;
|
|
1148
|
+
for (const row of unlabeledRows) {
|
|
1149
|
+
const label = segmentLabel(row);
|
|
1150
|
+
const labelWidth = textWidth(label, 11, 500) + 24;
|
|
1151
|
+
if (captionX > barLeft && captionX + labelWidth > contentRight - 16) {
|
|
1152
|
+
captionX = barLeft;
|
|
1153
|
+
captionRow += 1;
|
|
1224
1154
|
}
|
|
1155
|
+
elements.push(svgText({
|
|
1156
|
+
x: captionX, y: top + 38 + captionRow * 18, value: label,
|
|
1157
|
+
fill: styleForModel(row.model), size: 11, weight: 500,
|
|
1158
|
+
}));
|
|
1159
|
+
captionX += labelWidth;
|
|
1160
|
+
}
|
|
1161
|
+
return top + sectionHeight + (unlabeledRows.length ? (captionRow + 1) * 18 : 0);
|
|
1162
|
+
}
|
|
1225
1163
|
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
...lastPoint,
|
|
1239
|
-
x: nextReset.x,
|
|
1240
|
-
timestampMs: nextReset.timestampMs,
|
|
1241
|
-
carriedToReset: true,
|
|
1164
|
+
// ------------------------------------------------------------ daily chart
|
|
1165
|
+
function buildDailyChartSection(top) {
|
|
1166
|
+
const hourlyMode = meta.granularity === "hour" &&
|
|
1167
|
+
Array.isArray(vm.hourly) &&
|
|
1168
|
+
vm.hourly.length > 0;
|
|
1169
|
+
const hourlyRows = hourlyMode ? hourlyChartRows(vm.hourly, timeZone) : null;
|
|
1170
|
+
const plotWidthEstimate = contentWidth - 70 - 66 - 24;
|
|
1171
|
+
const binSize = hourlyMode
|
|
1172
|
+
? 1
|
|
1173
|
+
: chooseBinSize(meta.rangeDays, plotWidthEstimate, {
|
|
1174
|
+
minBinWidth: MIN_BAR_WIDTH,
|
|
1175
|
+
preferDaily: true,
|
|
1242
1176
|
});
|
|
1243
|
-
|
|
1177
|
+
const tokenBins = hourlyMode ? hourlyRows : binDailyRows(vm.daily, binSize);
|
|
1178
|
+
const meterDrainAvailable = Boolean(options.drain) &&
|
|
1179
|
+
Boolean(drainTrend?.available) &&
|
|
1180
|
+
meter.status !== "unavailable";
|
|
1181
|
+
const burnCandidate = meterDrainAvailable
|
|
1182
|
+
? hourlyMode
|
|
1183
|
+
? buildBurnHourBins(
|
|
1184
|
+
drainTrend,
|
|
1185
|
+
hourlyRows,
|
|
1186
|
+
meta.startMs,
|
|
1187
|
+
meta.effectiveEndMs,
|
|
1188
|
+
)
|
|
1189
|
+
: buildBurnDayBins(drainTrend, bounds, { days: meta.rangeDays, binSize })
|
|
1190
|
+
: null;
|
|
1191
|
+
const percentMode = Boolean(options.drain && burnCandidate?.totalPercent > 0);
|
|
1192
|
+
const drainFallback = Boolean(options.drain) && !percentMode;
|
|
1193
|
+
const burn = percentMode ? burnCandidate : null;
|
|
1194
|
+
const bins = percentMode
|
|
1195
|
+
? burn.bins.map((bin, index) => ({
|
|
1196
|
+
...bin,
|
|
1197
|
+
startDateString: bin.startDateString,
|
|
1198
|
+
lastDateString: bin.lastDateString ?? shiftCalendarDate(bin.endDateString, -1),
|
|
1199
|
+
totalPercent: bin.totalPercent,
|
|
1200
|
+
approximate: bin.approximate,
|
|
1201
|
+
values: bin.values,
|
|
1202
|
+
partial: tokenBins[index]?.partial ?? false,
|
|
1203
|
+
unobserved: tokenBins[index]?.unobserved ?? true,
|
|
1204
|
+
estimated: false,
|
|
1205
|
+
}))
|
|
1206
|
+
: tokenBins;
|
|
1207
|
+
const binCount = bins.length;
|
|
1208
|
+
const binTotalOf = (bin) => (percentMode ? bin.totalPercent : bin.totalTokens);
|
|
1209
|
+
const maxBin = bins.reduce((maximum, bin) => Math.max(maximum, binTotalOf(bin)), 0);
|
|
1210
|
+
const ceiling = reportCeiling(maxBin);
|
|
1211
|
+
const meterVisible = meter.status !== "unavailable" && meter.observations.length > 0;
|
|
1212
|
+
|
|
1213
|
+
const panelTop = top;
|
|
1214
|
+
const headerBaseline = panelTop + 27;
|
|
1215
|
+
const plotLeft = outer + 70;
|
|
1216
|
+
const plotRight = contentRight - (meterVisible ? 66 : 24);
|
|
1217
|
+
const plotWidth = plotRight - plotLeft;
|
|
1218
|
+
const plotTop = panelTop + 64;
|
|
1219
|
+
const plotHeight = wide ? 330 : 300;
|
|
1220
|
+
const plotBottom = plotTop + plotHeight;
|
|
1221
|
+
const partialInRange = bins.some((bin) => bin.partial);
|
|
1222
|
+
const slotWidth = plotWidth / binCount;
|
|
1223
|
+
const barWidth = Math.min(86, Math.max(MIN_BAR_WIDTH, slotWidth * 0.62));
|
|
1224
|
+
const entriesForBin = (bin) => percentMode
|
|
1225
|
+
? [...bin.values.entries()]
|
|
1226
|
+
.filter(([, value]) => value > 0)
|
|
1227
|
+
.sort(([left], [right]) => modelSort(left, right))
|
|
1228
|
+
.map(([model, value]) => ({ model, totalTokens: value, fastTokens: 0 }))
|
|
1229
|
+
: [...bin.models].filter((entry) => entry.totalTokens > 0)
|
|
1230
|
+
.sort((left, right) => modelSort(left.model, right.model));
|
|
1231
|
+
const valueLabelFor = (entry) => percentMode
|
|
1232
|
+
? pct(entry.totalTokens)
|
|
1233
|
+
: approximateLabel(compact(entry.totalTokens), entry.estimated);
|
|
1234
|
+
const fitsInside = (entry) =>
|
|
1235
|
+
(entry.totalTokens / ceiling) * plotHeight >= 20 &&
|
|
1236
|
+
textWidth(entry.model, 12.5, 700) <= barWidth - 6;
|
|
1237
|
+
const smallSegments = bins.map((bin) => bin.unobserved
|
|
1238
|
+
? [] : entriesForBin(bin).filter((entry) => !fitsInside(entry)));
|
|
1239
|
+
const labelBand = 58 + (partialInRange ? 18 : 0);
|
|
1240
|
+
const panelHeight = plotBottom - panelTop + labelBand;
|
|
1241
|
+
elements.push(svgRect(outer, panelTop, contentWidth, panelHeight, {
|
|
1242
|
+
rx: 8,
|
|
1243
|
+
fill: COLORS.panel,
|
|
1244
|
+
stroke: COLORS.panelBorder,
|
|
1245
|
+
"stroke-width": 1,
|
|
1246
|
+
}));
|
|
1244
1247
|
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1248
|
+
// Panel header: title + legend + right axis caption.
|
|
1249
|
+
const chartTitle = percentMode
|
|
1250
|
+
? "OBSERVED LIMIT DRAIN"
|
|
1251
|
+
: hourlyMode
|
|
1252
|
+
? "HOURLY TOKEN VOLUME"
|
|
1253
|
+
: "DAILY TOKEN VOLUME";
|
|
1254
|
+
const chartSubtitle = percentMode
|
|
1255
|
+
? hourlyMode
|
|
1256
|
+
? "(meter percent by hour)"
|
|
1257
|
+
: "(meter percent by model)"
|
|
1258
|
+
: drainFallback
|
|
1259
|
+
? "(actual · --drain unavailable; raw local tokens)"
|
|
1260
|
+
: "(actual)";
|
|
1261
|
+
elements.push(svgText({
|
|
1262
|
+
x: outer + 16,
|
|
1263
|
+
y: headerBaseline,
|
|
1264
|
+
value: chartTitle,
|
|
1265
|
+
fill: COLORS.leftAxis,
|
|
1266
|
+
size: 12,
|
|
1267
|
+
weight: 600,
|
|
1268
|
+
spacing: "1.08",
|
|
1269
|
+
}));
|
|
1270
|
+
elements.push(svgText({
|
|
1271
|
+
x: outer + 16 +
|
|
1272
|
+
spacedWidth(chartTitle, 12, 600, 1.08) + 8,
|
|
1273
|
+
y: headerBaseline,
|
|
1274
|
+
value: chartSubtitle,
|
|
1275
|
+
fill: COLORS.muted,
|
|
1276
|
+
size: 11.5,
|
|
1277
|
+
}));
|
|
1278
|
+
if (meterVisible) {
|
|
1279
|
+
elements.push(svgText({
|
|
1280
|
+
x: contentRight - 16,
|
|
1281
|
+
y: headerBaseline,
|
|
1282
|
+
value: "Meter %",
|
|
1283
|
+
fill: COLORS.meterAxis,
|
|
1284
|
+
size: 12,
|
|
1285
|
+
weight: 600,
|
|
1286
|
+
anchor: "end",
|
|
1287
|
+
}));
|
|
1267
1288
|
}
|
|
1268
1289
|
|
|
1269
|
-
//
|
|
1270
|
-
const
|
|
1271
|
-
const
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
const binEndMs = zonedMidnight(
|
|
1278
|
-
bars[binIndex].endDateString,
|
|
1279
|
-
bounds.timeZone,
|
|
1280
|
-
).getTime();
|
|
1281
|
-
const binStartMs = zonedMidnight(
|
|
1282
|
-
bars[binIndex].startDateString,
|
|
1283
|
-
bounds.timeZone,
|
|
1284
|
-
).getTime();
|
|
1285
|
-
const point = observed.findLast(
|
|
1286
|
-
(candidate) =>
|
|
1287
|
-
candidate.timestampMs >= binStartMs && candidate.timestampMs < binEndMs,
|
|
1288
|
-
);
|
|
1289
|
-
if (!point) continue;
|
|
1290
|
-
binDots.push({
|
|
1291
|
-
binIndex,
|
|
1292
|
-
x: xForTimestamp(point.timestampMs),
|
|
1293
|
-
y: yForRemaining(point.remainingPercent),
|
|
1294
|
-
remainingPercent: point.remainingPercent,
|
|
1295
|
-
cycle: point.cycle,
|
|
1296
|
-
});
|
|
1290
|
+
// Legend, wrapped when narrow.
|
|
1291
|
+
const legendItems = [];
|
|
1292
|
+
const presentModels = vm.models
|
|
1293
|
+
.filter((row) => row.totalTokens > 0)
|
|
1294
|
+
.map((row) => row.model)
|
|
1295
|
+
.sort(modelSort);
|
|
1296
|
+
for (const model of presentModels) {
|
|
1297
|
+
legendItems.push({ kind: "swatch", fill: styleForModel(model), label: model });
|
|
1297
1298
|
}
|
|
1298
|
-
|
|
1299
|
-
|
|
1299
|
+
if (!percentMode && summary.fastTokens > 0) {
|
|
1300
|
+
legendItems.push({ kind: "hatch", label: "Fast mode" });
|
|
1300
1301
|
}
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
const
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
:
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
:
|
|
1302
|
+
if (meterVisible) {
|
|
1303
|
+
legendItems.push({ kind: "solid", label: "Reported interval" });
|
|
1304
|
+
legendItems.push({ kind: "dashed", label: "Unobserved gap" });
|
|
1305
|
+
}
|
|
1306
|
+
let legendX = outer + 16;
|
|
1307
|
+
let legendY = headerBaseline + 21;
|
|
1308
|
+
const legendLimit = contentRight - 16;
|
|
1309
|
+
for (const item of legendItems) {
|
|
1310
|
+
const swatchWidth = item.kind === "swatch" || item.kind === "hatch" ? 13 : 20;
|
|
1311
|
+
const itemWidth = swatchWidth + 7 + textWidth(item.label, 12) + 18;
|
|
1312
|
+
if (legendX + itemWidth > legendLimit && legendX > outer + 16) {
|
|
1313
|
+
legendX = outer + 16;
|
|
1314
|
+
legendY += 18;
|
|
1315
|
+
}
|
|
1316
|
+
if (item.kind === "swatch") {
|
|
1317
|
+
elements.push(svgRect(legendX, legendY - 9, 13, 10, { fill: item.fill, rx: 2 }));
|
|
1318
|
+
} else if (item.kind === "hatch") {
|
|
1319
|
+
elements.push(svgRect(legendX, legendY - 9, 13, 10, { fill: COLORS.leftAxis, rx: 2 }));
|
|
1320
|
+
elements.push(svgRect(legendX, legendY - 9, 13, 10, { fill: "url(#fast-mode-hatch)", rx: 2 }));
|
|
1321
|
+
} else if (item.kind === "solid") {
|
|
1322
|
+
elements.push(svgLine(legendX, legendY - 4, legendX + 20, legendY - 4, {
|
|
1323
|
+
stroke: COLORS.line,
|
|
1324
|
+
"stroke-width": 2.4,
|
|
1325
|
+
}));
|
|
1326
|
+
} else {
|
|
1327
|
+
elements.push(svgLine(legendX, legendY - 4, legendX + 20, legendY - 4, {
|
|
1328
|
+
stroke: COLORS.line,
|
|
1329
|
+
"stroke-width": 2,
|
|
1330
|
+
"stroke-dasharray": "4 4",
|
|
1331
|
+
}));
|
|
1330
1332
|
}
|
|
1331
|
-
laneRight[lane] = labelCenterX + labelWidth / 2;
|
|
1332
|
-
const labelBaseline = plotTop + 20 + lane * 21;
|
|
1333
|
-
elements.push(svgRect(
|
|
1334
|
-
labelCenterX - labelWidth / 2,
|
|
1335
|
-
labelBaseline - 14,
|
|
1336
|
-
labelWidth,
|
|
1337
|
-
19,
|
|
1338
|
-
{
|
|
1339
|
-
rx: 5,
|
|
1340
|
-
fill: COLORS.background,
|
|
1341
|
-
stroke: "rgba(246,183,60,.42)",
|
|
1342
|
-
"stroke-width": 1,
|
|
1343
|
-
},
|
|
1344
|
-
));
|
|
1345
1333
|
elements.push(svgText({
|
|
1346
|
-
x:
|
|
1347
|
-
y:
|
|
1348
|
-
value:
|
|
1349
|
-
fill: COLORS.
|
|
1350
|
-
size:
|
|
1351
|
-
weight: 700,
|
|
1352
|
-
anchor: "middle",
|
|
1353
|
-
mono: true,
|
|
1334
|
+
x: legendX + swatchWidth + 7,
|
|
1335
|
+
y: legendY,
|
|
1336
|
+
value: item.label,
|
|
1337
|
+
fill: COLORS.secondary,
|
|
1338
|
+
size: 12,
|
|
1354
1339
|
}));
|
|
1340
|
+
legendX += itemWidth;
|
|
1355
1341
|
}
|
|
1356
1342
|
|
|
1357
|
-
//
|
|
1358
|
-
//
|
|
1359
|
-
//
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
dotY: dot.y,
|
|
1390
|
-
};
|
|
1391
|
-
});
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
// ---- Bar totals and day labels (drawn over the line like the labels) ----
|
|
1395
|
-
elements.push(...segmentLabels);
|
|
1396
|
-
// Where the line passes through a horizontal span at band height, from the
|
|
1397
|
-
// thinned polylines; keeps each column total clear of the amber stroke.
|
|
1398
|
-
const lineTopIfCrossing = (x0, x1, bandTop, bandBottom) => {
|
|
1399
|
-
let top = Infinity;
|
|
1400
|
-
for (const segment of lineSegments) {
|
|
1401
|
-
for (let index = 0; index < segment.length - 1; index += 1) {
|
|
1402
|
-
const from = segment[index];
|
|
1403
|
-
const to = segment[index + 1];
|
|
1404
|
-
if (to.x < x0 || from.x > x1) continue;
|
|
1405
|
-
const clip0 = Math.max(x0, from.x);
|
|
1406
|
-
const clip1 = Math.min(x1, to.x);
|
|
1343
|
+
// Meter pixel geometry is needed both by the overlay and by bar-total
|
|
1344
|
+
// placement (totals step above the line when it crosses their band).
|
|
1345
|
+
// Hourly bars retain the nominal end of the final partial hour so meter
|
|
1346
|
+
// timestamps land inside the same column; meter observations themselves
|
|
1347
|
+
// are still clipped by the view model's effective cutoff.
|
|
1348
|
+
const chartEndMs = hourlyMode
|
|
1349
|
+
? hourlyRows.at(-1).endMs
|
|
1350
|
+
: meta.requestedEndMs;
|
|
1351
|
+
const spanMs = chartEndMs - meta.startMs;
|
|
1352
|
+
const xForTs = (timestampMs) =>
|
|
1353
|
+
plotLeft +
|
|
1354
|
+
Math.max(0, Math.min(1, spanMs > 0 ? (timestampMs - meta.startMs) / spanMs : 0)) *
|
|
1355
|
+
plotWidth;
|
|
1356
|
+
const resetXs = meterVisible
|
|
1357
|
+
? meter.resets.map((reset) => xForTs(reset.timestampMs))
|
|
1358
|
+
: [];
|
|
1359
|
+
const yForRemaining = (value) =>
|
|
1360
|
+
plotTop + (1 - Math.max(0, Math.min(100, value)) / 100) * plotHeight;
|
|
1361
|
+
const pixelSegments = meterVisible
|
|
1362
|
+
? meter.segments.map((segment) => ({
|
|
1363
|
+
x0: xForTs(segment.fromMs),
|
|
1364
|
+
y0: yForRemaining(segment.fromPercent),
|
|
1365
|
+
x1: xForTs(segment.toMs),
|
|
1366
|
+
y1: yForRemaining(segment.toPercent),
|
|
1367
|
+
}))
|
|
1368
|
+
: [];
|
|
1369
|
+
const lineTopWithin = (x0, x1, bandTop, bandBottom) => {
|
|
1370
|
+
let top = Infinity;
|
|
1371
|
+
for (const segment of pixelSegments) {
|
|
1372
|
+
if (segment.x1 < x0 || segment.x0 > x1) continue;
|
|
1373
|
+
const clip0 = Math.max(x0, segment.x0);
|
|
1374
|
+
const clip1 = Math.min(x1, segment.x1);
|
|
1407
1375
|
if (clip1 < clip0) continue;
|
|
1408
1376
|
const yAt = (x) =>
|
|
1409
|
-
|
|
1377
|
+
segment.y0 +
|
|
1378
|
+
(segment.x1 === segment.x0
|
|
1379
|
+
? 0
|
|
1380
|
+
: ((x - segment.x0) / (segment.x1 - segment.x0)) * (segment.y1 - segment.y0));
|
|
1410
1381
|
const yLow = Math.min(yAt(clip0), yAt(clip1));
|
|
1411
1382
|
const yHigh = Math.max(yAt(clip0), yAt(clip1));
|
|
1412
1383
|
if (yLow <= bandBottom && yHigh >= bandTop) top = Math.min(top, yLow);
|
|
1413
1384
|
}
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
if (binTotal > 0 && isLabeledColumn(binIndex)) {
|
|
1425
|
-
// The total label sits in the band just above the stack; step it above
|
|
1426
|
-
// the line only when the line actually crosses that band.
|
|
1427
|
-
const lineTop = lineTopIfCrossing(
|
|
1428
|
-
centerX - barWidth / 2 - 6,
|
|
1429
|
-
centerX + barWidth / 2 + 6,
|
|
1430
|
-
topY - 32,
|
|
1431
|
-
topY + 8,
|
|
1432
|
-
);
|
|
1433
|
-
const clearedTop = Number.isFinite(lineTop) ? Math.min(topY, lineTop) : topY;
|
|
1385
|
+
return Number.isFinite(top) ? top : null;
|
|
1386
|
+
};
|
|
1387
|
+
|
|
1388
|
+
// Grid and axes.
|
|
1389
|
+
for (const fraction of [1, 0.75, 0.5, 0.25, 0]) {
|
|
1390
|
+
const y = plotBottom - fraction * plotHeight;
|
|
1391
|
+
elements.push(svgLine(plotLeft, y, plotRight, y, {
|
|
1392
|
+
stroke: fraction === 0 ? COLORS.baseline : COLORS.grid,
|
|
1393
|
+
"stroke-width": 1,
|
|
1394
|
+
}));
|
|
1434
1395
|
elements.push(svgText({
|
|
1435
|
-
x:
|
|
1436
|
-
y:
|
|
1396
|
+
x: plotLeft - 12,
|
|
1397
|
+
y: y + 4,
|
|
1437
1398
|
value: percentMode
|
|
1438
|
-
? `${
|
|
1439
|
-
:
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1399
|
+
? `${Number((ceiling * fraction).toFixed(1))}%`
|
|
1400
|
+
: fraction === 0
|
|
1401
|
+
? "0"
|
|
1402
|
+
: compact(ceiling * fraction),
|
|
1403
|
+
fill: COLORS.muted,
|
|
1404
|
+
size: 12,
|
|
1405
|
+
anchor: "end",
|
|
1406
|
+
mono: true,
|
|
1444
1407
|
}));
|
|
1408
|
+
if (meterVisible) {
|
|
1409
|
+
elements.push(svgText({
|
|
1410
|
+
x: plotRight + 12,
|
|
1411
|
+
y: y + 4,
|
|
1412
|
+
value: `${Math.round(fraction * 100)}%`,
|
|
1413
|
+
fill: COLORS.meterAxis,
|
|
1414
|
+
size: 12,
|
|
1415
|
+
mono: true,
|
|
1416
|
+
}));
|
|
1417
|
+
}
|
|
1445
1418
|
}
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1419
|
+
|
|
1420
|
+
// Bars.
|
|
1421
|
+
const labelStep = labelEvery(binCount);
|
|
1422
|
+
const peakBinIndex = hourlyMode && maxBin > 0
|
|
1423
|
+
? bins.reduce(
|
|
1424
|
+
(peakIndex, bin, index) =>
|
|
1425
|
+
binTotalOf(bin) > binTotalOf(bins[peakIndex]) ? index : peakIndex,
|
|
1426
|
+
0,
|
|
1427
|
+
)
|
|
1428
|
+
: -1;
|
|
1429
|
+
const isLabeledColumn = (index) =>
|
|
1430
|
+
index % labelStep === 0 ||
|
|
1431
|
+
index === binCount - 1 ||
|
|
1432
|
+
index === peakBinIndex;
|
|
1433
|
+
const dateLabelIndices = selectDateLabelIndices(bins, {
|
|
1434
|
+
timeZone,
|
|
1435
|
+
slotWidth,
|
|
1436
|
+
labelStep,
|
|
1437
|
+
labelSize: 14,
|
|
1438
|
+
labelForBin: hourlyMode
|
|
1439
|
+
? (bin) => bin.hourLabel
|
|
1440
|
+
: (bin) => binDateLabel(bin, timeZone),
|
|
1441
|
+
});
|
|
1442
|
+
const isDateLabeledColumn = (index) => dateLabelIndices.has(index);
|
|
1443
|
+
const segmentLabels = [];
|
|
1444
|
+
|
|
1445
|
+
bins.forEach((bin, binIndex) => {
|
|
1446
|
+
const centerX = plotLeft + (binIndex + 0.5) * slotWidth;
|
|
1447
|
+
const x = centerX - barWidth / 2;
|
|
1448
|
+
if (bin.unobserved) {
|
|
1449
|
+
elements.push(svgRect(centerX - slotWidth / 2 + 2, plotTop, slotWidth - 4, plotHeight, {
|
|
1450
|
+
fill: "rgba(255,255,255,.025)",
|
|
1451
|
+
}));
|
|
1452
|
+
if (isDateLabeledColumn(binIndex)) {
|
|
1453
|
+
elements.push(chip(centerX, plotBottom + 21, "UNOBSERVED", {
|
|
1454
|
+
fill: "rgba(255,255,255,.04)",
|
|
1455
|
+
stroke: COLORS.baseline,
|
|
1456
|
+
color: COLORS.muted,
|
|
1457
|
+
size: 9.5,
|
|
1458
|
+
anchor: "middle",
|
|
1459
|
+
}).markup);
|
|
1460
|
+
elements.push(svgText({
|
|
1461
|
+
x: centerX,
|
|
1462
|
+
y: plotBottom + 45,
|
|
1463
|
+
value: hourlyMode ? bin.hourLabel : binDateLabel(bin, timeZone),
|
|
1464
|
+
fill: COLORS.secondary,
|
|
1465
|
+
size: 14,
|
|
1466
|
+
anchor: "middle",
|
|
1467
|
+
}));
|
|
1468
|
+
}
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
if (bin.partial) {
|
|
1472
|
+
elements.push(svgRect(centerX - slotWidth / 2 + 2, plotTop, slotWidth - 4, plotHeight, {
|
|
1473
|
+
fill: "rgba(255,255,255,.03)",
|
|
1474
|
+
}));
|
|
1475
|
+
}
|
|
1476
|
+
const entries = entriesForBin(bin);
|
|
1477
|
+
let y = plotBottom;
|
|
1478
|
+
for (const entry of entries) {
|
|
1479
|
+
const value = entry.totalTokens;
|
|
1480
|
+
const segmentHeight = (value / ceiling) * plotHeight;
|
|
1481
|
+
y -= segmentHeight;
|
|
1482
|
+
if (segmentHeight <= 0.4) continue;
|
|
1483
|
+
const baseColor = styleForModel(entry.model);
|
|
1484
|
+
elements.push(svgRect(x, y, barWidth, segmentHeight, {
|
|
1485
|
+
fill: baseColor,
|
|
1486
|
+
"data-series": "usage-bars",
|
|
1487
|
+
}));
|
|
1488
|
+
// Fast-mode tokens are a subset of the segment: same color, hatched,
|
|
1489
|
+
// never extra height.
|
|
1490
|
+
const fastFraction = value > 0 ? Math.min(1, entry.fastTokens / value) : 0;
|
|
1491
|
+
const fastHeight = segmentHeight * fastFraction;
|
|
1492
|
+
if (fastHeight > 0.5) {
|
|
1493
|
+
elements.push(svgRect(x, y, barWidth, fastHeight, {
|
|
1494
|
+
fill: "url(#fast-mode-hatch)",
|
|
1495
|
+
}));
|
|
1496
|
+
}
|
|
1497
|
+
const valueLabel = valueLabelFor(entry);
|
|
1498
|
+
if (fitsInside(entry)) {
|
|
1499
|
+
const segmentCenter = y + segmentHeight / 2;
|
|
1500
|
+
const amountSize = Math.min(14,
|
|
1501
|
+
14 * (barWidth - 6) / Math.max(1, textWidth(valueLabel, 14, 700)));
|
|
1502
|
+
const showAmount = segmentHeight >= 34 && amountSize >= 9;
|
|
1503
|
+
segmentLabels.push(`<g data-role="segment-label" data-bin="${binIndex}">`);
|
|
1504
|
+
segmentLabels.push(svgText({
|
|
1505
|
+
x: centerX,
|
|
1506
|
+
y: segmentCenter + (showAmount ? -4 : 4),
|
|
1507
|
+
value: entry.model,
|
|
1508
|
+
fill: COLORS.onFill,
|
|
1509
|
+
size: 12.5,
|
|
1510
|
+
anchor: "middle",
|
|
1511
|
+
}));
|
|
1512
|
+
if (showAmount) segmentLabels.push(svgText({
|
|
1513
|
+
x: centerX,
|
|
1514
|
+
y: segmentCenter + 13,
|
|
1515
|
+
value: valueLabel,
|
|
1516
|
+
fill: "#ffffff",
|
|
1517
|
+
size: amountSize,
|
|
1518
|
+
weight: 700,
|
|
1519
|
+
anchor: "middle",
|
|
1520
|
+
}));
|
|
1521
|
+
segmentLabels.push("</g>");
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
const total = binTotalOf(bin);
|
|
1525
|
+
let totalLabelY = y - 9;
|
|
1526
|
+
if (total > 0 && isLabeledColumn(binIndex)) {
|
|
1527
|
+
const estimatedPrefix = (percentMode ? bin.approximate : bin.estimated) ? "≈" : "";
|
|
1528
|
+
const labelValue = percentMode
|
|
1529
|
+
? `${estimatedPrefix}${pct(total)}`
|
|
1530
|
+
: `${estimatedPrefix}${compact(total)}`;
|
|
1531
|
+
const labelWidth = textWidth(labelValue, 15, 700);
|
|
1532
|
+
const placement = barTotalLabelPlacement({
|
|
1533
|
+
centerX,
|
|
1534
|
+
labelWidth,
|
|
1535
|
+
slotLeft: centerX - slotWidth / 2 + 4,
|
|
1536
|
+
slotRight: centerX + slotWidth / 2 - 4,
|
|
1537
|
+
resetXs,
|
|
1538
|
+
});
|
|
1539
|
+
let labelTop = y;
|
|
1540
|
+
const labelLeft = placement.anchor === "end"
|
|
1541
|
+
? placement.x - labelWidth
|
|
1542
|
+
: placement.anchor === "start"
|
|
1543
|
+
? placement.x
|
|
1544
|
+
: placement.x - labelWidth / 2;
|
|
1545
|
+
const labelRight = labelLeft + labelWidth;
|
|
1546
|
+
const clearance = lineTopWithin(
|
|
1547
|
+
labelLeft - 5,
|
|
1548
|
+
labelRight + 5,
|
|
1549
|
+
labelTop - 28,
|
|
1550
|
+
labelTop + 8,
|
|
1551
|
+
);
|
|
1552
|
+
if (clearance !== null) labelTop = Math.min(labelTop, clearance);
|
|
1553
|
+
const lineSafeLabelY = Math.max(plotTop + 12, labelTop - 9);
|
|
1554
|
+
const labelY = placement.placement.startsWith("reset-")
|
|
1555
|
+
? Math.max(plotTop + 40, lineSafeLabelY)
|
|
1556
|
+
: lineSafeLabelY;
|
|
1557
|
+
totalLabelY = labelY;
|
|
1558
|
+
segmentLabels.push(
|
|
1559
|
+
`<g data-role="bar-total-label" data-placement="${placement.placement}"${placement.placement.startsWith("reset-") ? ' data-clearance="reset-marker"' : ""}>${svgText({
|
|
1560
|
+
x: placement.x,
|
|
1561
|
+
y: labelY,
|
|
1562
|
+
value: labelValue,
|
|
1563
|
+
size: 15,
|
|
1564
|
+
weight: 700,
|
|
1565
|
+
anchor: placement.anchor,
|
|
1566
|
+
})}</g>`,
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
// Low-volume columns have room above them for a compact callout;
|
|
1570
|
+
// keep the date axis clear and connect the callout to its actual bar.
|
|
1571
|
+
if (total > 0 && (total / ceiling) * plotHeight < 100) {
|
|
1572
|
+
const details = smallSegments[binIndex]
|
|
1573
|
+
.filter((entry) => entry.totalTokens / total >= 0.05)
|
|
1574
|
+
.sort((left, right) => right.totalTokens - left.totalTokens);
|
|
1575
|
+
const rowHeight = 16;
|
|
1576
|
+
const calloutBottom = totalLabelY - 25;
|
|
1577
|
+
if (details.length) {
|
|
1578
|
+
elements.push(svgLine(centerX, calloutBottom + 5, centerX, totalLabelY - 13, {
|
|
1579
|
+
stroke: COLORS.muted, "stroke-width": 1,
|
|
1580
|
+
}));
|
|
1581
|
+
}
|
|
1582
|
+
details.forEach((entry, rowIndex) => {
|
|
1583
|
+
const label = `${entry.model} ${valueLabelFor(entry)}`;
|
|
1584
|
+
const labelSize = Math.min(10.5, 10.5 * (slotWidth - 6) /
|
|
1585
|
+
Math.max(1, textWidth(label, 10.5, 600)));
|
|
1586
|
+
elements.push(`<g data-role="small-segment-label" data-bin="${binIndex}">`);
|
|
1587
|
+
elements.push(svgText({
|
|
1588
|
+
x: centerX,
|
|
1589
|
+
y: calloutBottom - (details.length - rowIndex - 1) * rowHeight,
|
|
1590
|
+
value: label, fill: styleForModel(entry.model), size: labelSize,
|
|
1591
|
+
weight: 600, anchor: "middle",
|
|
1592
|
+
}));
|
|
1593
|
+
elements.push("</g>");
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
// Day labels.
|
|
1597
|
+
if (isDateLabeledColumn(binIndex)) {
|
|
1598
|
+
const weekday = !hourlyMode &&
|
|
1599
|
+
binSize === 1 &&
|
|
1600
|
+
bin.lastDateString === bin.startDateString
|
|
1601
|
+
? localWeekdayLabel(bin.startDateString, timeZone).toUpperCase()
|
|
1602
|
+
: "";
|
|
1603
|
+
if (bin.partial) {
|
|
1604
|
+
elements.push(chip(centerX, plotBottom + 21, "PARTIAL", {
|
|
1605
|
+
fill: "rgba(255,255,255,.06)",
|
|
1606
|
+
stroke: COLORS.baseline,
|
|
1607
|
+
color: COLORS.secondary,
|
|
1608
|
+
size: 10.5,
|
|
1609
|
+
anchor: "middle",
|
|
1610
|
+
}).markup);
|
|
1611
|
+
} else if (weekday) {
|
|
1612
|
+
elements.push(svgText({
|
|
1613
|
+
x: centerX,
|
|
1614
|
+
y: plotBottom + 25,
|
|
1615
|
+
value: weekday,
|
|
1616
|
+
fill: COLORS.muted,
|
|
1617
|
+
size: 12,
|
|
1618
|
+
anchor: "middle",
|
|
1619
|
+
spacing: "1.44",
|
|
1620
|
+
}));
|
|
1621
|
+
}
|
|
1451
1622
|
elements.push(svgText({
|
|
1452
1623
|
x: centerX,
|
|
1453
|
-
y: plotBottom +
|
|
1454
|
-
value:
|
|
1455
|
-
fill: COLORS.
|
|
1456
|
-
size:
|
|
1624
|
+
y: plotBottom + (weekday || bin.partial ? 45 : 34),
|
|
1625
|
+
value: hourlyMode ? bin.hourLabel : binDateLabel(bin, timeZone),
|
|
1626
|
+
fill: COLORS.secondary,
|
|
1627
|
+
size: 14,
|
|
1457
1628
|
anchor: "middle",
|
|
1458
|
-
spacing: "1.56",
|
|
1459
1629
|
}));
|
|
1630
|
+
if (bin.partial && meta.partialFinalDay) {
|
|
1631
|
+
elements.push(svgText({
|
|
1632
|
+
x: centerX,
|
|
1633
|
+
y: plotBottom + 62,
|
|
1634
|
+
value: `THROUGH ${timeOnlyLabel(meta.effectiveEndMs, timeZone).toUpperCase()}`,
|
|
1635
|
+
fill: COLORS.muted,
|
|
1636
|
+
size: 10.5,
|
|
1637
|
+
anchor: "middle",
|
|
1638
|
+
spacing: "0.63",
|
|
1639
|
+
}));
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
// Meter overlay: solid runs mark spans confirmed by repeated equal
|
|
1645
|
+
// readings, while dashed runs bridge unobserved gaps. The line never
|
|
1646
|
+
// extends past the last reading.
|
|
1647
|
+
if (meterVisible) {
|
|
1648
|
+
// Dense windows keep a line per reset but cap the callout chips so the
|
|
1649
|
+
// top of the plot stays readable; scheduled expiries win the labels.
|
|
1650
|
+
const chipLimit = 4;
|
|
1651
|
+
const labeledResets = (() => {
|
|
1652
|
+
if (meter.resets.length <= chipLimit) return new Set(meter.resets);
|
|
1653
|
+
const scheduled = meter.resets.filter(
|
|
1654
|
+
(reset) => reset.kind === "weekly-expiry",
|
|
1655
|
+
);
|
|
1656
|
+
const pool = scheduled.length >= chipLimit ? scheduled : meter.resets;
|
|
1657
|
+
const selected = new Set();
|
|
1658
|
+
for (let index = 0; index < chipLimit; index += 1) {
|
|
1659
|
+
selected.add(
|
|
1660
|
+
pool[Math.round((index / (chipLimit - 1)) * (pool.length - 1))],
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
return selected;
|
|
1664
|
+
})();
|
|
1665
|
+
const resetLabelMarkups = [];
|
|
1666
|
+
const resetLabelBounds = [];
|
|
1667
|
+
for (const reset of meter.resets) {
|
|
1668
|
+
const x = xForTs(reset.timestampMs);
|
|
1669
|
+
elements.push(svgLine(x, plotTop, x, plotBottom, {
|
|
1670
|
+
stroke: "rgba(246,183,60,.5)",
|
|
1671
|
+
"stroke-width": 2,
|
|
1672
|
+
"stroke-dasharray": reset.inferred ? "5 6" : null,
|
|
1673
|
+
}));
|
|
1674
|
+
if (!labeledResets.has(reset)) continue;
|
|
1675
|
+
const labelX = Math.max(plotLeft + 32, Math.min(plotRight - 32, x));
|
|
1676
|
+
const resetLabel = chip(
|
|
1677
|
+
labelX,
|
|
1678
|
+
plotTop + 13,
|
|
1679
|
+
reset.kind === "weekly-expiry" ? "RESET" : "RESTART",
|
|
1680
|
+
{
|
|
1681
|
+
fill: COLORS.panel,
|
|
1682
|
+
color: COLORS.meterAxis,
|
|
1683
|
+
size: 10.5,
|
|
1684
|
+
weight: 600,
|
|
1685
|
+
anchor: "middle",
|
|
1686
|
+
mono: true,
|
|
1687
|
+
},
|
|
1688
|
+
);
|
|
1689
|
+
resetLabelBounds.push({
|
|
1690
|
+
left: labelX - resetLabel.width / 2,
|
|
1691
|
+
right: labelX + resetLabel.width / 2,
|
|
1692
|
+
bottom: plotTop + 19,
|
|
1693
|
+
});
|
|
1694
|
+
resetLabelMarkups.push(
|
|
1695
|
+
`<g data-role="meter-reset-label">${resetLabel.markup}</g>`,
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
for (const segment of meter.segments) {
|
|
1700
|
+
elements.push(svgLine(
|
|
1701
|
+
xForTs(segment.fromMs),
|
|
1702
|
+
yForRemaining(segment.fromPercent),
|
|
1703
|
+
xForTs(segment.toMs),
|
|
1704
|
+
yForRemaining(segment.toPercent),
|
|
1705
|
+
{
|
|
1706
|
+
stroke: COLORS.line,
|
|
1707
|
+
"stroke-width": segment.kind === "confirmed" ? 2.6 : 2,
|
|
1708
|
+
"stroke-dasharray": segment.kind === "confirmed" ? null : "5 5",
|
|
1709
|
+
"stroke-linecap": "round",
|
|
1710
|
+
"data-series": "weekly-meter",
|
|
1711
|
+
},
|
|
1712
|
+
));
|
|
1713
|
+
}
|
|
1714
|
+
elements.push(...resetLabelMarkups);
|
|
1715
|
+
const latest = meter.observations.at(-1);
|
|
1716
|
+
if (latest) {
|
|
1717
|
+
const label = `${Math.round(latest.remainingPercent)}%`;
|
|
1718
|
+
const px = xForTs(latest.timestampMs);
|
|
1719
|
+
const py = yForRemaining(latest.remainingPercent);
|
|
1720
|
+
const anchorEnd = px > plotRight - 70;
|
|
1721
|
+
const labelX = anchorEnd ? px - 10 : px + 10;
|
|
1722
|
+
const labelWidth = textWidth(label, 11, 700) + 14;
|
|
1723
|
+
const labelLeft = anchorEnd ? labelX - labelWidth : labelX;
|
|
1724
|
+
const labelRight = labelLeft + labelWidth;
|
|
1725
|
+
let labelY = Math.max(plotTop + 14, Math.min(plotBottom - 6, py + 1));
|
|
1726
|
+
const overlapsResetLabel = resetLabelBounds.some((bounds) =>
|
|
1727
|
+
labelLeft < bounds.right + 6 &&
|
|
1728
|
+
labelRight > bounds.left - 6 &&
|
|
1729
|
+
labelY - 13 < bounds.bottom + 6
|
|
1730
|
+
);
|
|
1731
|
+
if (overlapsResetLabel) labelY = plotTop + 38;
|
|
1732
|
+
const latestLabel = chip(
|
|
1733
|
+
labelX,
|
|
1734
|
+
labelY,
|
|
1735
|
+
label,
|
|
1736
|
+
{
|
|
1737
|
+
fill: COLORS.background,
|
|
1738
|
+
stroke: COLORS.line,
|
|
1739
|
+
color: COLORS.line,
|
|
1740
|
+
size: 11,
|
|
1741
|
+
anchor: anchorEnd ? "end" : "start",
|
|
1742
|
+
mono: true,
|
|
1743
|
+
},
|
|
1744
|
+
);
|
|
1745
|
+
elements.push(
|
|
1746
|
+
`<g data-role="meter-latest-label">${latestLabel.markup}</g>`,
|
|
1747
|
+
);
|
|
1460
1748
|
}
|
|
1461
|
-
elements.push(svgText({
|
|
1462
|
-
x: centerX,
|
|
1463
|
-
y: plotBottom + (weekday ? 54 : 40),
|
|
1464
|
-
value: binDateLabel(bin, bounds.timeZone),
|
|
1465
|
-
fill: COLORS.secondary,
|
|
1466
|
-
size: 15,
|
|
1467
|
-
anchor: "middle",
|
|
1468
|
-
}));
|
|
1469
1749
|
}
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
const leaderY = Math.max(pill.y + 6, Math.min(pill.y + pill.h - 6, pill.dotY));
|
|
1474
|
-
elements.push(`<line x1="${pill.dotX.toFixed(2)}" y1="${pill.dotY.toFixed(2)}" x2="${leaderX.toFixed(2)}" y2="${leaderY.toFixed(2)}" stroke="rgba(246,183,60,.58)" stroke-width="1"/>`);
|
|
1475
|
-
elements.push(svgRect(pill.x, pill.y, pill.w, pill.h, {
|
|
1476
|
-
rx: 5,
|
|
1477
|
-
fill: COLORS.background,
|
|
1478
|
-
stroke: COLORS.line,
|
|
1479
|
-
"stroke-width": 1,
|
|
1480
|
-
}));
|
|
1481
|
-
elements.push(svgText({
|
|
1482
|
-
x: pill.tx,
|
|
1483
|
-
y: pill.ty,
|
|
1484
|
-
value: pill.label,
|
|
1485
|
-
fill: COLORS.line,
|
|
1486
|
-
size: 12,
|
|
1487
|
-
weight: 700,
|
|
1488
|
-
anchor: "middle",
|
|
1489
|
-
mono: true,
|
|
1490
|
-
}));
|
|
1750
|
+
|
|
1751
|
+
elements.push(...segmentLabels);
|
|
1752
|
+
return panelTop + panelHeight;
|
|
1491
1753
|
}
|
|
1492
1754
|
|
|
1493
|
-
//
|
|
1494
|
-
|
|
1495
|
-
percentMode ? burn.totals : actual.totals,
|
|
1496
|
-
).map(([model]) => model);
|
|
1497
|
-
let legendX = outer;
|
|
1498
|
-
const legendItem = (swatchMarkup, swatchWidth, label) => {
|
|
1499
|
-
elements.push(swatchMarkup);
|
|
1755
|
+
// ------------------------------------------------------------ lower panels
|
|
1756
|
+
function panelHeading(x, y, title, suffix = null) {
|
|
1500
1757
|
elements.push(svgText({
|
|
1501
|
-
x:
|
|
1502
|
-
y:
|
|
1503
|
-
value:
|
|
1504
|
-
fill: COLORS.
|
|
1505
|
-
size:
|
|
1758
|
+
x: x + 16,
|
|
1759
|
+
y: y + 25,
|
|
1760
|
+
value: title,
|
|
1761
|
+
fill: COLORS.leftAxis,
|
|
1762
|
+
size: 12,
|
|
1763
|
+
weight: 600,
|
|
1764
|
+
spacing: "1.08",
|
|
1506
1765
|
}));
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
if (hasFast && legendModels.length) {
|
|
1517
|
-
legendItem(
|
|
1518
|
-
svgRect(legendX, legendBaseline - 10, 13, 11, {
|
|
1519
|
-
fill: fastShade(styleForModel(legendModels[0])),
|
|
1520
|
-
}),
|
|
1521
|
-
13,
|
|
1522
|
-
"Darker shade = fast mode",
|
|
1523
|
-
);
|
|
1524
|
-
}
|
|
1525
|
-
if (hasLine) {
|
|
1526
|
-
legendItem(
|
|
1527
|
-
svgRect(legendX, legendBaseline - 6, 20, 3, { fill: COLORS.line }),
|
|
1528
|
-
20,
|
|
1529
|
-
percentMode
|
|
1530
|
-
? "Amber = observed meter remaining · columns = estimated model split"
|
|
1531
|
-
: "Weekly meter remaining · observed",
|
|
1532
|
-
);
|
|
1766
|
+
if (suffix) {
|
|
1767
|
+
elements.push(svgText({
|
|
1768
|
+
x: x + 16 + spacedWidth(title, 12, 600, 1.08) + 8,
|
|
1769
|
+
y: y + 25,
|
|
1770
|
+
value: suffix,
|
|
1771
|
+
fill: COLORS.muted,
|
|
1772
|
+
size: 11.5,
|
|
1773
|
+
}));
|
|
1774
|
+
}
|
|
1533
1775
|
}
|
|
1534
1776
|
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
if (hasCache) {
|
|
1546
|
-
const cacheLegendItems = [
|
|
1547
|
-
{ swatch: COLORS.cached, label: "Cached" },
|
|
1548
|
-
{ swatch: COLORS.uncached, label: "Uncached" },
|
|
1549
|
-
{
|
|
1550
|
-
swatch: null,
|
|
1551
|
-
label: `${percent(cacheData.rate)} weighted · ${compact(cacheData.cachedInputTokens)} of ${compact(cacheData.inputTokens)} input cached`,
|
|
1552
|
-
},
|
|
1553
|
-
];
|
|
1554
|
-
let cacheLegendX = contentRight - cacheLegendItems.reduce(
|
|
1555
|
-
(sum, item) =>
|
|
1556
|
-
sum + (item.swatch ? 20 : 0) + textWidth(item.label, 12.5) + 18,
|
|
1557
|
-
-18,
|
|
1777
|
+
function buildCacheByDayPanel(x, y, panelWidth, panelHeight) {
|
|
1778
|
+
const hourlyMode = meta.granularity === "hour" &&
|
|
1779
|
+
Array.isArray(vm.hourly) &&
|
|
1780
|
+
vm.hourly.length > 0;
|
|
1781
|
+
const hourlyRows = hourlyMode ? hourlyChartRows(vm.hourly, timeZone) : null;
|
|
1782
|
+
panelHeading(
|
|
1783
|
+
x,
|
|
1784
|
+
y,
|
|
1785
|
+
hourlyMode ? "CACHE EFFICIENCY BY HOUR" : "CACHE EFFICIENCY BY DAY",
|
|
1786
|
+
"(input-weighted)",
|
|
1558
1787
|
);
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1788
|
+
const inner = panelWidth - 32;
|
|
1789
|
+
const left = x + 16;
|
|
1790
|
+
|
|
1791
|
+
const binSize = hourlyMode
|
|
1792
|
+
? 1
|
|
1793
|
+
: chooseBinSize(meta.rangeDays, inner, {
|
|
1794
|
+
minBinWidth: 18,
|
|
1795
|
+
preferDaily: true,
|
|
1796
|
+
});
|
|
1797
|
+
const cacheBins = hourlyMode ? hourlyRows : binDailyRows(vm.daily, binSize);
|
|
1798
|
+
const rated = cacheBins.filter((bin) => bin.inputTokens > 0);
|
|
1799
|
+
if (!rated.length) {
|
|
1567
1800
|
elements.push(svgText({
|
|
1568
|
-
x:
|
|
1569
|
-
y:
|
|
1570
|
-
value:
|
|
1801
|
+
x: left,
|
|
1802
|
+
y: y + 60,
|
|
1803
|
+
value: "No measured input-token breakdown in this range",
|
|
1571
1804
|
fill: COLORS.muted,
|
|
1572
1805
|
size: 12.5,
|
|
1573
1806
|
}));
|
|
1574
|
-
|
|
1807
|
+
return;
|
|
1575
1808
|
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1809
|
+
|
|
1810
|
+
const rates = rated.map((bin) => (bin.cachedInputTokens / bin.inputTokens) * 100);
|
|
1811
|
+
const minRate = Math.min(...rates);
|
|
1812
|
+
// Zoomed axis: at least a 20-point span, floor snapped to tens, 0–80.
|
|
1813
|
+
let floor = Math.max(0, Math.min(80, Math.floor((minRate - 5) / 10) * 10));
|
|
1814
|
+
floor = Math.min(floor, 80);
|
|
1815
|
+
if (floor > 0) {
|
|
1579
1816
|
elements.push(svgText({
|
|
1580
|
-
x:
|
|
1581
|
-
y: y +
|
|
1582
|
-
value:
|
|
1817
|
+
x: x + panelWidth - 16,
|
|
1818
|
+
y: y + 25,
|
|
1819
|
+
value: "ZOOMED SCALE",
|
|
1583
1820
|
fill: COLORS.muted,
|
|
1584
|
-
size:
|
|
1821
|
+
size: 9.5,
|
|
1822
|
+
weight: 600,
|
|
1823
|
+
spacing: "0.65",
|
|
1824
|
+
anchor: "end",
|
|
1825
|
+
}));
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
const lineTop = y + 52;
|
|
1829
|
+
const lineHeight = 64;
|
|
1830
|
+
const lineBottom = lineTop + lineHeight;
|
|
1831
|
+
const axisLabels = [
|
|
1832
|
+
{ value: 100, y: lineTop },
|
|
1833
|
+
{ value: (100 + floor) / 2, y: lineTop + lineHeight / 2 },
|
|
1834
|
+
{ value: floor, y: lineBottom },
|
|
1835
|
+
];
|
|
1836
|
+
const axisWidth = 34;
|
|
1837
|
+
for (const label of axisLabels) {
|
|
1838
|
+
elements.push(svgText({
|
|
1839
|
+
x: left + axisWidth - 6,
|
|
1840
|
+
y: label.y + 4,
|
|
1841
|
+
value: `${Math.round(label.value)}%`,
|
|
1842
|
+
fill: COLORS.muted,
|
|
1843
|
+
size: 10.5,
|
|
1585
1844
|
anchor: "end",
|
|
1586
1845
|
mono: true,
|
|
1587
1846
|
}));
|
|
1847
|
+
elements.push(svgLine(left + axisWidth, label.y, x + panelWidth - 16, label.y, {
|
|
1848
|
+
stroke: COLORS.grid,
|
|
1849
|
+
"stroke-width": 1,
|
|
1850
|
+
}));
|
|
1588
1851
|
}
|
|
1589
|
-
const
|
|
1590
|
-
const
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1852
|
+
const chartLeft = left + axisWidth + 6;
|
|
1853
|
+
const chartWidth = x + panelWidth - 16 - chartLeft;
|
|
1854
|
+
const slot = chartWidth / cacheBins.length;
|
|
1855
|
+
const yForRate = (rate) =>
|
|
1856
|
+
lineBottom - ((Math.max(floor, Math.min(100, rate)) - floor) / (100 - floor)) * lineHeight;
|
|
1857
|
+
|
|
1858
|
+
const linePoints = [];
|
|
1859
|
+
cacheBins.forEach((bin, index) => {
|
|
1860
|
+
if (!(bin.inputTokens > 0)) {
|
|
1861
|
+
linePoints.push(null);
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
linePoints.push({
|
|
1865
|
+
x: chartLeft + (index + 0.5) * slot,
|
|
1866
|
+
y: yForRate((bin.cachedInputTokens / bin.inputTokens) * 100),
|
|
1867
|
+
rate: (bin.cachedInputTokens / bin.inputTokens) * 100,
|
|
1868
|
+
estimated: bin.estimated,
|
|
1869
|
+
});
|
|
1870
|
+
});
|
|
1871
|
+
for (let index = 0; index < linePoints.length - 1; index += 1) {
|
|
1872
|
+
const from = linePoints[index];
|
|
1873
|
+
const to = linePoints[index + 1];
|
|
1874
|
+
if (!from || !to) continue;
|
|
1875
|
+
elements.push(svgLine(from.x, from.y, to.x, to.y, {
|
|
1876
|
+
stroke: COLORS.cache,
|
|
1877
|
+
"stroke-width": 2,
|
|
1878
|
+
"stroke-linecap": "round",
|
|
1879
|
+
}));
|
|
1880
|
+
}
|
|
1881
|
+
const rateLabelStep = cacheBins.length <= 8 ? 1 : Math.ceil(cacheBins.length / 8);
|
|
1882
|
+
const cacheDateLabelIndices = selectDateLabelIndices(cacheBins, {
|
|
1883
|
+
timeZone,
|
|
1884
|
+
slotWidth: slot,
|
|
1885
|
+
labelStep: rateLabelStep,
|
|
1886
|
+
labelSize: 10,
|
|
1887
|
+
labelForBin: hourlyMode
|
|
1888
|
+
? (bin) => bin.hourLabel
|
|
1889
|
+
: (bin) => binDateLabel(bin, timeZone),
|
|
1890
|
+
});
|
|
1891
|
+
linePoints.forEach((point, index) => {
|
|
1892
|
+
if (!point) return;
|
|
1893
|
+
elements.push(`<circle cx="${point.x.toFixed(2)}" cy="${point.y.toFixed(2)}" r="3.2" fill="${COLORS.cache}"/>`);
|
|
1894
|
+
if (cacheDateLabelIndices.has(index)) {
|
|
1895
|
+
elements.push(svgText({
|
|
1896
|
+
x: point.x,
|
|
1897
|
+
y: point.y - 8,
|
|
1898
|
+
value: approximateLabel(pct(point.rate), point.estimated),
|
|
1899
|
+
fill: COLORS.secondary,
|
|
1900
|
+
size: 10.5,
|
|
1901
|
+
anchor: "middle",
|
|
1603
1902
|
}));
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1903
|
+
}
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
// Input-volume columns beneath the rate line.
|
|
1907
|
+
const columnsTop = lineBottom + 26;
|
|
1908
|
+
const columnsHeight = 44;
|
|
1909
|
+
const columnsBottom = columnsTop + columnsHeight;
|
|
1910
|
+
const maxInput = Math.max(...cacheBins.map((bin) => bin.inputTokens), 1);
|
|
1911
|
+
elements.push(svgText({
|
|
1912
|
+
x: left + axisWidth - 6,
|
|
1913
|
+
y: columnsBottom - columnsHeight / 2 + 4,
|
|
1914
|
+
value: "Input",
|
|
1915
|
+
fill: COLORS.muted,
|
|
1916
|
+
size: 10.5,
|
|
1917
|
+
anchor: "end",
|
|
1918
|
+
}));
|
|
1919
|
+
cacheBins.forEach((bin, index) => {
|
|
1920
|
+
const centerX = chartLeft + (index + 0.5) * slot;
|
|
1921
|
+
const columnWidth = Math.min(30, Math.max(8, slot * 0.5));
|
|
1922
|
+
if (bin.unobserved) {
|
|
1923
|
+
elements.push(svgRect(
|
|
1924
|
+
centerX - columnWidth / 2,
|
|
1925
|
+
lineTop,
|
|
1926
|
+
columnWidth,
|
|
1927
|
+
columnsBottom - lineTop,
|
|
1928
|
+
{ fill: "rgba(255,255,255,.025)" },
|
|
1929
|
+
));
|
|
1930
|
+
}
|
|
1931
|
+
const columnHeight = (bin.inputTokens / maxInput) * columnsHeight;
|
|
1932
|
+
if (columnHeight > 0.4) {
|
|
1933
|
+
elements.push(svgRect(centerX - columnWidth / 2, columnsBottom - columnHeight, columnWidth, columnHeight, {
|
|
1934
|
+
fill: COLORS.cache,
|
|
1935
|
+
opacity: 0.85,
|
|
1936
|
+
rx: 1.5,
|
|
1937
|
+
}));
|
|
1938
|
+
}
|
|
1939
|
+
if (cacheDateLabelIndices.has(index)) {
|
|
1940
|
+
if (bin.inputTokens > 0) {
|
|
1941
|
+
elements.push(svgText({
|
|
1942
|
+
x: centerX,
|
|
1943
|
+
y: columnsBottom - columnHeight - 5,
|
|
1944
|
+
value: approximateLabel(compact(bin.inputTokens), bin.estimated),
|
|
1945
|
+
fill: COLORS.muted,
|
|
1946
|
+
size: 10,
|
|
1947
|
+
anchor: "middle",
|
|
1948
|
+
}));
|
|
1613
1949
|
}
|
|
1614
|
-
if (
|
|
1950
|
+
if (bin.unobserved) {
|
|
1615
1951
|
elements.push(svgText({
|
|
1616
1952
|
x: centerX,
|
|
1617
|
-
y:
|
|
1618
|
-
value:
|
|
1619
|
-
fill: COLORS.
|
|
1620
|
-
size:
|
|
1621
|
-
weight:
|
|
1953
|
+
y: columnsBottom + 15,
|
|
1954
|
+
value: "UNOBSERVED",
|
|
1955
|
+
fill: COLORS.muted,
|
|
1956
|
+
size: 8.5,
|
|
1957
|
+
weight: 600,
|
|
1958
|
+
spacing: "0.45",
|
|
1959
|
+
anchor: "middle",
|
|
1960
|
+
}));
|
|
1961
|
+
} else {
|
|
1962
|
+
elements.push(svgText({
|
|
1963
|
+
x: centerX,
|
|
1964
|
+
y: columnsBottom + 15,
|
|
1965
|
+
value: hourlyMode ? bin.hourLabel : binDateLabel(bin, timeZone),
|
|
1966
|
+
fill: COLORS.muted,
|
|
1967
|
+
size: 10,
|
|
1622
1968
|
anchor: "middle",
|
|
1623
|
-
mono: true,
|
|
1624
1969
|
}));
|
|
1625
1970
|
}
|
|
1626
|
-
} else {
|
|
1627
|
-
// No measured input this period: an empty track with a midline dash.
|
|
1628
|
-
elements.push(svgRect(barX, cachePlotTop, cacheBarWidth, cachePlotHeight, {
|
|
1629
|
-
rx: 3,
|
|
1630
|
-
fill: COLORS.cacheTrack,
|
|
1631
|
-
stroke: COLORS.baseline,
|
|
1632
|
-
"stroke-width": 1,
|
|
1633
|
-
}));
|
|
1634
|
-
elements.push(`<line x1="${(barX + 5).toFixed(2)}" y1="${cachePlotTop + cachePlotHeight / 2}" x2="${(barX + cacheBarWidth - 5).toFixed(2)}" y2="${cachePlotTop + cachePlotHeight / 2}" stroke="${COLORS.muted}" stroke-width="1"/>`);
|
|
1635
1971
|
}
|
|
1636
1972
|
});
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
x: plotRight + 8,
|
|
1642
|
-
y: lineY + 4,
|
|
1643
|
-
value: percent(cacheData.rate),
|
|
1644
|
-
fill: COLORS.weighted,
|
|
1645
|
-
size: 11.5,
|
|
1646
|
-
weight: 700,
|
|
1647
|
-
mono: true,
|
|
1648
|
-
}));
|
|
1649
|
-
}
|
|
1650
|
-
} else {
|
|
1973
|
+
|
|
1974
|
+
// Summary strip.
|
|
1975
|
+
const stripY = y + panelHeight - 32;
|
|
1976
|
+
const approxUncached = vm.coverage.estimated;
|
|
1651
1977
|
elements.push(svgText({
|
|
1652
|
-
x:
|
|
1653
|
-
y:
|
|
1654
|
-
value:
|
|
1655
|
-
|
|
1656
|
-
|
|
1978
|
+
x: left,
|
|
1979
|
+
y: stripY + 15,
|
|
1980
|
+
value: truncateToWidth(
|
|
1981
|
+
`${approximateLabel(pct(summary.cacheRatePercent), summary.estimated)} input-weighted · ${approximateLabel(compact(summary.cachedInputTokens), summary.estimated)} of ${approximateLabel(compact(summary.inputTokens), summary.estimated)} input cached · ${approxUncached ? "≈" : ""}${compact(summary.uncachedInputTokens)} uncached`,
|
|
1982
|
+
inner,
|
|
1983
|
+
11,
|
|
1984
|
+
),
|
|
1985
|
+
fill: COLORS.cache,
|
|
1986
|
+
size: 11,
|
|
1657
1987
|
}));
|
|
1658
1988
|
}
|
|
1659
1989
|
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
const leftColumnRight = outer + leftColumnWidth;
|
|
1671
|
-
const dividerX = leftColumnRight + columnGap;
|
|
1672
|
-
const modelColumnX = dividerX + 28;
|
|
1673
|
-
|
|
1674
|
-
const projectHeader = "WHERE IT WENT · TOP PROJECTS";
|
|
1675
|
-
elements.push(svgText({
|
|
1676
|
-
x: outer,
|
|
1677
|
-
y: sectionBaseline,
|
|
1678
|
-
value: projectHeader,
|
|
1679
|
-
fill: COLORS.muted,
|
|
1680
|
-
size: 12,
|
|
1681
|
-
spacing: "1.32",
|
|
1682
|
-
}));
|
|
1683
|
-
const topRows = rows.slice(0, 3);
|
|
1684
|
-
const restRows = rows.slice(3);
|
|
1685
|
-
const topTokens = topRows.reduce((sum, row) => sum + row.totalTokens, 0);
|
|
1686
|
-
const topShare = totalTokens > 0
|
|
1687
|
-
? percent((topTokens / totalTokens) * 100)
|
|
1688
|
-
: "—";
|
|
1689
|
-
const fullProjectSummary = `${rows.length} ${rows.length === 1 ? "project" : "projects"} active · top ${topRows.length} = ${topShare} of tokens`;
|
|
1690
|
-
const shortProjectSummary = `top ${topRows.length} = ${topShare}`;
|
|
1691
|
-
const projectHeaderWidth =
|
|
1692
|
-
textWidth(projectHeader, 12) +
|
|
1693
|
-
Math.max(0, projectHeader.length - 1) * 1.32;
|
|
1694
|
-
const projectSummary = textWidth(fullProjectSummary, 12.5) <=
|
|
1695
|
-
leftColumnRight - outer - projectHeaderWidth - 16
|
|
1696
|
-
? fullProjectSummary
|
|
1697
|
-
: shortProjectSummary;
|
|
1698
|
-
elements.push(svgText({
|
|
1699
|
-
x: leftColumnRight,
|
|
1700
|
-
y: sectionBaseline,
|
|
1701
|
-
value: projectSummary,
|
|
1702
|
-
fill: COLORS.muted,
|
|
1703
|
-
size: 12.5,
|
|
1704
|
-
anchor: "end",
|
|
1705
|
-
}));
|
|
1990
|
+
function buildProjectsPanel(x, y, panelWidth, panelHeight) {
|
|
1991
|
+
panelHeading(x, y, "WHERE IT WENT · TOP PROJECTS");
|
|
1992
|
+
elements.push(svgText({
|
|
1993
|
+
x: x + panelWidth - 16,
|
|
1994
|
+
y: y + 25,
|
|
1995
|
+
value: `${summary.activeProjects} ${summary.activeProjects === 1 ? "project" : "projects"} active`,
|
|
1996
|
+
fill: COLORS.muted,
|
|
1997
|
+
size: 11.5,
|
|
1998
|
+
anchor: "end",
|
|
1999
|
+
}));
|
|
1706
2000
|
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
2001
|
+
const rows = vm.projects.map((row, index) => ({
|
|
2002
|
+
rank: String(index + 1).padStart(2, "0"),
|
|
2003
|
+
name: options.private ? `Project ${index + 1}` : row.displayProject,
|
|
2004
|
+
tokens: row.totalTokens,
|
|
2005
|
+
share: row.sharePercent,
|
|
2006
|
+
estimated: row.estimated,
|
|
2007
|
+
muted: false,
|
|
2008
|
+
}));
|
|
2009
|
+
if (vm.projectRemainder.count > 0) {
|
|
2010
|
+
rows.push({
|
|
2011
|
+
rank: String(rows.length + 1).padStart(2, "0"),
|
|
2012
|
+
name: vm.projectRemainder.count === 1
|
|
2013
|
+
? "1 other project"
|
|
2014
|
+
: `${vm.projectRemainder.count} other projects`,
|
|
2015
|
+
tokens: vm.projectRemainder.totalTokens,
|
|
2016
|
+
share: vm.projectRemainder.sharePercent,
|
|
2017
|
+
estimated: vm.projectRemainder.estimated,
|
|
2018
|
+
muted: true,
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
while (rows.length < PROJECT_PANEL_ROW_COUNT) {
|
|
2022
|
+
rows.push({
|
|
2023
|
+
rank: String(rows.length + 1).padStart(2, "0"),
|
|
2024
|
+
name: rows.length === 0 ? "No project activity in range" : "—",
|
|
2025
|
+
empty: true,
|
|
2026
|
+
muted: true,
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
const rowTop = y + 52;
|
|
2031
|
+
const rowGap = 33;
|
|
2032
|
+
const rankX = x + 16;
|
|
2033
|
+
const nameX = rankX + 26;
|
|
2034
|
+
const pctRight = x + panelWidth - 16;
|
|
2035
|
+
const tokensRight = pctRight - 52;
|
|
2036
|
+
const barWidth = Math.max(56, panelWidth * 0.2);
|
|
2037
|
+
const barX = tokensRight - 66 - barWidth;
|
|
2038
|
+
const nameWidth = barX - nameX - 12;
|
|
2039
|
+
rows.forEach((row, index) => {
|
|
2040
|
+
const centerY = rowTop + index * rowGap + 8;
|
|
2041
|
+
elements.push(`<g data-role="project-row" data-kind="${row.empty ? "placeholder" : "data"}" data-baseline="${centerY + 4}">`);
|
|
1741
2042
|
elements.push(svgText({
|
|
1742
2043
|
x: rankX,
|
|
1743
|
-
y: centerY +
|
|
2044
|
+
y: centerY + 4,
|
|
1744
2045
|
value: row.rank,
|
|
1745
2046
|
fill: COLORS.muted,
|
|
1746
|
-
size:
|
|
2047
|
+
size: 12,
|
|
1747
2048
|
mono: true,
|
|
1748
2049
|
}));
|
|
2050
|
+
elements.push(svgText({
|
|
2051
|
+
x: nameX,
|
|
2052
|
+
y: centerY + 4,
|
|
2053
|
+
value: truncateToWidth(row.name, nameWidth, 13.5, row.muted ? 400 : 700),
|
|
2054
|
+
fill: row.muted ? COLORS.muted : COLORS.ink,
|
|
2055
|
+
size: 13.5,
|
|
2056
|
+
weight: row.muted ? 400 : 700,
|
|
2057
|
+
}));
|
|
2058
|
+
if (!row.empty) {
|
|
2059
|
+
elements.push(svgRect(barX, centerY - 4, barWidth, 9, {
|
|
2060
|
+
rx: 2,
|
|
2061
|
+
fill: COLORS.projectTrack,
|
|
2062
|
+
}));
|
|
2063
|
+
const fillWidth = (Math.max(0, Math.min(100, row.share)) / 100) * barWidth;
|
|
2064
|
+
if (fillWidth > 0) {
|
|
2065
|
+
elements.push(svgRect(barX, centerY - 4, fillWidth, 9, {
|
|
2066
|
+
rx: 2,
|
|
2067
|
+
fill: row.muted ? COLORS.remainderBar : COLORS.leftAxis,
|
|
2068
|
+
}));
|
|
2069
|
+
}
|
|
2070
|
+
elements.push(svgText({
|
|
2071
|
+
x: tokensRight,
|
|
2072
|
+
y: centerY + 4,
|
|
2073
|
+
value: approximateLabel(compact(row.tokens), row.estimated),
|
|
2074
|
+
fill: row.muted ? COLORS.secondary : COLORS.ink,
|
|
2075
|
+
size: 13.5,
|
|
2076
|
+
weight: 700,
|
|
2077
|
+
anchor: "end",
|
|
2078
|
+
}));
|
|
2079
|
+
elements.push(svgText({
|
|
2080
|
+
x: pctRight,
|
|
2081
|
+
y: centerY + 4,
|
|
2082
|
+
value: approximateLabel(pct(row.share), summary.estimated),
|
|
2083
|
+
fill: COLORS.muted,
|
|
2084
|
+
size: 12,
|
|
2085
|
+
anchor: "end",
|
|
2086
|
+
}));
|
|
2087
|
+
}
|
|
2088
|
+
elements.push("</g>");
|
|
2089
|
+
});
|
|
2090
|
+
|
|
2091
|
+
if (summary.topFourProjectSharePercent !== null && vm.projects.length) {
|
|
2092
|
+
const stripY = y + panelHeight - 32;
|
|
2093
|
+
elements.push(svgText({
|
|
2094
|
+
x: x + 16,
|
|
2095
|
+
y: stripY + 15,
|
|
2096
|
+
value: `Top ${Math.min(4, vm.projects.length)} projects = ${approximateLabel(pct(summary.topFourProjectSharePercent), summary.estimated)} of tokens`,
|
|
2097
|
+
fill: COLORS.leftAxis,
|
|
2098
|
+
size: 11.5,
|
|
2099
|
+
}));
|
|
1749
2100
|
}
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
}));
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function buildModelCachePanel(x, y, panelWidth, panelHeight) {
|
|
2104
|
+
panelHeading(x, y, "CACHE EFFICIENCY BY MODEL", "(input-weighted)");
|
|
2105
|
+
|
|
2106
|
+
// Combine minor models past the fourth row so the table always fits.
|
|
2107
|
+
const source = vm.models.filter((row) => row.totalTokens > 0);
|
|
2108
|
+
const rows = source.slice(0, 4).map((row) => ({ ...row }));
|
|
2109
|
+
const overflow = source.slice(4);
|
|
2110
|
+
if (overflow.length) {
|
|
2111
|
+
const merged = overflow.reduce(
|
|
2112
|
+
(sum, row) => {
|
|
2113
|
+
sum.cacheInputTokens += row.cacheInputTokens;
|
|
2114
|
+
sum.cachedInputTokens += row.cachedInputTokens;
|
|
2115
|
+
sum.estimated ||= row.estimated === true;
|
|
2116
|
+
return sum;
|
|
2117
|
+
},
|
|
2118
|
+
{
|
|
2119
|
+
model: `${overflow.length} other models`,
|
|
2120
|
+
cacheInputTokens: 0,
|
|
2121
|
+
cachedInputTokens: 0,
|
|
2122
|
+
estimated: false,
|
|
2123
|
+
},
|
|
2124
|
+
);
|
|
2125
|
+
// A single overflow row is still one identifiable model. Naming it
|
|
2126
|
+
// directly avoids making its cache rate look like an unexplained
|
|
2127
|
+
// aggregate; reserve the aggregate label for real multi-model overflow.
|
|
2128
|
+
merged.model = overflow.length === 1
|
|
2129
|
+
? overflow[0].model
|
|
2130
|
+
: `${overflow.length} other models`;
|
|
2131
|
+
merged.uncachedInputTokens = Math.max(
|
|
2132
|
+
0,
|
|
2133
|
+
merged.cacheInputTokens - merged.cachedInputTokens,
|
|
2134
|
+
);
|
|
2135
|
+
merged.cacheRatePercent = merged.cacheInputTokens > 0
|
|
2136
|
+
? (merged.cachedInputTokens / merged.cacheInputTokens) * 100
|
|
2137
|
+
: null;
|
|
2138
|
+
merged.combined = overflow.length > 1;
|
|
2139
|
+
rows.push(merged);
|
|
2140
|
+
}
|
|
2141
|
+
if (!rows.length) {
|
|
2142
|
+
elements.push(svgText({
|
|
2143
|
+
x: x + 16,
|
|
2144
|
+
y: y + 60,
|
|
2145
|
+
value: "No measured input-token breakdown in this range",
|
|
2146
|
+
fill: COLORS.muted,
|
|
2147
|
+
size: 12.5,
|
|
1768
2148
|
}));
|
|
2149
|
+
return;
|
|
1769
2150
|
}
|
|
2151
|
+
|
|
2152
|
+
const inputRight = x + panelWidth - 104;
|
|
2153
|
+
const uncachedRight = x + panelWidth - 16;
|
|
1770
2154
|
elements.push(svgText({
|
|
1771
|
-
x:
|
|
1772
|
-
y:
|
|
1773
|
-
value:
|
|
1774
|
-
fill:
|
|
1775
|
-
size:
|
|
1776
|
-
|
|
2155
|
+
x: inputRight,
|
|
2156
|
+
y: y + 47,
|
|
2157
|
+
value: "INPUT",
|
|
2158
|
+
fill: COLORS.muted,
|
|
2159
|
+
size: 10.5,
|
|
2160
|
+
spacing: "0.84",
|
|
1777
2161
|
anchor: "end",
|
|
1778
2162
|
}));
|
|
1779
2163
|
elements.push(svgText({
|
|
1780
|
-
x:
|
|
1781
|
-
y:
|
|
1782
|
-
value:
|
|
2164
|
+
x: uncachedRight,
|
|
2165
|
+
y: y + 47,
|
|
2166
|
+
value: "UNCACHED",
|
|
1783
2167
|
fill: COLORS.muted,
|
|
1784
|
-
size:
|
|
2168
|
+
size: 10.5,
|
|
2169
|
+
spacing: "0.84",
|
|
1785
2170
|
anchor: "end",
|
|
1786
2171
|
}));
|
|
1787
|
-
});
|
|
1788
2172
|
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
x
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
2173
|
+
const rowTop = y + 64;
|
|
2174
|
+
const rowGap = 33;
|
|
2175
|
+
const nameX = x + 30;
|
|
2176
|
+
const rateX = x + Math.min(150, panelWidth * 0.34);
|
|
2177
|
+
const barX = rateX + 52;
|
|
2178
|
+
const barWidth = Math.max(44, inputRight - 66 - barX);
|
|
2179
|
+
rows.forEach((row, index) => {
|
|
2180
|
+
const centerY = rowTop + index * rowGap;
|
|
2181
|
+
elements.push(`<g data-role="model-cache-row" data-baseline="${centerY}">`);
|
|
2182
|
+
if (!row.combined) {
|
|
2183
|
+
elements.push(`<circle cx="${x + 19}" cy="${centerY - 4}" r="4" fill="${styleForModel(row.model)}"/>`);
|
|
2184
|
+
}
|
|
2185
|
+
elements.push(svgText({
|
|
2186
|
+
x: nameX,
|
|
2187
|
+
y: centerY,
|
|
2188
|
+
value: truncateToWidth(row.model, rateX - nameX - 8, 13, row.combined ? 400 : 600),
|
|
2189
|
+
fill: row.combined ? COLORS.muted : COLORS.ink,
|
|
2190
|
+
size: 13,
|
|
2191
|
+
weight: row.combined ? 400 : 600,
|
|
2192
|
+
}));
|
|
2193
|
+
const hasComponents = row.cacheRatePercent !== null;
|
|
2194
|
+
elements.push(svgText({
|
|
2195
|
+
x: rateX + 44,
|
|
2196
|
+
y: centerY,
|
|
2197
|
+
value: hasComponents
|
|
2198
|
+
? approximateLabel(pct(row.cacheRatePercent), row.estimated)
|
|
2199
|
+
: "—",
|
|
2200
|
+
fill: COLORS.ink,
|
|
2201
|
+
size: 13,
|
|
2202
|
+
weight: 700,
|
|
2203
|
+
anchor: "end",
|
|
2204
|
+
mono: true,
|
|
2205
|
+
}));
|
|
2206
|
+
if (hasComponents && barWidth > 30) {
|
|
2207
|
+
elements.push(svgRect(barX, centerY - 8, barWidth, 8, {
|
|
2208
|
+
rx: 2,
|
|
2209
|
+
fill: COLORS.track,
|
|
2210
|
+
}));
|
|
2211
|
+
const cachedWidth = (row.cacheRatePercent / 100) * barWidth;
|
|
2212
|
+
if (cachedWidth > 0) {
|
|
2213
|
+
elements.push(svgRect(barX, centerY - 8, cachedWidth, 8, {
|
|
2214
|
+
rx: 2,
|
|
2215
|
+
fill: COLORS.cache,
|
|
2216
|
+
}));
|
|
2217
|
+
}
|
|
2218
|
+
if (barWidth - cachedWidth > 0.5) {
|
|
2219
|
+
elements.push(svgRect(barX + cachedWidth, centerY - 8, barWidth - cachedWidth, 8, {
|
|
2220
|
+
fill: COLORS.uncached,
|
|
2221
|
+
rx: 2,
|
|
2222
|
+
}));
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
elements.push(svgText({
|
|
2226
|
+
x: inputRight,
|
|
2227
|
+
y: centerY,
|
|
2228
|
+
value: hasComponents
|
|
2229
|
+
? approximateLabel(compact(row.cacheInputTokens), row.estimated)
|
|
2230
|
+
: "—",
|
|
2231
|
+
fill: COLORS.secondary,
|
|
2232
|
+
size: 12.5,
|
|
2233
|
+
anchor: "end",
|
|
2234
|
+
mono: true,
|
|
2235
|
+
}));
|
|
2236
|
+
elements.push(svgText({
|
|
2237
|
+
x: uncachedRight,
|
|
2238
|
+
y: centerY,
|
|
2239
|
+
value: hasComponents
|
|
2240
|
+
? approximateLabel(compact(row.uncachedInputTokens), row.estimated)
|
|
2241
|
+
: "—",
|
|
2242
|
+
fill: COLORS.secondary,
|
|
2243
|
+
size: 12.5,
|
|
2244
|
+
anchor: "end",
|
|
2245
|
+
mono: true,
|
|
2246
|
+
}));
|
|
2247
|
+
elements.push("</g>");
|
|
2248
|
+
});
|
|
2249
|
+
|
|
1799
2250
|
elements.push(svgText({
|
|
1800
|
-
x:
|
|
1801
|
-
y:
|
|
1802
|
-
value: "
|
|
1803
|
-
fill: COLORS.
|
|
1804
|
-
size:
|
|
2251
|
+
x: x + 16,
|
|
2252
|
+
y: y + panelHeight - 15,
|
|
2253
|
+
value: "Uncached = input not served from cache (input-weighted)",
|
|
2254
|
+
fill: COLORS.muted,
|
|
2255
|
+
size: 11,
|
|
1805
2256
|
}));
|
|
1806
2257
|
}
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
(
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
)
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
elements.push(`<circle cx="${(modelColumnX + 5).toFixed(2)}" cy="${centerY}" r="4" fill="${styleForModel(model.model)}"/>`);
|
|
1837
|
-
}
|
|
1838
|
-
elements.push(svgText({
|
|
1839
|
-
x: modelColumnX + 18,
|
|
1840
|
-
y: centerY + 4,
|
|
1841
|
-
value: model.model,
|
|
1842
|
-
fill: model.muted ? COLORS.muted : COLORS.ink,
|
|
1843
|
-
size: modelLabelSize,
|
|
1844
|
-
weight: model.muted ? 400 : 700,
|
|
1845
|
-
}));
|
|
1846
|
-
elements.push(svgText({
|
|
1847
|
-
x: modelRateRight,
|
|
1848
|
-
y: centerY + 4,
|
|
1849
|
-
value: percent(model.rate),
|
|
1850
|
-
fill: COLORS.secondary,
|
|
1851
|
-
size: modelRateSize,
|
|
1852
|
-
weight: 700,
|
|
1853
|
-
anchor: "end",
|
|
1854
|
-
mono: true,
|
|
1855
|
-
}));
|
|
1856
|
-
elements.push(svgRect(modelBarX, centerY - 5, modelBarWidth, 10, {
|
|
1857
|
-
rx: 3,
|
|
1858
|
-
fill: COLORS.uncached,
|
|
1859
|
-
opacity: ".7",
|
|
1860
|
-
}));
|
|
1861
|
-
const rateFill = Number.isFinite(model.rate)
|
|
1862
|
-
? modelBarWidth * (model.rate / 100)
|
|
1863
|
-
: 0;
|
|
1864
|
-
if (rateFill > 0) {
|
|
1865
|
-
elements.push(svgRect(modelBarX, centerY - 5, rateFill, 10, {
|
|
1866
|
-
rx: 3,
|
|
1867
|
-
fill: COLORS.cached,
|
|
1868
|
-
}));
|
|
2258
|
+
|
|
2259
|
+
function buildLowerSection(top) {
|
|
2260
|
+
const gap = 14;
|
|
2261
|
+
const projectRowCount = PROJECT_PANEL_ROW_COUNT;
|
|
2262
|
+
const modelRowCount = Math.min(5, vm.models.filter((r) => r.totalTokens > 0).length || 1);
|
|
2263
|
+
const cacheHeight = 258;
|
|
2264
|
+
const projectsHeight = Math.max(150, 44 + projectRowCount * 33 + 46);
|
|
2265
|
+
const modelHeight = Math.max(150, 64 + modelRowCount * 33 + 30);
|
|
2266
|
+
if (wide) {
|
|
2267
|
+
const height = Math.max(cacheHeight, projectsHeight, modelHeight);
|
|
2268
|
+
const cacheWidth = contentWidth * 0.36;
|
|
2269
|
+
const projectsWidth = contentWidth * 0.31 - gap;
|
|
2270
|
+
const modelWidth = contentWidth - cacheWidth - projectsWidth - gap * 2;
|
|
2271
|
+
addVerticalDivider(
|
|
2272
|
+
outer + cacheWidth + gap / 2,
|
|
2273
|
+
top,
|
|
2274
|
+
height,
|
|
2275
|
+
"lower-column-divider",
|
|
2276
|
+
);
|
|
2277
|
+
addVerticalDivider(
|
|
2278
|
+
outer + cacheWidth + gap + projectsWidth + gap / 2,
|
|
2279
|
+
top,
|
|
2280
|
+
height,
|
|
2281
|
+
"lower-column-divider",
|
|
2282
|
+
);
|
|
2283
|
+
buildCacheByDayPanel(outer, top, cacheWidth, height);
|
|
2284
|
+
buildProjectsPanel(outer + cacheWidth + gap, top, projectsWidth, height);
|
|
2285
|
+
buildModelCachePanel(outer + cacheWidth + projectsWidth + gap * 2, top, modelWidth, height);
|
|
2286
|
+
return top + height;
|
|
1869
2287
|
}
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
2288
|
+
buildCacheByDayPanel(outer, top, contentWidth, cacheHeight);
|
|
2289
|
+
const rowTop = top + cacheHeight + gap;
|
|
2290
|
+
const half = (contentWidth - gap) / 2;
|
|
2291
|
+
const rowHeight = Math.max(projectsHeight, modelHeight);
|
|
2292
|
+
addVerticalDivider(
|
|
2293
|
+
outer + half + gap / 2,
|
|
2294
|
+
rowTop,
|
|
2295
|
+
rowHeight,
|
|
2296
|
+
"lower-column-divider",
|
|
2297
|
+
);
|
|
2298
|
+
buildProjectsPanel(outer, rowTop, half, rowHeight);
|
|
2299
|
+
buildModelCachePanel(outer + half + gap, rowTop, half, rowHeight);
|
|
2300
|
+
return rowTop + rowHeight;
|
|
2301
|
+
}
|
|
1881
2302
|
|
|
1882
|
-
|
|
1883
|
-
|
|
2303
|
+
// ---------------------------------------------------------------- compose
|
|
2304
|
+
const body = [];
|
|
2305
|
+
const headerBottom = buildHeaderSection();
|
|
2306
|
+
const kpiBottom = buildKpiSection(headerBottom + 10);
|
|
2307
|
+
const mixBottom = buildModelMixSection(kpiBottom + 10);
|
|
2308
|
+
const chartBottom = buildDailyChartSection(mixBottom + 10);
|
|
2309
|
+
const lowerBottom = buildLowerSection(chartBottom + 14);
|
|
2310
|
+
const height = Math.ceil(lowerBottom + 16);
|
|
2311
|
+
|
|
2312
|
+
const description =
|
|
2313
|
+
"Dark report card: total usage, input-weighted cache efficiency, fast-mode share, and active-project KPI cards beside the sampled weekly-limit state; a model-mix strip; stacked daily token columns by model with hatched fast-mode overlays and the sampled weekly meter drawn as solid confirmed intervals and dashed unobserved gaps; plus daily cache efficiency, top projects, and per-model cache tables.";
|
|
2314
|
+
body.push(
|
|
2315
|
+
`<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">`,
|
|
2316
|
+
`<title id="trend-title">${escapeXml(`Token Ledger · ${meta.rangeDays}-day trend`)}</title>`,
|
|
2317
|
+
`<desc id="trend-description">${escapeXml(description)}</desc>`,
|
|
2318
|
+
defs,
|
|
2319
|
+
`<rect width="100%" height="100%" fill="${COLORS.background}"/>`,
|
|
2320
|
+
...elements,
|
|
2321
|
+
"</svg>",
|
|
2322
|
+
);
|
|
2323
|
+
return body.join("\n");
|
|
1884
2324
|
}
|
|
1885
2325
|
|
|
1886
2326
|
export async function writeTrendPng(svg, outputPath) {
|