nilometer 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/LICENSE +21 -0
- package/README.md +462 -2
- package/core/pricing/prices.json +348 -0
- package/core/schema/001_ingestion.sql +57 -0
- package/core/schema/002_line_count.sql +7 -0
- package/core/schema/003_derived.sql +70 -0
- package/core/schema/004_requests_dedup.sql +50 -0
- package/core/schema/005_repositories.sql +25 -0
- package/core/schema/006_status_readings.sql +33 -0
- package/core/schema/007_pricing.sql +95 -0
- package/core/schema/008_interruptions.sql +263 -0
- package/core/schema/009_headroom.sql +84 -0
- package/core/schema/010_attribution.sql +106 -0
- package/core/schema/011_projections.sql +83 -0
- package/core/schema/012_unsplit_cache_writes.sql +49 -0
- package/core/schema/013_repository_revalidation.sql +104 -0
- package/core/schema/014_observed_readings.sql +204 -0
- package/core/schema/015_response_observations.sql +127 -0
- package/core/schema/016_windows_repository_paths.sql +41 -0
- package/core/schema/017_unattributed_usage_once.sql +47 -0
- package/core/schema/018_request_time_index.sql +21 -0
- package/core/schema/019_dedup_earliest_of_equals.sql +61 -0
- package/core/schema/020_limit_quota_fields.sql +131 -0
- package/core/schema/021_plan_periods.sql +121 -0
- package/core/schema/022_attachment_walk.sql +223 -0
- package/core/schema/023_auto_continuations.sql +129 -0
- package/core/schema/024_counted_limit_groups.sql +29 -0
- package/core/schema/025_model_fallback.sql +77 -0
- package/core/schema/026_changed_readings.sql +106 -0
- package/core/schema/027_weekly_crossings.sql +32 -0
- package/core/schema/028_stale_readings.sql +196 -0
- package/core/schema/029_lockout_sessions.sql +68 -0
- package/core/schema/030_repository_aliases.sql +81 -0
- package/core/schema/031_cte_cursor_budget.sql +153 -0
- package/dist/cli/main.cli.js +39 -0
- package/dist/cli/program.js +801 -0
- package/dist/core/db/database.js +182 -0
- package/dist/core/db/fingerprint.js +71 -0
- package/dist/core/ingest/attribution.js +131 -0
- package/dist/core/ingest/classify.js +382 -0
- package/dist/core/ingest/command.js +95 -0
- package/dist/core/ingest/derive.js +212 -0
- package/dist/core/ingest/discover.js +126 -0
- package/dist/core/ingest/ingest.js +160 -0
- package/dist/core/ingest/reader.js +89 -0
- package/dist/core/ingest/reset-time.js +97 -0
- package/dist/core/ingest/results.js +201 -0
- package/dist/core/ingest/spool.js +165 -0
- package/dist/core/ingest/store.js +99 -0
- package/dist/core/install/install.js +185 -0
- package/dist/core/install/locations.js +89 -0
- package/dist/core/install/private-files.js +259 -0
- package/dist/core/install/record.js +103 -0
- package/dist/core/plans/plan-prices.js +128 -0
- package/dist/core/plans/repository-aliases.js +136 -0
- package/dist/core/pricing/prices.js +147 -0
- package/dist/core/settings/settings-file.js +215 -0
- package/dist/core/settings/statusline.js +240 -0
- package/dist/core/util/commands.js +91 -0
- package/dist/core/util/node-version.js +58 -0
- package/dist/core/util/open-page.js +69 -0
- package/dist/core/util/paths.js +36 -0
- package/dist/core/verify/compare.js +77 -0
- package/dist/core/verify/verify.js +220 -0
- package/dist/viewer/explain.js +574 -0
- package/dist/viewer/format.js +150 -0
- package/dist/viewer/html-charts.js +1292 -0
- package/dist/viewer/html-contract.js +2 -0
- package/dist/viewer/html-data.js +718 -0
- package/dist/viewer/html.js +1654 -0
- package/dist/viewer/queries.js +128 -0
- package/dist/viewer/render.js +602 -0
- package/dist/viewer/report.js +120 -0
- package/dist/viewer/save.js +110 -0
- package/docs/requirements.md +113 -0
- package/hooks/statusline.sh +104 -0
- package/package.json +61 -4
|
@@ -0,0 +1,1292 @@
|
|
|
1
|
+
import { UNKNOWN, formatCoverage, formatDuration, formatInstant, formatNumber, printable, } from "./format.js";
|
|
2
|
+
import { LABELS, autoContinuedText, lockoutHitsText, describeBurnRate, displayPath, planPeriodMonths, planPeriodName, plural, repoKindName, windowName, } from "./render.js";
|
|
3
|
+
/** The percentage plot shared by the gauge and a window's line, so both read on the same ruler. */
|
|
4
|
+
const PLOT = {
|
|
5
|
+
/**
|
|
6
|
+
* Room above the plot: the 100% key on the first row, group or reset labels on the second, and
|
|
7
|
+
* hit flags below that. Labels drawn on the plot itself collided with columns and the reset.
|
|
8
|
+
*/
|
|
9
|
+
top: 56,
|
|
10
|
+
/** Baseline of the first label row above the plot (the 100% key). */
|
|
11
|
+
keyRow: 14,
|
|
12
|
+
/** Baseline of the second label row above the plot (group names, the reset). */
|
|
13
|
+
labelRow: 34,
|
|
14
|
+
/** Plot height; 220 makes a 0 to 110% scale exactly 2 units per percentage point. */
|
|
15
|
+
height: 220,
|
|
16
|
+
/** Room left of the plot for the percentage labels. */
|
|
17
|
+
left: 44,
|
|
18
|
+
/** Room right of the plot. */
|
|
19
|
+
right: 12,
|
|
20
|
+
/** Room below the plot for the column labels and the span caption. */
|
|
21
|
+
bottom: 60,
|
|
22
|
+
};
|
|
23
|
+
/** Most minor ticks the percentage scale draws before it widens its step, so it stays legible. */
|
|
24
|
+
const MAX_MINOR_TICKS = 24;
|
|
25
|
+
/** Widest a gauge column is drawn, in viewBox units (thin columns; the slot's leftover is air). */
|
|
26
|
+
const MAX_COLUMN = 24;
|
|
27
|
+
/** Space between the 5-hour group and the weekly group of gauge columns. */
|
|
28
|
+
const GROUP_GAP = 24;
|
|
29
|
+
/** Closest two column labels in one row may sit before the later one is left out. */
|
|
30
|
+
const MIN_LABEL_GAP = 40;
|
|
31
|
+
/** Label text for the user's own 100% line: the only limit any usage chart draws (D-009, D-070). */
|
|
32
|
+
const LIMIT_LABEL = "100%, your plan's limit";
|
|
33
|
+
/** Window order in the gauge: the 5-hour group reads first, then the weekly group. */
|
|
34
|
+
const WINDOW_ORDER = ["five_hour", "seven_day"];
|
|
35
|
+
/** What each coverage source is called and what it covers, as the text report names them. */
|
|
36
|
+
const SOURCES = {
|
|
37
|
+
session_logs: { name: "Session logs", covers: "tokens, models, and limit hits" },
|
|
38
|
+
status_line: { name: "Status line", covers: "usage windows" },
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Escapes text for an SVG text node or a double-quoted attribute value, keeping the output ASCII.
|
|
42
|
+
* @param value - Any text, including model names from the logs.
|
|
43
|
+
* @returns The text with control characters written as visible escapes (D-050: a raw control
|
|
44
|
+
* character isn't even well-formed XML), `&`, `<`, `>`, `"` as entities, and every other
|
|
45
|
+
* character outside printable ASCII as a numeric character reference (D-063).
|
|
46
|
+
*/
|
|
47
|
+
function esc(value) {
|
|
48
|
+
return (printable(value)
|
|
49
|
+
// `&` first, so the references written below and the entities after it aren't escaped twice.
|
|
50
|
+
.replaceAll("&", "&")
|
|
51
|
+
.replaceAll("<", "<")
|
|
52
|
+
.replaceAll(">", ">")
|
|
53
|
+
.replaceAll('"', """)
|
|
54
|
+
// A model name from the logs may hold anything; the browser shows the reference as the same
|
|
55
|
+
// character, so the page keeps the name while its source stays ASCII (D-063). The `u` flag
|
|
56
|
+
// takes a character outside the Basic Multilingual Plane as one match, one reference.
|
|
57
|
+
.replace(/[^\x20-\x7e]/gu, reference));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Writes one character as a numeric character reference.
|
|
61
|
+
* @param char - One code point outside printable ASCII (control characters are already escaped).
|
|
62
|
+
* @returns `&#x<hex>;`; a lone surrogate, which no reference may name in well-formed XML (D-050),
|
|
63
|
+
* is written as the replacement character U+FFFD, as a browser would show it.
|
|
64
|
+
*/
|
|
65
|
+
function reference(char) {
|
|
66
|
+
const code = char.codePointAt(0);
|
|
67
|
+
const shown = code >= 0xd800 && code <= 0xdfff ? 0xfffd : code;
|
|
68
|
+
return `&#x${shown.toString(16)};`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Writes a coordinate. Rounding here is display rounding: every position is computed unrounded.
|
|
72
|
+
* @param value - A coordinate or length in viewBox units.
|
|
73
|
+
* @returns At most two decimals, trailing zeros dropped; `-0` is written `0`.
|
|
74
|
+
*/
|
|
75
|
+
function num(value) {
|
|
76
|
+
return String(Math.round(value * 100) / 100);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Writes an element's attributes.
|
|
80
|
+
* @param attributes - Name to value; numbers are coordinates, strings are escaped.
|
|
81
|
+
* @returns ` name="value"` pairs, in insertion order, skipping undefined values.
|
|
82
|
+
*/
|
|
83
|
+
function attrs(attributes) {
|
|
84
|
+
return Object.entries(attributes)
|
|
85
|
+
.filter((entry) => entry[1] !== undefined)
|
|
86
|
+
.map(([name, value]) => ` ${name}="${typeof value === "number" ? num(value) : esc(value)}"`)
|
|
87
|
+
.join("");
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Writes one SVG element.
|
|
91
|
+
* @param tag - Element name.
|
|
92
|
+
* @param attributes - Its attributes.
|
|
93
|
+
* @param children - SVG text inside it, already escaped; empty writes a self-closing element.
|
|
94
|
+
* @returns The element as SVG text.
|
|
95
|
+
*/
|
|
96
|
+
function el(tag, attributes, children = "") {
|
|
97
|
+
return children === ""
|
|
98
|
+
? `<${tag}${attrs(attributes)}/>`
|
|
99
|
+
: `<${tag}${attrs(attributes)}>${children}</${tag}>`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Writes a `<title>` child, which browsers show on hover and screen readers read.
|
|
103
|
+
* @param text - What the element is and its value, in the report's wording.
|
|
104
|
+
* @returns The escaped element.
|
|
105
|
+
*/
|
|
106
|
+
function title(text) {
|
|
107
|
+
return `<title>${esc(text)}</title>`;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Writes a text element in a text color (series colors are for drawn elements only).
|
|
111
|
+
* @param x - Anchor x.
|
|
112
|
+
* @param y - Baseline y.
|
|
113
|
+
* @param content - The text, escaped here.
|
|
114
|
+
* @param attributes - Extra attributes; `fill` defaults to the muted text color.
|
|
115
|
+
* @returns The element as SVG text.
|
|
116
|
+
*/
|
|
117
|
+
function text(x, y, content, attributes = {}) {
|
|
118
|
+
return el("text", { x, y, fill: "var(--muted)", ...attributes }, esc(content));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Writes the `data-events` value for a drawn element (principle 4).
|
|
122
|
+
* @param ids - Raw line IDs.
|
|
123
|
+
* @returns The IDs separated by single spaces; empty when there are none.
|
|
124
|
+
*/
|
|
125
|
+
function eventList(ids) {
|
|
126
|
+
return ids.join(" ");
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Merges event lists for a container that stands for several drawn elements.
|
|
130
|
+
* @param lists - Event lists.
|
|
131
|
+
* @returns Every ID once, ascending.
|
|
132
|
+
*/
|
|
133
|
+
function union(...lists) {
|
|
134
|
+
return [...new Set(lists.flat())].sort((a, b) => a - b);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Finds the earliest and latest of some instants.
|
|
138
|
+
* @param isos - ISO-8601 instants.
|
|
139
|
+
* @returns The earliest and latest as given, or null when there are none.
|
|
140
|
+
*/
|
|
141
|
+
function extent(isos) {
|
|
142
|
+
if (isos.length === 0) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
// Compare as instants, not as text, so a differently written offset can't reorder them.
|
|
146
|
+
const sorted = [...isos].sort((a, b) => Date.parse(a) - Date.parse(b));
|
|
147
|
+
return { from: sorted[0], to: sorted[sorted.length - 1] };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Writes a chart's root element.
|
|
151
|
+
* @param kind - Short chart name; the root gets class `nm-chart-<kind>`, apart from the element classes.
|
|
152
|
+
* @param options - Width and zone.
|
|
153
|
+
* @param height - viewBox height.
|
|
154
|
+
* @param label - The accessible name: what the chart shows and its span.
|
|
155
|
+
* @param body - Child SVG text.
|
|
156
|
+
* @param extra - Extra root attributes (the window series carries `data-series`).
|
|
157
|
+
* @returns The `<svg>` element.
|
|
158
|
+
*/
|
|
159
|
+
function svg(kind, options, height, label, body, extra = {}) {
|
|
160
|
+
// No xmlns: the element is embedded inline in HTML, which needs none, and the namespace URI
|
|
161
|
+
// would be the only URL in a file that promises to make no network requests (D-070).
|
|
162
|
+
return el("svg", {
|
|
163
|
+
class: `nm-chart nm-chart-${kind}`,
|
|
164
|
+
viewBox: `0 0 ${num(options.width)} ${num(height)}`,
|
|
165
|
+
preserveAspectRatio: "xMinYMin meet",
|
|
166
|
+
role: "img",
|
|
167
|
+
"aria-label": label,
|
|
168
|
+
"font-size": 11,
|
|
169
|
+
...extra,
|
|
170
|
+
}, body.join(""));
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Writes the chart shown for empty input: a label and no drawn data elements.
|
|
174
|
+
* @param kind - Short chart name.
|
|
175
|
+
* @param options - Width and zone.
|
|
176
|
+
* @param label - What the chart would show.
|
|
177
|
+
* @param extra - Extra root attributes.
|
|
178
|
+
* @returns A small `<svg>` saying "No data".
|
|
179
|
+
*/
|
|
180
|
+
function noData(kind, options, label, extra = {}) {
|
|
181
|
+
return svg(kind, options, 40, `${label}: No data`, [text(0, 24, "No data", { class: "nm-empty" })], extra);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Chooses a percentage scale that reaches above 100% and above every value (D-068).
|
|
185
|
+
*
|
|
186
|
+
* The scale is one minor step past the highest of 100 and the values, so a reading of exactly
|
|
187
|
+
* 100 or 103 still has room above it. Steps run 10, 20, 50, 100, ... so a stray very large valid
|
|
188
|
+
* reading (anything below the epoch check, D-068) widens the ticks instead of drawing hundreds.
|
|
189
|
+
* @param values - Percentages to fit; null and non-finite values are ignored.
|
|
190
|
+
* @returns The scale.
|
|
191
|
+
*/
|
|
192
|
+
function percentScale(values) {
|
|
193
|
+
const finite = values.filter((v) => v !== null && Number.isFinite(v));
|
|
194
|
+
const reach = Math.max(100, ...finite);
|
|
195
|
+
let step = 0;
|
|
196
|
+
let minor = 10;
|
|
197
|
+
while (Math.floor(reach / minor) + 1 > MAX_MINOR_TICKS) {
|
|
198
|
+
step += 1;
|
|
199
|
+
minor = oneTwoFive(step) * 10;
|
|
200
|
+
}
|
|
201
|
+
return { top: (Math.floor(reach / minor) + 1) * minor, minor };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The 1, 2, 5, 10, 20, 50, ... sequence, for tick steps a reader can add up at a glance.
|
|
205
|
+
* @param index - Position in the sequence, from 0.
|
|
206
|
+
* @returns The value at that position.
|
|
207
|
+
*/
|
|
208
|
+
function oneTwoFive(index) {
|
|
209
|
+
const digit = index % 3 === 0 ? 1 : index % 3 === 1 ? 2 : 5;
|
|
210
|
+
return digit * 10 ** Math.floor(index / 3);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Places a percentage on the shared plot.
|
|
214
|
+
* @param scale - The chart's scale.
|
|
215
|
+
* @param percent - A percentage, possibly above 100 (D-068).
|
|
216
|
+
* @returns Its y coordinate: {@link PLOT}.top at the scale's top, top plus height at 0.
|
|
217
|
+
*/
|
|
218
|
+
function percentY(scale, percent) {
|
|
219
|
+
return PLOT.top + ((scale.top - percent) / scale.top) * PLOT.height;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The readings a usage line joins: those that aren't stale (D-089). A stale reading is below an
|
|
223
|
+
* earlier one in its window, so joining it would draw usage falling, which it can't.
|
|
224
|
+
* @param points - A window's readings in time order.
|
|
225
|
+
* @returns The ones the line passes through.
|
|
226
|
+
*/
|
|
227
|
+
function onLine(points) {
|
|
228
|
+
return points.filter((p) => !p.stale);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* A reading's marker: filled on the line, hollow and muted off it when stale (D-089).
|
|
232
|
+
* @param p - The reading.
|
|
233
|
+
* @returns The circle's size and paint.
|
|
234
|
+
*/
|
|
235
|
+
function pointStyle(p) {
|
|
236
|
+
// A ring in the surface color keeps overlapping points apart.
|
|
237
|
+
return p.stale
|
|
238
|
+
? { r: 3, fill: "var(--surface)", stroke: "var(--muted)", "stroke-width": 1.5 }
|
|
239
|
+
: { r: 4, fill: "var(--water)", stroke: "var(--surface)", "stroke-width": 2 };
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* A reading's hover title, saying when it's stale and why it's off the line (D-089).
|
|
243
|
+
* @param p - The reading.
|
|
244
|
+
* @param tz - Display zone.
|
|
245
|
+
* @returns The title text.
|
|
246
|
+
*/
|
|
247
|
+
function pointTitle(p, tz) {
|
|
248
|
+
const reading = `${formatNumber(p.used, "percent")} at ${formatInstant(p.atUtc, tz)}`;
|
|
249
|
+
return p.stale
|
|
250
|
+
? `Stale reading ${reading}: below an earlier reading in this window, so not used`
|
|
251
|
+
: `Observed usage ${reading}`;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Draws the graduated percentage scale and the user's own 100% line.
|
|
255
|
+
* @param scale - The chart's scale.
|
|
256
|
+
* @param left - Plot's left edge.
|
|
257
|
+
* @param right - Plot's right edge.
|
|
258
|
+
* @returns Grid lines, small ticks, labels, and the dashed 100% line.
|
|
259
|
+
*/
|
|
260
|
+
function percentAxis(scale, left, right) {
|
|
261
|
+
const parts = [];
|
|
262
|
+
// Multiply rather than accumulate, so no floating-point drift moves a tick.
|
|
263
|
+
for (let k = 0; k * scale.minor <= scale.top; k += 1) {
|
|
264
|
+
const percent = k * scale.minor;
|
|
265
|
+
const y = percentY(scale, percent);
|
|
266
|
+
if (k % 2 === 0) {
|
|
267
|
+
// A recessive hairline and a number every second tick; the baseline is one step stronger.
|
|
268
|
+
parts.push(el("line", {
|
|
269
|
+
class: "nm-grid",
|
|
270
|
+
x1: left,
|
|
271
|
+
x2: right,
|
|
272
|
+
y1: y,
|
|
273
|
+
y2: y,
|
|
274
|
+
stroke: percent === 0 ? "var(--axis)" : "var(--grid)",
|
|
275
|
+
"stroke-width": 1,
|
|
276
|
+
}), text(left - 7, y + 3.5, formatNumber(percent, "percent"), {
|
|
277
|
+
class: "nm-tick",
|
|
278
|
+
"text-anchor": "end",
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
// The small graduation between numbers, drawn at the axis only, like a stone gauge.
|
|
283
|
+
parts.push(el("line", {
|
|
284
|
+
class: "nm-tick",
|
|
285
|
+
x1: left - 4,
|
|
286
|
+
x2: left,
|
|
287
|
+
y1: y,
|
|
288
|
+
y2: y,
|
|
289
|
+
stroke: "var(--axis)",
|
|
290
|
+
"stroke-width": 1,
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// The only limit line on a usage chart: the user's own plan at 100% (D-009, D-070).
|
|
295
|
+
const limitY = percentY(scale, 100);
|
|
296
|
+
parts.push(el("line", {
|
|
297
|
+
class: "nm-limit",
|
|
298
|
+
x1: left,
|
|
299
|
+
x2: right,
|
|
300
|
+
y1: limitY,
|
|
301
|
+
y2: limitY,
|
|
302
|
+
stroke: "var(--limit)",
|
|
303
|
+
"stroke-width": 1.5,
|
|
304
|
+
"stroke-dasharray": "5 4",
|
|
305
|
+
}),
|
|
306
|
+
// Its key sits above the plot, where no column, point, or reset label can cover it.
|
|
307
|
+
el("line", {
|
|
308
|
+
class: "nm-limit-key",
|
|
309
|
+
x1: left,
|
|
310
|
+
x2: left + 18,
|
|
311
|
+
y1: PLOT.keyRow - 4,
|
|
312
|
+
y2: PLOT.keyRow - 4,
|
|
313
|
+
stroke: "var(--limit)",
|
|
314
|
+
"stroke-width": 1.5,
|
|
315
|
+
"stroke-dasharray": "5 4",
|
|
316
|
+
}), text(left + 24, PLOT.keyRow, LIMIT_LABEL, { class: "nm-label", fill: "var(--ink-2)" }));
|
|
317
|
+
return parts;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Names a window instance for titles and labels.
|
|
321
|
+
* @param window - `five_hour` or `seven_day`.
|
|
322
|
+
* @param resetAtUtc - The window's reset.
|
|
323
|
+
* @param timeZone - Display zone.
|
|
324
|
+
* @returns E.g. `5-hour window resetting 2026-09-03 13:00`.
|
|
325
|
+
*/
|
|
326
|
+
function windowTitle(window, resetAtUtc, timeZone) {
|
|
327
|
+
return `${windowName(window)} window resetting ${formatInstant(resetAtUtc, timeZone)}`;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Draws one gauge column: the track, the fill to the peak, the tick at the last reading, and the
|
|
331
|
+
* hit count on top.
|
|
332
|
+
* @param w - The window.
|
|
333
|
+
* @param cx - Column centre.
|
|
334
|
+
* @param width - Column width.
|
|
335
|
+
* @param scale - The gauge's scale.
|
|
336
|
+
* @param timeZone - Display zone.
|
|
337
|
+
* @returns The column's `<g>`, focusable, carrying the window's identity in `data-series`.
|
|
338
|
+
*/
|
|
339
|
+
function gaugeColumn(w, cx, width, scale, timeZone) {
|
|
340
|
+
const x = cx - width / 2;
|
|
341
|
+
const top = percentY(scale, scale.top);
|
|
342
|
+
const baseline = percentY(scale, 0);
|
|
343
|
+
const name = windowTitle(w.window, w.resetAtUtc, timeZone);
|
|
344
|
+
// The track is the column's full height, so the whole column is a target, not only its fill.
|
|
345
|
+
const parts = [
|
|
346
|
+
el("rect", {
|
|
347
|
+
class: "nm-track",
|
|
348
|
+
x,
|
|
349
|
+
y: top,
|
|
350
|
+
width,
|
|
351
|
+
height: baseline - top,
|
|
352
|
+
rx: 2,
|
|
353
|
+
fill: "var(--sunken)",
|
|
354
|
+
}),
|
|
355
|
+
];
|
|
356
|
+
if (w.peak !== null) {
|
|
357
|
+
const y = percentY(scale, w.peak);
|
|
358
|
+
parts.push(el("rect", {
|
|
359
|
+
class: "nm-data nm-peak",
|
|
360
|
+
"data-events": eventList(w.peakEvents),
|
|
361
|
+
x,
|
|
362
|
+
y,
|
|
363
|
+
width,
|
|
364
|
+
height: baseline - y,
|
|
365
|
+
rx: 2,
|
|
366
|
+
fill: "var(--water)",
|
|
367
|
+
}, title(`${LABELS.columns.peak} ${formatNumber(w.peak, "percent")} at ${formatInstant(w.peakAtUtc, timeZone)}, ${name}`)));
|
|
368
|
+
}
|
|
369
|
+
if (w.last !== null) {
|
|
370
|
+
const y = percentY(scale, w.last);
|
|
371
|
+
// Wider than the column so the tick stays visible where it meets the fill (last <= peak).
|
|
372
|
+
parts.push(el("line", {
|
|
373
|
+
class: "nm-data nm-last",
|
|
374
|
+
"data-events": eventList(w.lastEvents),
|
|
375
|
+
x1: x - 3,
|
|
376
|
+
x2: x + width + 3,
|
|
377
|
+
y1: y,
|
|
378
|
+
y2: y,
|
|
379
|
+
stroke: "var(--ink)",
|
|
380
|
+
"stroke-width": 2,
|
|
381
|
+
}, title(`${LABELS.columns.lastUsage} ${formatNumber(w.last, "percent")} at ${formatInstant(w.lastAtUtc, timeZone)}, a lower bound, ${name}`)));
|
|
382
|
+
}
|
|
383
|
+
if (w.limitHits > 0) {
|
|
384
|
+
// A plain marker on top of the column: above the fill, or above the baseline when no reading
|
|
385
|
+
// was usable. No number inside: at this size it couldn't be read, and markers on neighbouring
|
|
386
|
+
// columns overlapped. The count is in its title, the table, and the events it lists (D-090).
|
|
387
|
+
const y = percentY(scale, w.peak ?? w.last ?? 0) - 9;
|
|
388
|
+
parts.push(el("circle", {
|
|
389
|
+
class: "nm-data nm-hit",
|
|
390
|
+
"data-events": eventList(w.hitEvents),
|
|
391
|
+
cx,
|
|
392
|
+
cy: y,
|
|
393
|
+
r: 5,
|
|
394
|
+
fill: "var(--limit)",
|
|
395
|
+
stroke: "var(--surface)",
|
|
396
|
+
"stroke-width": 1.5,
|
|
397
|
+
}, title(`Locked out in the ${name}: ${plural(w.limitHits, "limit hit", "limit hits")}`)));
|
|
398
|
+
}
|
|
399
|
+
if (w.open) {
|
|
400
|
+
parts.push(text(cx, baseline + 12, "open", { class: "nm-open", "text-anchor": "middle" }));
|
|
401
|
+
}
|
|
402
|
+
const summary = [
|
|
403
|
+
`peak ${formatNumber(w.peak, "percent")}`,
|
|
404
|
+
`${LABELS.columns.lastUsage.toLowerCase()} ${formatNumber(w.last, "percent")}`,
|
|
405
|
+
plural(w.limitHits, "limit hit", "limit hits"),
|
|
406
|
+
plural(w.readings, "reading", "readings"),
|
|
407
|
+
...(w.open ? ["open"] : []),
|
|
408
|
+
].join(", ");
|
|
409
|
+
const accessibleName = `${name}: ${summary}`;
|
|
410
|
+
return el("g", {
|
|
411
|
+
class: "nm-col",
|
|
412
|
+
"data-series": `${w.window}|${w.resetAtUtc}`,
|
|
413
|
+
"data-events": eventList(union(w.peakEvents, w.lastEvents, w.hitEvents)),
|
|
414
|
+
tabindex: "0",
|
|
415
|
+
role: "button",
|
|
416
|
+
"aria-label": accessibleName,
|
|
417
|
+
// The page heads the column's events panel with this: a `<g>` has no `<title>` of its own to
|
|
418
|
+
// read, and the panel should name the window the same way a screen reader does.
|
|
419
|
+
"data-label": accessibleName,
|
|
420
|
+
}, parts.join(""));
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* The gauge: one column per usage window, filled to its peak, ticked at its last observed reading,
|
|
424
|
+
* with its limit hit count on top and the user's own 100% line (D-024, D-068, D-070).
|
|
425
|
+
*
|
|
426
|
+
* 5-hour and weekly windows are two labeled groups side by side, separated by a gap and a
|
|
427
|
+
* hairline, on one shared percentage axis. Within a group, columns keep the input's order (oldest
|
|
428
|
+
* reset first). Each column is a focusable `g.nm-col` whose `data-series` is `<window>|<reset>`,
|
|
429
|
+
* which the page uses to open that window's line.
|
|
430
|
+
* @param windows - Windows with at least one counted reading.
|
|
431
|
+
* @param options - Width and zone.
|
|
432
|
+
* @returns One `<svg>`; a "No data" svg when there are no windows.
|
|
433
|
+
*/
|
|
434
|
+
export const renderGauge = (windows, options) => {
|
|
435
|
+
const label = "Usage windows: peak and last observed usage per window";
|
|
436
|
+
const groups = WINDOW_ORDER.map((window) => windows.filter((w) => w.window === window)).filter((group) => group.length > 0);
|
|
437
|
+
const count = groups.reduce((sum, group) => sum + group.length, 0);
|
|
438
|
+
if (count === 0) {
|
|
439
|
+
return noData("gauge", options, label);
|
|
440
|
+
}
|
|
441
|
+
const tz = options.timeZone;
|
|
442
|
+
const scale = percentScale(windows.flatMap((w) => [w.peak, w.last]));
|
|
443
|
+
const left = PLOT.left;
|
|
444
|
+
const right = options.width - PLOT.right;
|
|
445
|
+
const baseline = percentY(scale, 0);
|
|
446
|
+
const slot = (right - left - GROUP_GAP * (groups.length - 1)) / count;
|
|
447
|
+
const width = Math.min(MAX_COLUMN, slot * 0.62);
|
|
448
|
+
const parts = percentAxis(scale, left, right);
|
|
449
|
+
// Column labels in two rows below the baseline; a label too close to the last shown one in its
|
|
450
|
+
// row is left out, so labels never overlap (each column's title still names its reset).
|
|
451
|
+
let lastTimeX = -Infinity;
|
|
452
|
+
let lastDayX = -Infinity;
|
|
453
|
+
let cursor = left;
|
|
454
|
+
groups.forEach((group, g) => {
|
|
455
|
+
if (g > 0) {
|
|
456
|
+
const x = cursor - GROUP_GAP / 2;
|
|
457
|
+
parts.push(el("line", {
|
|
458
|
+
class: "nm-group-rule",
|
|
459
|
+
x1: x,
|
|
460
|
+
x2: x,
|
|
461
|
+
y1: percentY(scale, scale.top),
|
|
462
|
+
y2: baseline,
|
|
463
|
+
stroke: "var(--grid)",
|
|
464
|
+
"stroke-width": 1,
|
|
465
|
+
}));
|
|
466
|
+
}
|
|
467
|
+
parts.push(text(cursor, PLOT.labelRow, `${windowName(group[0]?.window ?? "")} windows`, {
|
|
468
|
+
class: "nm-label",
|
|
469
|
+
fill: "var(--ink-2)",
|
|
470
|
+
}));
|
|
471
|
+
let previousDay = "";
|
|
472
|
+
group.forEach((w, i) => {
|
|
473
|
+
const cx = cursor + slot * (i + 0.5);
|
|
474
|
+
parts.push(gaugeColumn(w, cx, width, scale, tz));
|
|
475
|
+
const instant = formatInstant(w.resetAtUtc, tz);
|
|
476
|
+
if (cx - lastTimeX >= MIN_LABEL_GAP) {
|
|
477
|
+
parts.push(text(cx, baseline + 24, instant.slice(11), { "text-anchor": "middle" }));
|
|
478
|
+
lastTimeX = cx;
|
|
479
|
+
}
|
|
480
|
+
// The date only where it changes, as a calendar would.
|
|
481
|
+
const day = instant.slice(5, 10);
|
|
482
|
+
if (day !== previousDay && cx - lastDayX >= MIN_LABEL_GAP) {
|
|
483
|
+
parts.push(text(cx, baseline + 36, day, { "text-anchor": "middle" }));
|
|
484
|
+
lastDayX = cx;
|
|
485
|
+
previousDay = day;
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
cursor += slot * group.length + GROUP_GAP;
|
|
489
|
+
});
|
|
490
|
+
const resets = extent(windows.map((w) => w.resetAtUtc));
|
|
491
|
+
const span = `Windows resetting ${formatCoverage(resets.from, resets.to, tz)}`;
|
|
492
|
+
parts.push(text(left, baseline + 54, span, { class: "nm-span" }));
|
|
493
|
+
return svg("gauge", options, PLOT.top + PLOT.height + PLOT.bottom, `${label}. ${span}`, parts);
|
|
494
|
+
};
|
|
495
|
+
/**
|
|
496
|
+
* One window's usage over time: a 2px line through its readings, a point at each reading, a flag
|
|
497
|
+
* at each limit hit, the user's own 100% line, and the reset (D-045, D-068, D-070).
|
|
498
|
+
*
|
|
499
|
+
* The time axis runs from the first reading or hit to the reset (or to a later hit, if any), and
|
|
500
|
+
* the percentage axis is the gauge's, so a column and its line read on the same ruler.
|
|
501
|
+
* @param series - The window's readings and hits.
|
|
502
|
+
* @param options - Width and zone.
|
|
503
|
+
* @returns One `<svg>` whose `data-series` is `<window>|<reset>`; a "No data" svg, with the same
|
|
504
|
+
* `data-series`, when the window has no readings.
|
|
505
|
+
*/
|
|
506
|
+
export const renderWindowSeries = (series, options) => {
|
|
507
|
+
const tz = options.timeZone;
|
|
508
|
+
const name = windowTitle(series.window, series.resetAtUtc, tz);
|
|
509
|
+
const identity = { "data-series": `${series.window}|${series.resetAtUtc}` };
|
|
510
|
+
const label = `${name}: observed usage over time`;
|
|
511
|
+
if (series.points.length === 0) {
|
|
512
|
+
return noData("series", options, label, identity);
|
|
513
|
+
}
|
|
514
|
+
const scale = percentScale(series.points.map((p) => p.used));
|
|
515
|
+
const reset = Date.parse(series.resetAtUtc);
|
|
516
|
+
const times = [...series.points, ...series.hits].map((p) => Date.parse(p.atUtc));
|
|
517
|
+
const from = Math.min(...times);
|
|
518
|
+
const to = Math.max(reset, ...times);
|
|
519
|
+
// A single instant still needs a width to divide by; one millisecond keeps it at the left edge.
|
|
520
|
+
const duration = Math.max(1, to - from);
|
|
521
|
+
const left = PLOT.left;
|
|
522
|
+
const right = options.width - PLOT.right;
|
|
523
|
+
const top = percentY(scale, scale.top);
|
|
524
|
+
const baseline = percentY(scale, 0);
|
|
525
|
+
/**
|
|
526
|
+
* Places an instant on the time axis.
|
|
527
|
+
* @param iso - ISO-8601 instant.
|
|
528
|
+
* @returns Its x coordinate.
|
|
529
|
+
*/
|
|
530
|
+
const timeX = (iso) => left + ((Date.parse(iso) - from) / duration) * (right - left);
|
|
531
|
+
const parts = percentAxis(scale, left, right);
|
|
532
|
+
const resetX = timeX(series.resetAtUtc);
|
|
533
|
+
const resetLabel = `${LABELS.columns.resets} ${formatInstant(series.resetAtUtc, tz)}`;
|
|
534
|
+
parts.push(el("line", {
|
|
535
|
+
class: "nm-reset",
|
|
536
|
+
x1: resetX,
|
|
537
|
+
x2: resetX,
|
|
538
|
+
y1: top,
|
|
539
|
+
y2: baseline,
|
|
540
|
+
stroke: "var(--ink-2)",
|
|
541
|
+
"stroke-width": 1,
|
|
542
|
+
}),
|
|
543
|
+
// Above the plot, ending at the reset line; when the reset is too near the left edge for the
|
|
544
|
+
// label to fit before it, the label starts at the line instead.
|
|
545
|
+
resetX - left >= 160
|
|
546
|
+
? text(resetX, PLOT.labelRow, resetLabel, {
|
|
547
|
+
class: "nm-label",
|
|
548
|
+
"text-anchor": "end",
|
|
549
|
+
fill: "var(--ink-2)",
|
|
550
|
+
})
|
|
551
|
+
: text(resetX, PLOT.labelRow, resetLabel, { class: "nm-label", fill: "var(--ink-2)" }));
|
|
552
|
+
// Hit flags first, so the line and its points draw over the flag staffs.
|
|
553
|
+
for (const hit of series.hits) {
|
|
554
|
+
const x = timeX(hit.atUtc);
|
|
555
|
+
parts.push(el("path", {
|
|
556
|
+
class: "nm-data nm-hit",
|
|
557
|
+
"data-events": String(hit.event),
|
|
558
|
+
// A staff from the baseline to the top, and a small pennant at the top.
|
|
559
|
+
d: `M${num(x)} ${num(baseline)}V${num(top)}M${num(x)} ${num(top)}h8l-8 5z`,
|
|
560
|
+
fill: "var(--limit)",
|
|
561
|
+
stroke: "var(--limit)",
|
|
562
|
+
"stroke-width": 1.5,
|
|
563
|
+
}, title(`Limit hit at ${formatInstant(hit.atUtc, tz)}`)));
|
|
564
|
+
}
|
|
565
|
+
const readings = extent(series.points.map((p) => p.atUtc));
|
|
566
|
+
const span = `Readings ${formatCoverage(readings.from, readings.to, tz)}`;
|
|
567
|
+
parts.push(el("path", {
|
|
568
|
+
class: "nm-data nm-line",
|
|
569
|
+
"data-events": eventList(onLine(series.points).map((p) => p.event)),
|
|
570
|
+
d: onLine(series.points)
|
|
571
|
+
.map((p, i) => `${i === 0 ? "M" : "L"}${num(timeX(p.atUtc))} ${num(percentY(scale, p.used))}`)
|
|
572
|
+
.join(""),
|
|
573
|
+
fill: "none",
|
|
574
|
+
stroke: "var(--water)",
|
|
575
|
+
"stroke-width": 2,
|
|
576
|
+
"stroke-linejoin": "round",
|
|
577
|
+
"stroke-linecap": "round",
|
|
578
|
+
}, title(`Observed usage in the ${name}, ${plural(series.points.length, "reading", "readings")}`)), ...series.points.map((p) => el("circle", {
|
|
579
|
+
class: "nm-data nm-point",
|
|
580
|
+
"data-events": String(p.event),
|
|
581
|
+
cx: timeX(p.atUtc),
|
|
582
|
+
cy: percentY(scale, p.used),
|
|
583
|
+
...pointStyle(p),
|
|
584
|
+
}, title(pointTitle(p, tz)))), text(left, baseline + 16, formatInstant(new Date(from).toISOString(), tz)));
|
|
585
|
+
if (to > reset) {
|
|
586
|
+
// A hit logged after the reset extends the axis; label its end so the extra reach is visible.
|
|
587
|
+
parts.push(text(right, baseline + 16, formatInstant(new Date(to).toISOString(), tz), {
|
|
588
|
+
"text-anchor": "end",
|
|
589
|
+
}));
|
|
590
|
+
}
|
|
591
|
+
parts.push(text(left, baseline + 54, span, { class: "nm-span" }));
|
|
592
|
+
return svg("series", options, PLOT.top + PLOT.height + PLOT.bottom, `${label}, ${plural(series.points.length, "reading", "readings")}, ${plural(series.hits.length, "limit hit", "limit hits")}. ${span}`, parts, identity);
|
|
593
|
+
};
|
|
594
|
+
/** Layout of the coverage chart. */
|
|
595
|
+
const COVERAGE = { left: 110, right: 12, top: 6, row: 24 };
|
|
596
|
+
/**
|
|
597
|
+
* What each number covers: one bar per source on one shared time axis (principle 5).
|
|
598
|
+
*
|
|
599
|
+
* A coverage bar draws a span, not events, so it carries a `<title>` and no `data-events`.
|
|
600
|
+
* @param rows - One row per source.
|
|
601
|
+
* @param options - Width and zone.
|
|
602
|
+
* @returns One `<svg>`; a "No data" svg when no source has any data.
|
|
603
|
+
*/
|
|
604
|
+
export const renderCoverage = (rows, options) => {
|
|
605
|
+
const label = "What each number covers";
|
|
606
|
+
const tz = options.timeZone;
|
|
607
|
+
const known = extent(rows.flatMap((row) => row.span.from === null || row.span.to === null ? [] : [row.span.from, row.span.to]));
|
|
608
|
+
if (known === null) {
|
|
609
|
+
return noData("coverage", options, label);
|
|
610
|
+
}
|
|
611
|
+
const from = Date.parse(known.from);
|
|
612
|
+
const duration = Math.max(1, Date.parse(known.to) - from);
|
|
613
|
+
const left = COVERAGE.left;
|
|
614
|
+
const right = options.width - COVERAGE.right;
|
|
615
|
+
/**
|
|
616
|
+
* Places an instant on the time axis.
|
|
617
|
+
* @param iso - ISO-8601 instant.
|
|
618
|
+
* @returns Its x coordinate.
|
|
619
|
+
*/
|
|
620
|
+
const timeX = (iso) => left + ((Date.parse(iso) - from) / duration) * (right - left);
|
|
621
|
+
const parts = [];
|
|
622
|
+
const spoken = [];
|
|
623
|
+
rows.forEach((row, i) => {
|
|
624
|
+
const source = SOURCES[row.source];
|
|
625
|
+
const y = COVERAGE.top + i * COVERAGE.row;
|
|
626
|
+
const covered = formatCoverage(row.span.from, row.span.to, tz);
|
|
627
|
+
spoken.push(`${source.name.toLowerCase()} ${covered}`);
|
|
628
|
+
parts.push(text(0, y + 13, source.name, { class: "nm-label", fill: "var(--ink-2)" }), el("rect", {
|
|
629
|
+
class: "nm-track",
|
|
630
|
+
x: left,
|
|
631
|
+
y: y + 3,
|
|
632
|
+
width: right - left,
|
|
633
|
+
height: 12,
|
|
634
|
+
rx: 3,
|
|
635
|
+
fill: "var(--sunken)",
|
|
636
|
+
}));
|
|
637
|
+
if (row.span.from === null || row.span.to === null) {
|
|
638
|
+
parts.push(text(left + 6, y + 13, covered));
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const x = timeX(row.span.from);
|
|
642
|
+
parts.push(el("rect", {
|
|
643
|
+
// No `nm-data` and no `data-events`: a span is not an event, and an empty list would make
|
|
644
|
+
// the page offer an events panel with nothing in it. The title still states the span.
|
|
645
|
+
class: "nm-coverage",
|
|
646
|
+
x,
|
|
647
|
+
y: y + 3,
|
|
648
|
+
// A source with one instant still shows as a sliver rather than vanishing.
|
|
649
|
+
width: Math.max(2, timeX(row.span.to) - x),
|
|
650
|
+
height: 12,
|
|
651
|
+
rx: 3,
|
|
652
|
+
fill: "var(--water)",
|
|
653
|
+
}, title(`${source.name}: ${covered}, covering ${source.covers}`)));
|
|
654
|
+
});
|
|
655
|
+
const axisY = COVERAGE.top + rows.length * COVERAGE.row + 12;
|
|
656
|
+
parts.push(text(left, axisY, formatInstant(known.from, tz)), text(right, axisY, formatInstant(known.to, tz), { "text-anchor": "end" }));
|
|
657
|
+
return svg("coverage", options, axisY + 6, `${label}: ${spoken.join("; ")}`, parts);
|
|
658
|
+
};
|
|
659
|
+
/** Layout of the lockout chart. */
|
|
660
|
+
const LOCKOUT = {
|
|
661
|
+
left: 124,
|
|
662
|
+
top: 8,
|
|
663
|
+
row: 28,
|
|
664
|
+
/** Extra row height when a label sits under its bar. */
|
|
665
|
+
labelRow: 16,
|
|
666
|
+
/** Width of one label character at the chart's 11 px monospace, generously. */
|
|
667
|
+
charWidth: 6.8,
|
|
668
|
+
/** Gap between a bar's end and its label (12 past a resumption ring). */
|
|
669
|
+
gap: 12,
|
|
670
|
+
/** Below this share of the width for bars, labels go under the bars. */
|
|
671
|
+
minPlotShare: 0.5,
|
|
672
|
+
};
|
|
673
|
+
/** Duration tick steps in seconds, from a minute to four weeks. */
|
|
674
|
+
const DURATION_STEPS = [
|
|
675
|
+
60, 300, 600, 900, 1_800, 3_600, 7_200, 10_800, 21_600, 43_200, 86_400, 172_800, 345_600, 604_800,
|
|
676
|
+
1_209_600, 2_419_200,
|
|
677
|
+
];
|
|
678
|
+
/**
|
|
679
|
+
* Chooses a duration axis: the smallest listed step that needs at most six ticks.
|
|
680
|
+
* @param longest - The longest duration drawn, in seconds.
|
|
681
|
+
* @returns The step and the axis end (a whole number of steps, at least one), in seconds.
|
|
682
|
+
*/
|
|
683
|
+
function durationAxis(longest) {
|
|
684
|
+
const step = DURATION_STEPS.find((s) => Math.ceil(longest / s) <= 6) ??
|
|
685
|
+
// Past four weeks, widen by whole four-week steps.
|
|
686
|
+
2_419_200 * Math.ceil(longest / 2_419_200 / 6);
|
|
687
|
+
return { step, end: Math.max(1, Math.ceil(longest / step)) * step };
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Labels a duration tick as the report writes durations, so one axis never mixes `90 m` with `2 h`.
|
|
691
|
+
* @param seconds - A multiple of 60.
|
|
692
|
+
* @returns `0`, `10 m`, `1 h 30 m`, `2 h`, or `3 d`.
|
|
693
|
+
*/
|
|
694
|
+
function durationTick(seconds) {
|
|
695
|
+
if (seconds === 0) {
|
|
696
|
+
return "0";
|
|
697
|
+
}
|
|
698
|
+
if (seconds % 86_400 === 0) {
|
|
699
|
+
return `${String(seconds / 86_400)} d`;
|
|
700
|
+
}
|
|
701
|
+
if (seconds % 3_600 === 0) {
|
|
702
|
+
return `${String(seconds / 3_600)} h`;
|
|
703
|
+
}
|
|
704
|
+
// Past an hour, hours and minutes, as formatDuration writes them; under it, minutes.
|
|
705
|
+
return seconds > 3_600 ? formatDuration(seconds) : `${String(seconds / 60)} m`;
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* States a lockout's logged resumption in the text report's words (D-077).
|
|
709
|
+
* @param bar - A lockout with at least one marker.
|
|
710
|
+
* @returns E.g. `Resumed by Claude Code (logged auto-continuation): 1 session, 46 s after the reset`.
|
|
711
|
+
*/
|
|
712
|
+
export function resumedText(bar) {
|
|
713
|
+
const text = autoContinuedText({
|
|
714
|
+
auto_continuations: bar.autoContinuations,
|
|
715
|
+
first_auto_continuation_seconds: bar.firstAutoContinuationSeconds,
|
|
716
|
+
}).replace(/^; /, "");
|
|
717
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Names a lockout lane by its plan period, as the per-plan table does (D-075).
|
|
721
|
+
* @param period - The period.
|
|
722
|
+
* @returns E.g. `Max 5x, 2026-08 onward` or `no plan entered, before 2026-07`.
|
|
723
|
+
*/
|
|
724
|
+
function laneName(period) {
|
|
725
|
+
const row = {
|
|
726
|
+
period_kind: period.kind,
|
|
727
|
+
plan_name: period.planName,
|
|
728
|
+
from_month: period.fromMonth,
|
|
729
|
+
to_month: period.toMonth,
|
|
730
|
+
until_month: period.untilMonth,
|
|
731
|
+
};
|
|
732
|
+
return `${planPeriodName(row)}, ${planPeriodMonths(row)}`;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Describes what followed a lockout's reset.
|
|
736
|
+
* @param bar - The lockout.
|
|
737
|
+
* @returns The reset-to-next-request text, or why there is none.
|
|
738
|
+
*/
|
|
739
|
+
function afterReset(bar) {
|
|
740
|
+
if (bar.resetToNextRequestSeconds !== null) {
|
|
741
|
+
return `${formatDuration(bar.resetToNextRequestSeconds)} ${LABELS.resetToNextRequest}`;
|
|
742
|
+
}
|
|
743
|
+
return bar.resetAfterCoverage
|
|
744
|
+
? "the reset came after the logs end"
|
|
745
|
+
: "no Claude Code request followed the reset";
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Elapsed lockout time: one bar per interval, limit hit to reset, overlapping hits merged (D-023).
|
|
749
|
+
* All bars share one duration axis that starts at 0; each row is labeled with the first hit.
|
|
750
|
+
* @param bars - Lockout intervals in time order.
|
|
751
|
+
* @param options - Width and zone.
|
|
752
|
+
* @returns One `<svg>`; a "No data" svg when there are no lockouts.
|
|
753
|
+
*/
|
|
754
|
+
export const renderLockouts = (bars, options) => {
|
|
755
|
+
const label = "Elapsed lockout time, first limit hit to reset";
|
|
756
|
+
if (bars.length === 0) {
|
|
757
|
+
return noData("lockouts", options, label);
|
|
758
|
+
}
|
|
759
|
+
const tz = options.timeZone;
|
|
760
|
+
const axis = durationAxis(Math.max(...bars.map((b) => b.seconds)));
|
|
761
|
+
const left = LOCKOUT.left;
|
|
762
|
+
// Each bar's label, as the text report words it (D-077, D-090).
|
|
763
|
+
const labels = bars.map((bar) => `${formatDuration(bar.seconds)}, ${lockoutHitsText({
|
|
764
|
+
hits: bar.hits,
|
|
765
|
+
refused_requests: bar.refusedRequests,
|
|
766
|
+
sessions: bar.sessions,
|
|
767
|
+
})}${bar.autoContinuations > 0 ? ", resumed by Claude Code" : ""}`);
|
|
768
|
+
const room = options.width - left - 4;
|
|
769
|
+
// The widest plot at which every label still ends inside the chart, beside its bar.
|
|
770
|
+
const fitted = Math.min(room, ...bars.map((bar, i) => (room - LOCKOUT.gap - (labels[i] ?? "").length * LOCKOUT.charWidth) /
|
|
771
|
+
(bar.seconds / axis.end)));
|
|
772
|
+
// Too narrow for that (a phone): every label goes under its bar instead. A thumbnail has none.
|
|
773
|
+
const compact = options.compact === true;
|
|
774
|
+
const under = !compact && fitted < room * LOCKOUT.minPlotShare;
|
|
775
|
+
const plotWidth = under || compact ? room : fitted;
|
|
776
|
+
const row = LOCKOUT.row + (under ? LOCKOUT.labelRow : 0);
|
|
777
|
+
// One lane per plan period once bars carry one (D-075): a heading row above each period's first
|
|
778
|
+
// bar. Bars come in time order and periods never overlap, so each period's bars are adjacent.
|
|
779
|
+
const lanes = bars.map((bar, i) => {
|
|
780
|
+
const name = bar.period === undefined ? undefined : laneName(bar.period);
|
|
781
|
+
const previous = i === 0 ? undefined : bars[i - 1]?.period;
|
|
782
|
+
return name !== undefined && (previous === undefined || laneName(previous) !== name)
|
|
783
|
+
? name
|
|
784
|
+
: undefined;
|
|
785
|
+
});
|
|
786
|
+
/** Row index of each bar, counting the lane headings above it. */
|
|
787
|
+
const rowOf = bars.map((_, i) => i + lanes.slice(0, i + 1).filter((l) => l !== undefined).length);
|
|
788
|
+
const rows = bars.length + lanes.filter((l) => l !== undefined).length;
|
|
789
|
+
const bottom = LOCKOUT.top + rows * row;
|
|
790
|
+
const parts = [];
|
|
791
|
+
for (let k = 0; k * axis.step <= axis.end; k += 1) {
|
|
792
|
+
const x = left + ((k * axis.step) / axis.end) * plotWidth;
|
|
793
|
+
parts.push(el("line", {
|
|
794
|
+
class: "nm-grid",
|
|
795
|
+
x1: x,
|
|
796
|
+
x2: x,
|
|
797
|
+
y1: LOCKOUT.top,
|
|
798
|
+
y2: bottom,
|
|
799
|
+
stroke: k === 0 ? "var(--axis)" : "var(--grid)",
|
|
800
|
+
"stroke-width": 1,
|
|
801
|
+
}), text(x, bottom + 14, durationTick(k * axis.step), {
|
|
802
|
+
class: "nm-tick",
|
|
803
|
+
"text-anchor": "middle",
|
|
804
|
+
}));
|
|
805
|
+
}
|
|
806
|
+
bars.forEach((bar, i) => {
|
|
807
|
+
const y = LOCKOUT.top + (rowOf[i] ?? i) * row;
|
|
808
|
+
const lane = lanes[i];
|
|
809
|
+
if (lane !== undefined) {
|
|
810
|
+
// The heading sits in the row above the bar, in a text color, never the bars' color.
|
|
811
|
+
parts.push(text(0, y - row + 18, lane, {
|
|
812
|
+
class: "nm-lane",
|
|
813
|
+
fill: "var(--ink)",
|
|
814
|
+
"font-weight": 600,
|
|
815
|
+
}));
|
|
816
|
+
}
|
|
817
|
+
const width = (bar.seconds / axis.end) * plotWidth;
|
|
818
|
+
// What the lockout's hits were, as the text report says it (D-090).
|
|
819
|
+
const hits = lockoutHitsText({
|
|
820
|
+
hits: bar.hits,
|
|
821
|
+
refused_requests: bar.refusedRequests,
|
|
822
|
+
sessions: bar.sessions,
|
|
823
|
+
});
|
|
824
|
+
parts.push(text(0, y + 18, formatInstant(bar.lockedFromUtc, tz), {
|
|
825
|
+
class: "nm-label",
|
|
826
|
+
fill: "var(--ink-2)",
|
|
827
|
+
}), el("rect", {
|
|
828
|
+
class: "nm-data nm-lockout",
|
|
829
|
+
"data-events": eventList(bar.events),
|
|
830
|
+
x: left,
|
|
831
|
+
y: y + 6,
|
|
832
|
+
width,
|
|
833
|
+
height: 16,
|
|
834
|
+
rx: 2,
|
|
835
|
+
fill: "var(--limit)",
|
|
836
|
+
}, title(`Elapsed lockout time ${formatDuration(bar.seconds)}, ${formatCoverage(bar.lockedFromUtc, bar.lockedUntilUtc, tz)}, ${hits}; ${afterReset(bar)}`)));
|
|
837
|
+
const resumed = bar.autoContinuations > 0;
|
|
838
|
+
if (resumed) {
|
|
839
|
+
// Claude Code's own logged resumption (D-077): a ring at the reset end of the bar, in ink,
|
|
840
|
+
// apart from the bar's limit color, carrying the marker lines.
|
|
841
|
+
parts.push(el("circle", {
|
|
842
|
+
class: "nm-data nm-resumed",
|
|
843
|
+
"data-events": eventList(bar.autoContinuationEvents),
|
|
844
|
+
cx: left + width,
|
|
845
|
+
cy: y + 14,
|
|
846
|
+
r: 5,
|
|
847
|
+
fill: "var(--surface)",
|
|
848
|
+
stroke: "var(--ink)",
|
|
849
|
+
"stroke-width": 2,
|
|
850
|
+
}, title(resumedText(bar))));
|
|
851
|
+
}
|
|
852
|
+
if (!compact) {
|
|
853
|
+
parts.push(
|
|
854
|
+
// Under the bar when labels don't fit beside the bars; otherwise beside it.
|
|
855
|
+
under
|
|
856
|
+
? text(left, y + 18 + LOCKOUT.labelRow, labels[i] ?? "", {
|
|
857
|
+
class: "nm-value",
|
|
858
|
+
fill: "var(--ink-2)",
|
|
859
|
+
})
|
|
860
|
+
: // Past the ring when there is one, so the two never overlap.
|
|
861
|
+
text(left + width + (resumed ? 12 : 6), y + 18, labels[i] ?? "", {
|
|
862
|
+
class: "nm-value",
|
|
863
|
+
fill: "var(--ink-2)",
|
|
864
|
+
}));
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
const covered = extent(bars.flatMap((b) => [b.lockedFromUtc, b.lockedUntilUtc]));
|
|
868
|
+
const span = `Lockouts ${formatCoverage(covered.from, covered.to, tz)}`;
|
|
869
|
+
parts.push(text(0, bottom + 34, span, { class: "nm-span" }));
|
|
870
|
+
return svg("lockouts", options, bottom + 40, `${label}. ${span}`, parts);
|
|
871
|
+
};
|
|
872
|
+
/** Layout of the model chart. */
|
|
873
|
+
const MODELS = { top: 4, row: 24, valueRoom: 90, charWidth: 6.6, maxChars: 32 };
|
|
874
|
+
/**
|
|
875
|
+
* Shortens a long model name for its row label; the bar's title keeps the full name.
|
|
876
|
+
* @param model - The exact `message.model` string.
|
|
877
|
+
* @returns The name, or its start and `...` past {@link MODELS}.maxChars characters.
|
|
878
|
+
*/
|
|
879
|
+
function modelLabel(model) {
|
|
880
|
+
const shown = printable(model);
|
|
881
|
+
return shown.length > MODELS.maxChars ? `${shown.slice(0, MODELS.maxChars - 3)}...` : shown;
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Output tokens by model: one bar per model, one hue, in the input's order (largest first).
|
|
885
|
+
*
|
|
886
|
+
* The contract's model rows carry no dates, so this chart can't state its span; the page states
|
|
887
|
+
* it from the session log coverage.
|
|
888
|
+
* @param bars - Models by output tokens.
|
|
889
|
+
* @param options - Width and zone.
|
|
890
|
+
* @returns One `<svg>`; a "No data" svg when there are no models.
|
|
891
|
+
*/
|
|
892
|
+
export const renderModelBars = (bars, options) => {
|
|
893
|
+
const label = `${LABELS.byModel}: output tokens`;
|
|
894
|
+
if (bars.length === 0) {
|
|
895
|
+
return noData("models", options, label);
|
|
896
|
+
}
|
|
897
|
+
const labels = bars.map((b) => modelLabel(b.model));
|
|
898
|
+
// Size the label column to the longest name, within bounds, so bars start after every label.
|
|
899
|
+
const longest = Math.max(...labels.map((l) => l.length));
|
|
900
|
+
const left = Math.min(options.width * 0.45, Math.max(80, longest * MODELS.charWidth + 12));
|
|
901
|
+
const plotWidth = options.width - left - MODELS.valueRoom;
|
|
902
|
+
const most = Math.max(...bars.map((b) => b.outputTokens));
|
|
903
|
+
const parts = [];
|
|
904
|
+
bars.forEach((bar, i) => {
|
|
905
|
+
const y = MODELS.top + i * MODELS.row;
|
|
906
|
+
// With no output tokens at all, every bar is empty rather than divided by zero.
|
|
907
|
+
const width = most > 0 ? (bar.outputTokens / most) * plotWidth : 0;
|
|
908
|
+
parts.push(text(0, y + 15, labels[i] ?? "", { class: "nm-label", fill: "var(--ink-2)" }), el("rect", {
|
|
909
|
+
class: "nm-data nm-model",
|
|
910
|
+
"data-events": eventList(bar.events),
|
|
911
|
+
x: left,
|
|
912
|
+
y: y + 5,
|
|
913
|
+
width,
|
|
914
|
+
height: 14,
|
|
915
|
+
rx: 2,
|
|
916
|
+
fill: "var(--water)",
|
|
917
|
+
}, title(`${bar.model}: ${formatNumber(bar.outputTokens, "tokens")} output tokens from ${plural(bar.requests, "request", "requests")}`)), text(left + width + 6, y + 15, formatNumber(bar.outputTokens, "tokens"), {
|
|
918
|
+
class: "nm-value",
|
|
919
|
+
fill: "var(--ink-2)",
|
|
920
|
+
}));
|
|
921
|
+
});
|
|
922
|
+
return svg("models", options, MODELS.top + bars.length * MODELS.row + 4, `${label}, ${plural(bars.length, "model", "models")}`, parts);
|
|
923
|
+
};
|
|
924
|
+
/** Repositories the chart draws by name; the rest share one bar, and the table lists every one. */
|
|
925
|
+
// 20, not 10 (2026-10-22 click-through): on real data, repositories 11 to 20 held 3.0M of the 3.5M
|
|
926
|
+
// output tokens the Other bar folded together.
|
|
927
|
+
export const REPO_BARS = 20;
|
|
928
|
+
/** Layout of the repository chart (step G2.1). */
|
|
929
|
+
const REPOS = {
|
|
930
|
+
top: 4,
|
|
931
|
+
row: 24,
|
|
932
|
+
valueRoom: 90,
|
|
933
|
+
charWidth: 6.6,
|
|
934
|
+
maxChars: 40,
|
|
935
|
+
maxBars: REPO_BARS,
|
|
936
|
+
};
|
|
937
|
+
/** What a repository chart calls a request with no working directory, as `explain by-repo` does. */
|
|
938
|
+
const NO_REPOSITORY = "unknown (no working directory)";
|
|
939
|
+
/**
|
|
940
|
+
* Shortens a long repository path for its row label, keeping the end, where the repository's own
|
|
941
|
+
* name is; the bar's title keeps the whole path.
|
|
942
|
+
* @param shown - The path as displayed (`~/...` under the home directory).
|
|
943
|
+
* @returns The path, or `...` and its last characters past {@link REPOS}.maxChars.
|
|
944
|
+
*/
|
|
945
|
+
function repoLabel(shown) {
|
|
946
|
+
const safe = printable(shown);
|
|
947
|
+
return safe.length > REPOS.maxChars ? `...${safe.slice(-(REPOS.maxChars - 3))}` : safe;
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Output tokens by repository (step G2.1, D-084): one bar per repository, one hue, in the input's
|
|
951
|
+
* order (largest first). Past {@link REPOS}.maxBars repositories, the rest are one neutral bar
|
|
952
|
+
* whose events are all of theirs, so every drawn bar still resolves to its requests (principle 4).
|
|
953
|
+
* A directory that no longer exists (D-028) is drawn faded, and its title says so.
|
|
954
|
+
*
|
|
955
|
+
* Repository rows carry no dates, so this chart can't state its span; the page states it from the
|
|
956
|
+
* session log coverage, as for models.
|
|
957
|
+
* @param input - Repositories by output tokens, and the home directory paths shorten against.
|
|
958
|
+
* @param options - Width and zone.
|
|
959
|
+
* @returns One `<svg>`; a "No data" svg when there are no repositories.
|
|
960
|
+
*/
|
|
961
|
+
export const renderRepoBars = (input, options) => {
|
|
962
|
+
const label = `${LABELS.byRepo}: output tokens`;
|
|
963
|
+
if (input.bars.length === 0) {
|
|
964
|
+
return noData("repos", options, label);
|
|
965
|
+
}
|
|
966
|
+
const named = input.bars.slice(0, REPOS.maxBars);
|
|
967
|
+
const rest = input.bars.slice(REPOS.maxBars);
|
|
968
|
+
const rows = named.map((bar) => {
|
|
969
|
+
const shown = bar.repository === null ? NO_REPOSITORY : displayPath(bar.repository, input.home);
|
|
970
|
+
return {
|
|
971
|
+
label: repoLabel(shown),
|
|
972
|
+
title: `${shown} (${repoKindName(bar.kind)})`,
|
|
973
|
+
outputTokens: bar.outputTokens,
|
|
974
|
+
requests: bar.requests,
|
|
975
|
+
events: bar.events,
|
|
976
|
+
faded: bar.kind === "missing",
|
|
977
|
+
other: false,
|
|
978
|
+
};
|
|
979
|
+
});
|
|
980
|
+
if (rest.length > 0) {
|
|
981
|
+
// Summed here only for drawing: each is a sum of its own unrounded rows from obs_usage_by_repo.
|
|
982
|
+
rows.push({
|
|
983
|
+
label: `Other: ${plural(rest.length, "more repository or folder", "more repositories and folders")}`,
|
|
984
|
+
title: `${plural(rest.length, "repository or folder", "repositories and folders")} past the first ${REPOS.maxBars}`,
|
|
985
|
+
outputTokens: rest.reduce((sum, bar) => sum + bar.outputTokens, 0),
|
|
986
|
+
requests: rest.reduce((sum, bar) => sum + bar.requests, 0),
|
|
987
|
+
events: union(...rest.map((bar) => bar.events)),
|
|
988
|
+
faded: false,
|
|
989
|
+
other: true,
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
// Size the label column to the longest label, within bounds, so bars start after every label.
|
|
993
|
+
const longest = Math.max(...rows.map((row) => row.label.length));
|
|
994
|
+
const left = Math.min(options.width * 0.45, Math.max(80, longest * REPOS.charWidth + 12));
|
|
995
|
+
const plotWidth = options.width - left - REPOS.valueRoom;
|
|
996
|
+
const most = Math.max(...rows.map((row) => row.outputTokens));
|
|
997
|
+
const parts = [];
|
|
998
|
+
rows.forEach((row, i) => {
|
|
999
|
+
const y = REPOS.top + i * REPOS.row;
|
|
1000
|
+
// With no output tokens at all, every bar is empty rather than divided by zero.
|
|
1001
|
+
const width = most > 0 ? (row.outputTokens / most) * plotWidth : 0;
|
|
1002
|
+
parts.push(text(0, y + 15, row.label, { class: "nm-label", fill: "var(--ink-2)" }), el("rect", {
|
|
1003
|
+
class: row.other ? "nm-data nm-repo nm-repo-other" : "nm-data nm-repo",
|
|
1004
|
+
"data-events": eventList(row.events),
|
|
1005
|
+
x: left,
|
|
1006
|
+
y: y + 5,
|
|
1007
|
+
width,
|
|
1008
|
+
height: 14,
|
|
1009
|
+
rx: 2,
|
|
1010
|
+
// The remainder is neutral, so it never reads as one more repository.
|
|
1011
|
+
fill: row.other ? "var(--axis)" : "var(--water)",
|
|
1012
|
+
// Faded, not recolored: a gone directory is still the same kind of bar (D-028).
|
|
1013
|
+
opacity: row.faded ? 0.5 : undefined,
|
|
1014
|
+
}, title(`${row.title}: ${formatNumber(row.outputTokens, "tokens")} output tokens from ${plural(row.requests, "request", "requests")}`)), text(left + width + 6, y + 15, formatNumber(row.outputTokens, "tokens"), {
|
|
1015
|
+
class: "nm-value",
|
|
1016
|
+
fill: "var(--ink-2)",
|
|
1017
|
+
}));
|
|
1018
|
+
});
|
|
1019
|
+
return svg("repos", options, REPOS.top + rows.length * REPOS.row + 4, `${label}, ${plural(input.bars.length, "repository or folder", "repositories and folders")}`, parts);
|
|
1020
|
+
};
|
|
1021
|
+
/**
|
|
1022
|
+
* One open window's burn rate (step G2.1, D-026, D-084): its observed readings, drawn as on its
|
|
1023
|
+
* usage-over-time chart, and the projection, a dotted neutral line from 0% at the window's start
|
|
1024
|
+
* at the window's average rate, to 100% or to the reset, whichever comes first.
|
|
1025
|
+
*
|
|
1026
|
+
* The projection is the only element here that isn't an observation, so it alone is dotted and
|
|
1027
|
+
* neutral: the user's 100% line is red and dashed on every usage chart, and a warm projection
|
|
1028
|
+
* beside it was too close in color for a red-green colorblind reader. The projection, and the
|
|
1029
|
+
* projected time it reaches 100% when that falls before the reset, carry the reading the
|
|
1030
|
+
* projection starts from, as `explain burn-rate` lists it (principle 4).
|
|
1031
|
+
* @param input - The window's projection and its readings.
|
|
1032
|
+
* @param options - Width and zone.
|
|
1033
|
+
* @returns One `<svg>`; a "No data" svg when the window has neither readings nor a last reading.
|
|
1034
|
+
*/
|
|
1035
|
+
export const renderBurnRate = (input, options) => {
|
|
1036
|
+
const tz = options.timeZone;
|
|
1037
|
+
const burn = input.burn;
|
|
1038
|
+
const name = windowTitle(burn.window, burn.resetAtUtc, tz);
|
|
1039
|
+
const label = `${LABELS.burnRate}, projected, for the ${name}`;
|
|
1040
|
+
const points = input.series?.points ?? [];
|
|
1041
|
+
if (points.length === 0 && burn.last === null) {
|
|
1042
|
+
return noData("burn", options, label);
|
|
1043
|
+
}
|
|
1044
|
+
const scale = percentScale([...points.map((p) => p.used), burn.last]);
|
|
1045
|
+
const from = Date.parse(burn.windowStartUtc);
|
|
1046
|
+
const reset = Date.parse(burn.resetAtUtc);
|
|
1047
|
+
const duration = Math.max(1, reset - from);
|
|
1048
|
+
const left = PLOT.left;
|
|
1049
|
+
const right = options.width - PLOT.right;
|
|
1050
|
+
const top = percentY(scale, scale.top);
|
|
1051
|
+
const baseline = percentY(scale, 0);
|
|
1052
|
+
/**
|
|
1053
|
+
* Places an instant on the window's time axis, from its start to its reset.
|
|
1054
|
+
* @param ms - Epoch milliseconds.
|
|
1055
|
+
* @returns Its x coordinate.
|
|
1056
|
+
*/
|
|
1057
|
+
const timeX = (ms) => left + ((ms - from) / duration) * (right - left);
|
|
1058
|
+
const parts = percentAxis(scale, left, right);
|
|
1059
|
+
const resetX = timeX(reset);
|
|
1060
|
+
parts.push(el("line", {
|
|
1061
|
+
class: "nm-reset",
|
|
1062
|
+
x1: resetX,
|
|
1063
|
+
x2: resetX,
|
|
1064
|
+
y1: top,
|
|
1065
|
+
y2: baseline,
|
|
1066
|
+
stroke: "var(--ink-2)",
|
|
1067
|
+
"stroke-width": 1,
|
|
1068
|
+
}), text(resetX, PLOT.labelRow, `${LABELS.columns.resets} ${formatInstant(burn.resetAtUtc, tz)}`, {
|
|
1069
|
+
class: "nm-label",
|
|
1070
|
+
"text-anchor": "end",
|
|
1071
|
+
fill: "var(--ink-2)",
|
|
1072
|
+
}), text(left, baseline + 16, formatInstant(burn.windowStartUtc, tz)));
|
|
1073
|
+
if (!burn.limitReached && burn.ratePerHour !== null) {
|
|
1074
|
+
const limitAt = burn.projectedLimitAtUtc === null ? null : Date.parse(burn.projectedLimitAtUtc);
|
|
1075
|
+
// The line stops at 100% when that comes before the reset, else at the reset (D-026).
|
|
1076
|
+
const end = limitAt !== null && burn.limitBeforeReset ? limitAt : reset;
|
|
1077
|
+
const endPercent = burn.ratePerHour * ((end - from) / 3_600_000);
|
|
1078
|
+
const sentence = burnSentence(burn, tz);
|
|
1079
|
+
parts.push(el("path", {
|
|
1080
|
+
class: "nm-data nm-projection",
|
|
1081
|
+
"data-events": eventList(burn.events),
|
|
1082
|
+
d: `M${num(timeX(from))} ${num(baseline)}L${num(timeX(end))} ${num(percentY(scale, endPercent))}`,
|
|
1083
|
+
fill: "none",
|
|
1084
|
+
stroke: "var(--ink-2)",
|
|
1085
|
+
"stroke-width": 2,
|
|
1086
|
+
"stroke-dasharray": "1 5",
|
|
1087
|
+
"stroke-linecap": "round",
|
|
1088
|
+
}, title(sentence)));
|
|
1089
|
+
if (limitAt !== null && burn.limitBeforeReset) {
|
|
1090
|
+
const x = timeX(limitAt);
|
|
1091
|
+
parts.push(el("circle", {
|
|
1092
|
+
class: "nm-data nm-projected-limit",
|
|
1093
|
+
"data-events": eventList(burn.events),
|
|
1094
|
+
cx: x,
|
|
1095
|
+
cy: percentY(scale, 100),
|
|
1096
|
+
r: 5,
|
|
1097
|
+
fill: "var(--surface)",
|
|
1098
|
+
stroke: "var(--ink-2)",
|
|
1099
|
+
"stroke-width": 2,
|
|
1100
|
+
}, title(sentence)), text(x, PLOT.keyRow, `limit projected ${formatInstant(burn.projectedLimitAtUtc, tz)}`, {
|
|
1101
|
+
class: "nm-label",
|
|
1102
|
+
// Kept inside the plot: anchored at its end when the point is in the right half.
|
|
1103
|
+
"text-anchor": x > (left + right) / 2 ? "end" : "start",
|
|
1104
|
+
fill: "var(--ink-2)",
|
|
1105
|
+
}));
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
else if (burn.limitReached) {
|
|
1109
|
+
parts.push(text(left + 8, top + 14, "Limit reached at the last reading; nothing is projected.", {
|
|
1110
|
+
class: "nm-label",
|
|
1111
|
+
fill: "var(--ink-2)",
|
|
1112
|
+
}));
|
|
1113
|
+
}
|
|
1114
|
+
if (points.length > 0) {
|
|
1115
|
+
parts.push(el("path", {
|
|
1116
|
+
class: "nm-data nm-line",
|
|
1117
|
+
"data-events": eventList(onLine(points).map((p) => p.event)),
|
|
1118
|
+
d: onLine(points)
|
|
1119
|
+
.map((p, i) => `${i === 0 ? "M" : "L"}${num(timeX(Date.parse(p.atUtc)))} ${num(percentY(scale, p.used))}`)
|
|
1120
|
+
.join(""),
|
|
1121
|
+
fill: "none",
|
|
1122
|
+
stroke: "var(--water)",
|
|
1123
|
+
"stroke-width": 2,
|
|
1124
|
+
"stroke-linejoin": "round",
|
|
1125
|
+
"stroke-linecap": "round",
|
|
1126
|
+
}, title(`Observed usage in the ${name}, ${plural(points.length, "reading", "readings")}`)), ...points.map((p) => el("circle", {
|
|
1127
|
+
class: "nm-data nm-point",
|
|
1128
|
+
"data-events": String(p.event),
|
|
1129
|
+
cx: timeX(Date.parse(p.atUtc)),
|
|
1130
|
+
cy: percentY(scale, p.used),
|
|
1131
|
+
...pointStyle(p),
|
|
1132
|
+
}, title(pointTitle(p, tz)))));
|
|
1133
|
+
}
|
|
1134
|
+
const readings = extent(points.map((p) => p.atUtc));
|
|
1135
|
+
const span = readings === null
|
|
1136
|
+
? `Projected from the reading at ${formatInstant(burn.lastAtUtc, tz)}`
|
|
1137
|
+
: `Readings ${formatCoverage(readings.from, readings.to, tz)}; projected from the last one`;
|
|
1138
|
+
parts.push(text(left, baseline + 54, span, { class: "nm-span" }));
|
|
1139
|
+
return svg("burn", options, PLOT.top + PLOT.height + PLOT.bottom, `${label}, ${plural(points.length, "reading", "readings")}. ${span}`, parts);
|
|
1140
|
+
};
|
|
1141
|
+
/**
|
|
1142
|
+
* Turns a burn window back into the `proj_burn_rate` row it was read from, so the page can state it
|
|
1143
|
+
* with the text report's own sentence and the two can never word it differently.
|
|
1144
|
+
* @param burn - The window's projection.
|
|
1145
|
+
* @returns The row; `window_open` is 1, since only open windows are loaded.
|
|
1146
|
+
*/
|
|
1147
|
+
export function burnRow(burn) {
|
|
1148
|
+
return {
|
|
1149
|
+
window: burn.window,
|
|
1150
|
+
reset_at_utc: burn.resetAtUtc,
|
|
1151
|
+
window_start_utc: burn.windowStartUtc,
|
|
1152
|
+
last_used_percentage: burn.last,
|
|
1153
|
+
last_reading_at_utc: burn.lastAtUtc,
|
|
1154
|
+
window_open: 1,
|
|
1155
|
+
// The page loads only windows still open at the report's time (D-083).
|
|
1156
|
+
open_at_report: 1,
|
|
1157
|
+
limit_reached: burn.limitReached ? 1 : 0,
|
|
1158
|
+
projected_percentage_points_per_hour: burn.ratePerHour,
|
|
1159
|
+
projected_limit_at_utc: burn.projectedLimitAtUtc,
|
|
1160
|
+
projected_limit_before_reset: burn.limitBeforeReset ? 1 : 0,
|
|
1161
|
+
covers_from: burn.span.from,
|
|
1162
|
+
covers_to: burn.span.to,
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* States a burn rate in the text report's words (`describeBurnRate`), without its indent, for the
|
|
1167
|
+
* projection's title: what it starts from, the rate, and when it reaches 100%.
|
|
1168
|
+
* @param burn - The window's projection.
|
|
1169
|
+
* @param tz - Display zone.
|
|
1170
|
+
* @returns One sentence; it says "projected" unless the limit was already reached.
|
|
1171
|
+
*/
|
|
1172
|
+
export function burnSentence(burn, tz) {
|
|
1173
|
+
return describeBurnRate(burnRow(burn), tz).trim();
|
|
1174
|
+
}
|
|
1175
|
+
/** Layout of the month chart. */
|
|
1176
|
+
const MONTHS = { top: 24, height: 180, left: 56, right: 12, bottom: 56, maxBar: 40 };
|
|
1177
|
+
/**
|
|
1178
|
+
* Chooses a dollar axis: the smallest 1-2-5 step that needs at most five steps.
|
|
1179
|
+
* @param highest - The largest dollar amount drawn.
|
|
1180
|
+
* @returns The step and the axis end (a whole number of steps, at least one).
|
|
1181
|
+
*/
|
|
1182
|
+
function dollarAxis(highest) {
|
|
1183
|
+
if (!(highest > 0)) {
|
|
1184
|
+
return { step: 1, end: 1 };
|
|
1185
|
+
}
|
|
1186
|
+
const base = 10 ** Math.floor(Math.log10(highest / 5));
|
|
1187
|
+
const step = [1, 2, 5, 10].map((m) => base * m).find((s) => Math.ceil(highest / s) <= 5) ?? base * 10;
|
|
1188
|
+
return { step, end: Math.max(1, Math.ceil(highest / step)) * step };
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* Labels a dollar tick with as many decimals as its step needs.
|
|
1192
|
+
* @param value - The tick's value.
|
|
1193
|
+
* @param step - The axis step.
|
|
1194
|
+
* @returns E.g. `$1,000`, `$50`, `$0.02`.
|
|
1195
|
+
*/
|
|
1196
|
+
function dollarTick(value, step) {
|
|
1197
|
+
if (step >= 1) {
|
|
1198
|
+
return `$${formatNumber(value, "count")}`;
|
|
1199
|
+
}
|
|
1200
|
+
// Enough decimals to tell neighbouring ticks apart; the epsilon absorbs log10 rounding.
|
|
1201
|
+
return `$${value.toFixed(Math.ceil(-Math.log10(step) - 1e-9))}`;
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Projected: observed tokens at API list price, one column per month, with the plan price entered
|
|
1205
|
+
* for that month as a dashed line only when one was entered (D-027, principle 3).
|
|
1206
|
+
*
|
|
1207
|
+
* A month with nothing priced shows "unknown", never a $0 column (D-005). Nothing here compares
|
|
1208
|
+
* the two: both are shown as they are.
|
|
1209
|
+
* @param bars - Months in order.
|
|
1210
|
+
* @param options - Width and zone.
|
|
1211
|
+
* @returns One `<svg>`; a "No data" svg when there are no months.
|
|
1212
|
+
*/
|
|
1213
|
+
export const renderMonthBars = (bars, options) => {
|
|
1214
|
+
const label = `${LABELS.apiListPrice} (projected)`;
|
|
1215
|
+
if (bars.length === 0) {
|
|
1216
|
+
return noData("months", options, label);
|
|
1217
|
+
}
|
|
1218
|
+
const tz = options.timeZone;
|
|
1219
|
+
const amounts = bars.flatMap((b) => [b.apiListPriceUsd, b.planUsdPerMonth]);
|
|
1220
|
+
const axis = dollarAxis(Math.max(0, ...amounts.filter((v) => v !== null)));
|
|
1221
|
+
const left = MONTHS.left;
|
|
1222
|
+
const right = options.width - MONTHS.right;
|
|
1223
|
+
const baseline = MONTHS.top + MONTHS.height;
|
|
1224
|
+
/**
|
|
1225
|
+
* Places a dollar amount on the axis.
|
|
1226
|
+
* @param usd - Amount, unrounded.
|
|
1227
|
+
* @returns Its y coordinate.
|
|
1228
|
+
*/
|
|
1229
|
+
const usdY = (usd) => MONTHS.top + ((axis.end - usd) / axis.end) * MONTHS.height;
|
|
1230
|
+
const parts = [];
|
|
1231
|
+
for (let k = 0; k * axis.step <= axis.end * (1 + 1e-9); k += 1) {
|
|
1232
|
+
const value = k * axis.step;
|
|
1233
|
+
const y = usdY(value);
|
|
1234
|
+
parts.push(el("line", {
|
|
1235
|
+
class: "nm-grid",
|
|
1236
|
+
x1: left,
|
|
1237
|
+
x2: right,
|
|
1238
|
+
y1: y,
|
|
1239
|
+
y2: y,
|
|
1240
|
+
stroke: k === 0 ? "var(--axis)" : "var(--grid)",
|
|
1241
|
+
"stroke-width": 1,
|
|
1242
|
+
}), text(left - 7, y + 3.5, dollarTick(value, axis.step), {
|
|
1243
|
+
class: "nm-tick",
|
|
1244
|
+
"text-anchor": "end",
|
|
1245
|
+
}));
|
|
1246
|
+
}
|
|
1247
|
+
const slot = (right - left) / bars.length;
|
|
1248
|
+
const width = Math.min(MONTHS.maxBar, slot * 0.5);
|
|
1249
|
+
bars.forEach((bar, i) => {
|
|
1250
|
+
const cx = left + slot * (i + 0.5);
|
|
1251
|
+
const covered = formatCoverage(bar.span.from, bar.span.to, tz);
|
|
1252
|
+
if (bar.apiListPriceUsd === null) {
|
|
1253
|
+
// Nothing priced: say so, rather than draw a $0 column (D-005).
|
|
1254
|
+
parts.push(text(cx, baseline - 6, UNKNOWN, { "text-anchor": "middle" }));
|
|
1255
|
+
}
|
|
1256
|
+
else {
|
|
1257
|
+
const y = usdY(bar.apiListPriceUsd);
|
|
1258
|
+
const usd = formatNumber(bar.apiListPriceUsd, "usd");
|
|
1259
|
+
parts.push(el("rect", {
|
|
1260
|
+
class: "nm-data nm-month",
|
|
1261
|
+
"data-events": eventList(bar.events),
|
|
1262
|
+
x: cx - width / 2,
|
|
1263
|
+
y,
|
|
1264
|
+
width,
|
|
1265
|
+
height: baseline - y,
|
|
1266
|
+
rx: 2,
|
|
1267
|
+
fill: "var(--silt)",
|
|
1268
|
+
"fill-opacity": 0.55,
|
|
1269
|
+
}, title(`Projected: observed tokens at API list price for ${bar.month}, ${usd}; ${plural(bar.pricedRequests, "priced request", "priced requests")}, ${plural(bar.unpricedRequests, "unpriced request", "unpriced requests")} (not counted as $0); covers ${covered}`)), text(cx, y - 6, usd, { class: "nm-value", "text-anchor": "middle", fill: "var(--ink)" }));
|
|
1270
|
+
}
|
|
1271
|
+
if (bar.planUsdPerMonth !== null) {
|
|
1272
|
+
const y = usdY(bar.planUsdPerMonth);
|
|
1273
|
+
const price = `${bar.planName ?? LABELS.columns.planPrice} ${formatNumber(bar.planUsdPerMonth, "usd")}`;
|
|
1274
|
+
parts.push(el("line", {
|
|
1275
|
+
class: "nm-ref nm-plan",
|
|
1276
|
+
x1: cx - width / 2 - 8,
|
|
1277
|
+
x2: cx + width / 2 + 8,
|
|
1278
|
+
y1: y,
|
|
1279
|
+
y2: y,
|
|
1280
|
+
stroke: "var(--silt)",
|
|
1281
|
+
"stroke-width": 2,
|
|
1282
|
+
"stroke-dasharray": "4 3",
|
|
1283
|
+
}, title(`${LABELS.columns.planPrice} for ${bar.month}: ${price}`)), text(cx + width / 2 + 12, y + 4, price, { class: "nm-label", fill: "var(--ink-2)" }));
|
|
1284
|
+
}
|
|
1285
|
+
parts.push(text(cx, baseline + 16, bar.month, { "text-anchor": "middle" }));
|
|
1286
|
+
});
|
|
1287
|
+
const covered = extent(bars.flatMap((b) => b.span.from === null || b.span.to === null ? [] : [b.span.from, b.span.to]));
|
|
1288
|
+
const span = `Months ${formatCoverage(covered?.from, covered?.to, tz)}`;
|
|
1289
|
+
parts.push(text(left, baseline + 34, span, { class: "nm-span" }));
|
|
1290
|
+
return svg("months", options, baseline + MONTHS.bottom, `${label}. ${span}`, parts);
|
|
1291
|
+
};
|
|
1292
|
+
//# sourceMappingURL=html-charts.js.map
|