tledger 0.3.0 → 0.4.0

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