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