@po.dev/pi-usage-dashboard 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/README.md +11 -0
- package/data.ts +1859 -0
- package/export.ts +146 -0
- package/graph.ts +399 -0
- package/index.ts +1231 -0
- package/package.json +13 -0
package/export.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure builders for /usage export ([e] key).
|
|
3
|
+
*
|
|
4
|
+
* Each view exports its current slice: the table as per-model CSV rows, the
|
|
5
|
+
* graph as one CSV column per visible series, and insights as structured JSON.
|
|
6
|
+
* Builders are pure string producers so they stay trivially testable; the
|
|
7
|
+
* component owns file naming and disk writes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import type { Insight, ProviderStats, TotalStats } from "./data.ts";
|
|
12
|
+
import type { GraphModel } from "./graph.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read the configured export directory from settings.json content, if any.
|
|
16
|
+
* Config shape: `{ "usage-extension": { "exportDir": "~/Downloads" } }`.
|
|
17
|
+
*/
|
|
18
|
+
export function parseExportDirSetting(settingsJson: string): string | null {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(settingsJson) as { "usage-extension"?: { exportDir?: unknown } };
|
|
21
|
+
const dir = parsed["usage-extension"]?.exportDir;
|
|
22
|
+
return typeof dir === "string" && dir.trim() !== "" ? dir.trim() : null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Pick the export directory. A configured dir wins (with `~` expanded);
|
|
30
|
+
* otherwise exports go to /tmp so they never litter a repo or home
|
|
31
|
+
* directory, falling back to the OS temp dir where /tmp doesn't exist.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveExportDir(
|
|
34
|
+
configured: string | null,
|
|
35
|
+
home: string,
|
|
36
|
+
slashTmpExists: boolean,
|
|
37
|
+
fallbackTmp: string,
|
|
38
|
+
): string {
|
|
39
|
+
if (configured !== null) {
|
|
40
|
+
if (configured === "~") return home;
|
|
41
|
+
if (configured.startsWith("~/")) return join(home, configured.slice(2));
|
|
42
|
+
return configured;
|
|
43
|
+
}
|
|
44
|
+
return slashTmpExists ? "/tmp" : fallbackTmp;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Quote a CSV field only when it needs it (comma, quote, or newline). */
|
|
48
|
+
function csvField(value: string | number): string {
|
|
49
|
+
const s = String(value);
|
|
50
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function csvLine(fields: (string | number)[]): string {
|
|
54
|
+
return fields.map(csvField).join(",");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Per-model rows (provider repeated), then a TOTAL row. */
|
|
58
|
+
export function buildTableCsv(providers: ReadonlyMap<string, ProviderStats>, totals: TotalStats): string {
|
|
59
|
+
const lines = [
|
|
60
|
+
csvLine([
|
|
61
|
+
"provider",
|
|
62
|
+
"model",
|
|
63
|
+
"sessions",
|
|
64
|
+
"messages",
|
|
65
|
+
"cost_usd",
|
|
66
|
+
"fresh_tokens",
|
|
67
|
+
"input_tokens",
|
|
68
|
+
"output_tokens",
|
|
69
|
+
"cache_read_tokens",
|
|
70
|
+
"cache_write_tokens",
|
|
71
|
+
]),
|
|
72
|
+
];
|
|
73
|
+
const sorted = Array.from(providers.entries()).sort((a, b) => b[1].cost - a[1].cost);
|
|
74
|
+
for (const [providerName, provider] of sorted) {
|
|
75
|
+
const models = Array.from(provider.models.entries()).sort((a, b) => b[1].cost - a[1].cost);
|
|
76
|
+
for (const [modelName, model] of models) {
|
|
77
|
+
lines.push(
|
|
78
|
+
csvLine([
|
|
79
|
+
providerName,
|
|
80
|
+
modelName,
|
|
81
|
+
model.sessions.size,
|
|
82
|
+
model.messages,
|
|
83
|
+
model.cost,
|
|
84
|
+
model.tokens.total,
|
|
85
|
+
model.tokens.input,
|
|
86
|
+
model.tokens.output,
|
|
87
|
+
model.tokens.cacheRead,
|
|
88
|
+
model.tokens.cacheWrite,
|
|
89
|
+
])
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
lines.push(
|
|
94
|
+
csvLine([
|
|
95
|
+
"TOTAL",
|
|
96
|
+
"",
|
|
97
|
+
totals.sessions,
|
|
98
|
+
totals.messages,
|
|
99
|
+
totals.cost,
|
|
100
|
+
totals.tokens.total,
|
|
101
|
+
totals.tokens.input,
|
|
102
|
+
totals.tokens.output,
|
|
103
|
+
totals.tokens.cacheRead,
|
|
104
|
+
totals.tokens.cacheWrite,
|
|
105
|
+
])
|
|
106
|
+
);
|
|
107
|
+
return lines.join("\n") + "\n";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One row per time bucket, one column per visible series, values exactly as
|
|
112
|
+
* plotted (per-bucket or cumulative, current metric).
|
|
113
|
+
*/
|
|
114
|
+
export function buildGraphCsv(model: GraphModel): string {
|
|
115
|
+
const visible = model.series.filter((s) => !s.hidden);
|
|
116
|
+
const lines = [csvLine(["bucket_start", ...visible.map((s) => s.label)])];
|
|
117
|
+
for (let i = 0; i < model.bucketStarts.length; i++) {
|
|
118
|
+
lines.push(csvLine([new Date(model.bucketStarts[i]!).toISOString(), ...visible.map((s) => s.points[i] ?? 0)]));
|
|
119
|
+
}
|
|
120
|
+
return lines.join("\n") + "\n";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Structured JSON of the period's insights plus headline totals. */
|
|
124
|
+
export function buildInsightsJson(period: string, totals: TotalStats, insights: Insight[]): string {
|
|
125
|
+
return (
|
|
126
|
+
JSON.stringify(
|
|
127
|
+
{
|
|
128
|
+
period,
|
|
129
|
+
generatedAt: new Date().toISOString(),
|
|
130
|
+
totals: { costUsd: totals.cost, messages: totals.messages, sessions: totals.sessions },
|
|
131
|
+
insights: insights.map((i) => ({ kind: i.kind, stat: i.stat, headline: i.headline, advice: i.advice })),
|
|
132
|
+
},
|
|
133
|
+
null,
|
|
134
|
+
"\t"
|
|
135
|
+
) + "\n"
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** usage-<view>-<period>[-<slice>]-<stamp>.<ext> in the current directory. */
|
|
140
|
+
export function exportFileName(view: string, period: string, slice: string | null, ext: string, now: Date): string {
|
|
141
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
142
|
+
const stamp =
|
|
143
|
+
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
|
144
|
+
`-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
145
|
+
return ["usage", view, period, ...(slice ? [slice] : []), stamp].join("-") + `.${ext}`;
|
|
146
|
+
}
|
package/graph.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph explorer model + braille chart rendering for /usage.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is pure and theme-free: series building works off the hourly
|
|
5
|
+
* buckets produced by data.ts, and the renderer emits plain text plus a
|
|
6
|
+
* colorize callback so the UI layer owns all styling. This keeps the module
|
|
7
|
+
* fully unit-testable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Explicit .ts extension so plain `node --test` (type stripping) can resolve
|
|
11
|
+
// this module too; pi's extension loader accepts it as well.
|
|
12
|
+
import type { HourlyCell, HourlyKey, PeriodBounds, TabName } from "./data.ts";
|
|
13
|
+
import { splitHourlyKey } from "./data.ts";
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// Options and model types
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
export type GraphMetric = "cost" | "tokens" | "messages" | "reasoning";
|
|
20
|
+
export type GraphGroupBy = "provider" | "model" | "thinking" | "total";
|
|
21
|
+
|
|
22
|
+
export const METRIC_ORDER: GraphMetric[] = ["cost", "tokens", "messages", "reasoning"];
|
|
23
|
+
export const GROUP_ORDER: GraphGroupBy[] = ["provider", "model", "thinking", "total"];
|
|
24
|
+
|
|
25
|
+
export const METRIC_LABELS: Record<GraphMetric, string> = {
|
|
26
|
+
cost: "cost",
|
|
27
|
+
tokens: "tokens",
|
|
28
|
+
messages: "messages",
|
|
29
|
+
reasoning: "reasoning tokens",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const GROUP_LABELS: Record<GraphGroupBy, string> = {
|
|
33
|
+
provider: "by provider",
|
|
34
|
+
model: "by model",
|
|
35
|
+
thinking: "by thinking level",
|
|
36
|
+
total: "total only",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Series beyond this cap are merged into a single "other" series. */
|
|
40
|
+
export const MAX_GROUP_SERIES = 6;
|
|
41
|
+
|
|
42
|
+
export const TOTAL_SERIES_KEY = "\u0000total";
|
|
43
|
+
export const OTHER_SERIES_KEY = "\u0000other";
|
|
44
|
+
|
|
45
|
+
export interface GraphOptions {
|
|
46
|
+
period: TabName;
|
|
47
|
+
metric: GraphMetric;
|
|
48
|
+
groupBy: GraphGroupBy;
|
|
49
|
+
cumulative: boolean;
|
|
50
|
+
/** Series keys hidden via the legend. */
|
|
51
|
+
hidden?: ReadonlySet<string>;
|
|
52
|
+
bounds: PeriodBounds;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface GraphSeries {
|
|
56
|
+
key: string;
|
|
57
|
+
label: string;
|
|
58
|
+
/** One value per bucket. Cumulative when options.cumulative. */
|
|
59
|
+
points: number[];
|
|
60
|
+
/** Period total for this series (not affected by cumulative). */
|
|
61
|
+
total: number;
|
|
62
|
+
hidden: boolean;
|
|
63
|
+
/** First bucket index with activity, or -1 when the series is empty. */
|
|
64
|
+
firstIdx: number;
|
|
65
|
+
/** Last bucket index with activity, or -1 when the series is empty. */
|
|
66
|
+
lastIdx: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface GraphModel {
|
|
70
|
+
series: GraphSeries[];
|
|
71
|
+
/** Bucket start timestamps (ms), ascending. */
|
|
72
|
+
bucketStarts: number[];
|
|
73
|
+
bucketMs: number;
|
|
74
|
+
domainStartMs: number;
|
|
75
|
+
domainEndMs: number;
|
|
76
|
+
/** Max point value across visible series (y-axis scale). */
|
|
77
|
+
yMax: number;
|
|
78
|
+
/** Sum of totals across grouped (non-total) series, for legend percentages. */
|
|
79
|
+
groupedTotal: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// =============================================================================
|
|
83
|
+
// Series building
|
|
84
|
+
// =============================================================================
|
|
85
|
+
|
|
86
|
+
const HOUR_MS = 3_600_000;
|
|
87
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
88
|
+
/** Periods spanning at most this many hours use hourly buckets; otherwise daily. */
|
|
89
|
+
const MAX_HOURLY_BUCKETS = 8 * 24;
|
|
90
|
+
|
|
91
|
+
function metricOf(cell: HourlyCell, metric: GraphMetric): number {
|
|
92
|
+
switch (metric) {
|
|
93
|
+
case "cost":
|
|
94
|
+
return cell.cost;
|
|
95
|
+
case "tokens":
|
|
96
|
+
// Matches the dashboard formula: fresh tokens = input + output + cacheWrite.
|
|
97
|
+
return cell.input + cell.output + cell.cacheWrite;
|
|
98
|
+
case "messages":
|
|
99
|
+
return cell.messages;
|
|
100
|
+
case "reasoning":
|
|
101
|
+
return cell.reasoning;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function groupKeyOf(key: HourlyKey, groupBy: GraphGroupBy): string {
|
|
106
|
+
if (groupBy === "total") return TOTAL_SERIES_KEY;
|
|
107
|
+
const { provider, model, thinkingLevel } = splitHourlyKey(key);
|
|
108
|
+
if (groupBy === "provider") return provider;
|
|
109
|
+
if (groupBy === "model") return model;
|
|
110
|
+
return thinkingLevel === "" ? "unknown" : thinkingLevel;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function domainFor(period: TabName, bounds: PeriodBounds, hourly: Map<number, Map<HourlyKey, HourlyCell>>): { startMs: number; endMs: number } {
|
|
114
|
+
switch (period) {
|
|
115
|
+
case "today":
|
|
116
|
+
return { startMs: bounds.todayMs, endMs: bounds.nowMs };
|
|
117
|
+
case "thisWeek":
|
|
118
|
+
return { startMs: bounds.weekStartMs, endMs: bounds.nowMs };
|
|
119
|
+
case "lastWeek":
|
|
120
|
+
return { startMs: bounds.lastWeekStartMs, endMs: bounds.weekStartMs };
|
|
121
|
+
case "last30Days":
|
|
122
|
+
return { startMs: bounds.last30DaysStartMs, endMs: bounds.nowMs };
|
|
123
|
+
case "allTime": {
|
|
124
|
+
let first = Number.POSITIVE_INFINITY;
|
|
125
|
+
for (const hour of hourly.keys()) if (hour < first) first = hour;
|
|
126
|
+
if (!Number.isFinite(first)) first = bounds.todayMs;
|
|
127
|
+
return { startMs: Math.min(first, bounds.nowMs), endMs: bounds.nowMs };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build the graph model for one (period, metric, groupBy) view.
|
|
134
|
+
*
|
|
135
|
+
* Buckets are hourly for short periods and daily for long ones. Group series
|
|
136
|
+
* are capped at MAX_GROUP_SERIES by period total; the rest merge into "other".
|
|
137
|
+
* A Total series is always present (first). Hidden series keep their points
|
|
138
|
+
* but are excluded from the y-axis scale.
|
|
139
|
+
*/
|
|
140
|
+
export function buildGraphModel(
|
|
141
|
+
hourly: Map<number, Map<HourlyKey, HourlyCell>>,
|
|
142
|
+
options: GraphOptions
|
|
143
|
+
): GraphModel {
|
|
144
|
+
const { startMs, endMs } = domainFor(options.period, options.bounds, hourly);
|
|
145
|
+
const spanMs = Math.max(endMs - startMs, 1);
|
|
146
|
+
const bucketMs = spanMs / HOUR_MS <= MAX_HOURLY_BUCKETS ? HOUR_MS : DAY_MS;
|
|
147
|
+
|
|
148
|
+
// Bucket starts aligned to the domain start so "day" buckets follow the
|
|
149
|
+
// local-midnight period boundaries computed by data.ts (DST shifts move a
|
|
150
|
+
// boundary by an hour, which is invisible at graph resolution).
|
|
151
|
+
const bucketCount = Math.max(1, Math.ceil(spanMs / bucketMs));
|
|
152
|
+
const bucketStarts: number[] = [];
|
|
153
|
+
for (let i = 0; i < bucketCount; i++) bucketStarts.push(startMs + i * bucketMs);
|
|
154
|
+
|
|
155
|
+
// Accumulate per-group bucket values.
|
|
156
|
+
const groupValues = new Map<string, number[]>();
|
|
157
|
+
const groupTotals = new Map<string, number>();
|
|
158
|
+
const totalPoints = new Array<number>(bucketCount).fill(0);
|
|
159
|
+
let totalSum = 0;
|
|
160
|
+
|
|
161
|
+
for (const [hour, bucket] of hourly) {
|
|
162
|
+
if (hour < startMs || hour >= endMs) continue;
|
|
163
|
+
const idx = Math.min(bucketCount - 1, Math.floor((hour - startMs) / bucketMs));
|
|
164
|
+
for (const [key, cell] of bucket) {
|
|
165
|
+
const value = metricOf(cell, options.metric);
|
|
166
|
+
if (value === 0) continue;
|
|
167
|
+
totalPoints[idx] += value;
|
|
168
|
+
totalSum += value;
|
|
169
|
+
if (options.groupBy !== "total") {
|
|
170
|
+
const groupKey = groupKeyOf(key, options.groupBy);
|
|
171
|
+
let points = groupValues.get(groupKey);
|
|
172
|
+
if (!points) {
|
|
173
|
+
points = new Array<number>(bucketCount).fill(0);
|
|
174
|
+
groupValues.set(groupKey, points);
|
|
175
|
+
}
|
|
176
|
+
points[idx] += value;
|
|
177
|
+
groupTotals.set(groupKey, (groupTotals.get(groupKey) ?? 0) + value);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Rank groups and cap at MAX_GROUP_SERIES; merge the tail into "other".
|
|
183
|
+
const ranked = Array.from(groupTotals.entries()).sort((a, b) => b[1] - a[1]);
|
|
184
|
+
const kept = ranked.slice(0, MAX_GROUP_SERIES);
|
|
185
|
+
const merged = ranked.slice(MAX_GROUP_SERIES);
|
|
186
|
+
|
|
187
|
+
const hidden = options.hidden ?? new Set<string>();
|
|
188
|
+
const series: GraphSeries[] = [];
|
|
189
|
+
|
|
190
|
+
// Active range per series (computed on raw per-bucket values, before any
|
|
191
|
+
// cumulative transform): lines are later drawn only between the first and
|
|
192
|
+
// last bucket with usage, so late-starting or retired series do not drag a
|
|
193
|
+
// flat zero/flat tail across the whole period.
|
|
194
|
+
const activeRange = (points: number[]): { firstIdx: number; lastIdx: number } => {
|
|
195
|
+
let firstIdx = -1;
|
|
196
|
+
let lastIdx = -1;
|
|
197
|
+
for (let i = 0; i < points.length; i++) {
|
|
198
|
+
if (points[i] !== 0) {
|
|
199
|
+
if (firstIdx === -1) firstIdx = i;
|
|
200
|
+
lastIdx = i;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { firstIdx, lastIdx };
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
series.push({
|
|
207
|
+
key: TOTAL_SERIES_KEY,
|
|
208
|
+
label: "Total",
|
|
209
|
+
points: totalPoints.slice(),
|
|
210
|
+
total: totalSum,
|
|
211
|
+
hidden: hidden.has(TOTAL_SERIES_KEY),
|
|
212
|
+
...activeRange(totalPoints),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
for (const [groupKey, total] of kept) {
|
|
216
|
+
const points = groupValues.get(groupKey)!;
|
|
217
|
+
series.push({
|
|
218
|
+
key: groupKey,
|
|
219
|
+
label: groupKey,
|
|
220
|
+
points,
|
|
221
|
+
total,
|
|
222
|
+
hidden: hidden.has(groupKey),
|
|
223
|
+
...activeRange(points),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (merged.length > 0) {
|
|
228
|
+
const points = new Array<number>(bucketCount).fill(0);
|
|
229
|
+
let total = 0;
|
|
230
|
+
for (const [groupKey, groupTotal] of merged) {
|
|
231
|
+
const groupPoints = groupValues.get(groupKey)!;
|
|
232
|
+
for (let i = 0; i < bucketCount; i++) points[i] += groupPoints[i]!;
|
|
233
|
+
total += groupTotal;
|
|
234
|
+
}
|
|
235
|
+
series.push({
|
|
236
|
+
key: OTHER_SERIES_KEY,
|
|
237
|
+
label: `other (${merged.length})`,
|
|
238
|
+
points,
|
|
239
|
+
total,
|
|
240
|
+
hidden: hidden.has(OTHER_SERIES_KEY),
|
|
241
|
+
...activeRange(points),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (options.cumulative) {
|
|
246
|
+
for (const s of series) {
|
|
247
|
+
let running = 0;
|
|
248
|
+
s.points = s.points.map((v) => (running += v));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let yMax = 0;
|
|
253
|
+
for (const s of series) {
|
|
254
|
+
if (s.hidden) continue;
|
|
255
|
+
for (const v of s.points) if (v > yMax) yMax = v;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
series,
|
|
260
|
+
bucketStarts,
|
|
261
|
+
bucketMs,
|
|
262
|
+
domainStartMs: startMs,
|
|
263
|
+
domainEndMs: endMs,
|
|
264
|
+
yMax,
|
|
265
|
+
groupedTotal: totalSum,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// =============================================================================
|
|
270
|
+
// Braille chart rendering
|
|
271
|
+
// =============================================================================
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Style callback: seriesIndex is the index into model.series, or -1 for
|
|
275
|
+
* chart furniture (axes). Return the text styled for the terminal.
|
|
276
|
+
*/
|
|
277
|
+
export type ChartColorize = (seriesIndex: number, text: string) => string;
|
|
278
|
+
|
|
279
|
+
export interface ChartRenderOptions {
|
|
280
|
+
width: number;
|
|
281
|
+
/** Text rows for the plot area (each row is 4 braille dots tall). */
|
|
282
|
+
height: number;
|
|
283
|
+
formatValue: (value: number) => string;
|
|
284
|
+
formatTime: (ms: number) => string;
|
|
285
|
+
colorize?: ChartColorize;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const BRAILLE_BASE = 0x2800;
|
|
289
|
+
// Dot bit masks by (x: 0=left, 1=right) and (y: 0=top .. 3=bottom).
|
|
290
|
+
const DOT_BITS = [
|
|
291
|
+
[0x01, 0x02, 0x04, 0x40],
|
|
292
|
+
[0x08, 0x10, 0x20, 0x80],
|
|
293
|
+
] as const;
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Render the model as a braille line chart with a y-axis and an x-axis line.
|
|
297
|
+
* Returns exactly height + 1 lines (plot rows + x-axis labels).
|
|
298
|
+
*/
|
|
299
|
+
export function renderChart(model: GraphModel, options: ChartRenderOptions): string[] {
|
|
300
|
+
const colorize: ChartColorize = options.colorize ?? ((_i, text) => text);
|
|
301
|
+
const plotHeightForLabels = Math.max(options.height, 4);
|
|
302
|
+
const midRowForLabels = Math.floor((plotHeightForLabels - 1) / 2);
|
|
303
|
+
const midValue = (model.yMax * (plotHeightForLabels - 1 - midRowForLabels)) / (plotHeightForLabels - 1);
|
|
304
|
+
const yLabelWidth = Math.max(
|
|
305
|
+
options.formatValue(model.yMax).length,
|
|
306
|
+
options.formatValue(midValue).length,
|
|
307
|
+
options.formatValue(0).length
|
|
308
|
+
);
|
|
309
|
+
const axisWidth = yLabelWidth + 2; // label + " ┤" / " │"
|
|
310
|
+
const plotWidth = Math.max(options.width - axisWidth, 10);
|
|
311
|
+
const plotHeight = Math.max(options.height, 4);
|
|
312
|
+
const dotW = plotWidth * 2;
|
|
313
|
+
const dotH = plotHeight * 4;
|
|
314
|
+
|
|
315
|
+
// masks[row][col] per series index — draw order decides which color wins a cell.
|
|
316
|
+
const cellMasks: number[][] = Array.from({ length: plotHeight }, () => new Array<number>(plotWidth).fill(0));
|
|
317
|
+
const cellOwner: number[][] = Array.from({ length: plotHeight }, () => new Array<number>(plotWidth).fill(-1));
|
|
318
|
+
|
|
319
|
+
const yMax = model.yMax > 0 ? model.yMax : 1;
|
|
320
|
+
const bucketCount = model.bucketStarts.length;
|
|
321
|
+
|
|
322
|
+
const plot = (seriesIndex: number, points: number[], firstIdx: number, lastIdx: number) => {
|
|
323
|
+
if (firstIdx < 0) return;
|
|
324
|
+
for (let i = firstIdx; i <= lastIdx; i++) {
|
|
325
|
+
const x = bucketCount === 1 ? dotW - 1 : Math.round((i / (bucketCount - 1)) * (dotW - 1));
|
|
326
|
+
const y = Math.round((1 - (points[i]! / yMax)) * (dotH - 1));
|
|
327
|
+
// One visible point per bucket: hourly data should not imply activity
|
|
328
|
+
// between requests by drawing a connecting line.
|
|
329
|
+
setDot(x, y, seriesIndex);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const setDot = (x: number, y: number, seriesIndex: number) => {
|
|
334
|
+
if (x < 0 || y < 0 || x >= dotW || y >= dotH) return;
|
|
335
|
+
const col = Math.floor(x / 2);
|
|
336
|
+
const row = Math.floor(y / 4);
|
|
337
|
+
cellMasks[row]![col]! |= DOT_BITS[x % 2]![y % 4]!;
|
|
338
|
+
cellOwner[row]![col] = seriesIndex;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
// Draw least-important first so important series own contested cells:
|
|
342
|
+
// other → smallest groups → largest group → total.
|
|
343
|
+
const drawOrder = model.series
|
|
344
|
+
.map((s, i) => ({ s, i }))
|
|
345
|
+
.filter(({ s }) => !s.hidden)
|
|
346
|
+
.sort((a, b) => {
|
|
347
|
+
const rank = (entry: { s: GraphSeries; i: number }) =>
|
|
348
|
+
entry.s.key === TOTAL_SERIES_KEY ? Number.POSITIVE_INFINITY : entry.s.key === OTHER_SERIES_KEY ? -1 : entry.s.total;
|
|
349
|
+
return rank(a) - rank(b);
|
|
350
|
+
});
|
|
351
|
+
for (const { s, i } of drawOrder) plot(i, s.points, s.firstIdx, s.lastIdx);
|
|
352
|
+
|
|
353
|
+
// Compose text rows.
|
|
354
|
+
const lines: string[] = [];
|
|
355
|
+
const midRow = Math.floor((plotHeight - 1) / 2);
|
|
356
|
+
for (let row = 0; row < plotHeight; row++) {
|
|
357
|
+
let label = "";
|
|
358
|
+
if (row === 0) label = options.formatValue(model.yMax);
|
|
359
|
+
else if (row === midRow && plotHeight > 2) label = options.formatValue(midValue);
|
|
360
|
+
else if (row === plotHeight - 1) label = options.formatValue(0);
|
|
361
|
+
const axisChar = label ? "┤" : "│";
|
|
362
|
+
let line = colorize(-1, label.padStart(yLabelWidth) + " " + axisChar);
|
|
363
|
+
// Batch consecutive cells with the same owning series into one colorize
|
|
364
|
+
// call to keep ANSI overhead proportional to color changes, not cells.
|
|
365
|
+
let runOwner = -2;
|
|
366
|
+
let runText = "";
|
|
367
|
+
const flush = () => {
|
|
368
|
+
if (!runText) return;
|
|
369
|
+
line += runOwner === -2 ? runText : colorize(runOwner, runText);
|
|
370
|
+
runText = "";
|
|
371
|
+
};
|
|
372
|
+
for (let col = 0; col < plotWidth; col++) {
|
|
373
|
+
const mask = cellMasks[row]![col]!;
|
|
374
|
+
const owner = mask === 0 ? -2 : cellOwner[row]![col]!;
|
|
375
|
+
if (owner !== runOwner) {
|
|
376
|
+
flush();
|
|
377
|
+
runOwner = owner;
|
|
378
|
+
}
|
|
379
|
+
runText += mask === 0 ? " " : "•";
|
|
380
|
+
}
|
|
381
|
+
flush();
|
|
382
|
+
lines.push(line);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// X-axis labels: start, optional middle, end.
|
|
386
|
+
const startLabel = options.formatTime(model.domainStartMs);
|
|
387
|
+
const endLabel = options.formatTime(model.domainEndMs);
|
|
388
|
+
const midLabel = plotWidth >= startLabel.length + endLabel.length + 14 ? options.formatTime(model.domainStartMs + (model.domainEndMs - model.domainStartMs) / 2) : "";
|
|
389
|
+
let axis = " ".repeat(yLabelWidth + 2) + startLabel;
|
|
390
|
+
if (midLabel) {
|
|
391
|
+
const midPos = yLabelWidth + 2 + Math.floor(plotWidth / 2 - midLabel.length / 2);
|
|
392
|
+
axis = axis.padEnd(midPos) + midLabel;
|
|
393
|
+
}
|
|
394
|
+
const endPos = yLabelWidth + 2 + plotWidth - endLabel.length;
|
|
395
|
+
axis = axis.padEnd(Math.max(endPos, axis.length + 1)) + endLabel;
|
|
396
|
+
lines.push(colorize(-1, axis));
|
|
397
|
+
|
|
398
|
+
return lines;
|
|
399
|
+
}
|