tledger 0.2.1 → 0.3.1

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