@nredd/pi-session-kit 0.4.0 → 0.6.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 +30 -15
- package/package.json +1 -1
- package/src/monitor/coalescer.ts +16 -2
- package/src/monitor/index.ts +5 -5
- package/src/monitor/manager.ts +15 -15
- package/src/monitor/messages.ts +29 -52
- package/src/monitor/output.ts +3 -1
- package/src/monitor/ring.ts +5 -0
- package/src/monitor/types.ts +3 -1
- package/src/monitor/widget.ts +18 -13
- package/src/stats/aggregate.ts +3 -0
- package/src/stats/chart.ts +3 -34
- package/src/stats/component.ts +290 -138
- package/src/stats/days.ts +7 -0
- package/src/stats/heatmap.ts +9 -2
- package/src/stats/ticks.ts +241 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X-axis ticks for the `/stats` time-series charts, one rule for every timescale.
|
|
3
|
+
*
|
|
4
|
+
* A chart has `n` buckets stretched over `width` plot columns, bucket `k` on column
|
|
5
|
+
* `round(k * width / (n - 1))` (see `stretch`). A tick is a bucket. Candidate tick sets run from fine
|
|
6
|
+
* to coarse (every hour, every 2 hours, ... / every day, every Monday, every 14 days, ... / every
|
|
7
|
+
* month, every quarter, ...), each with a wide and a narrow label format. The first set whose labels
|
|
8
|
+
* all fit, with air between them and at most `MAX_TICKS` of them, wins. So ticks are evenly spaced
|
|
9
|
+
* (clock- or calendar-aligned), never overlap, and are never cut: a label that cannot fit is not drawn.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { dayKey, monthName, shortDate, weekdayName } from "./days.ts";
|
|
13
|
+
|
|
14
|
+
/** What one bucket spans. `size` is days per bucket for the daily scale. */
|
|
15
|
+
export type Timescale = { kind: "hour" } | { kind: "day"; size: number };
|
|
16
|
+
|
|
17
|
+
export interface Tick {
|
|
18
|
+
/** Bucket index. */
|
|
19
|
+
bucket: number;
|
|
20
|
+
/** Plot column the bucket sits on. */
|
|
21
|
+
column: number;
|
|
22
|
+
label: string;
|
|
23
|
+
/** Column the label's first character is drawn in. */
|
|
24
|
+
start: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TimeAxis {
|
|
28
|
+
ticks: Tick[];
|
|
29
|
+
/** Baseline with a mark per tick, labels, and for hourly charts a row of dates. */
|
|
30
|
+
lines: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** More labels than this stop reading as an axis. */
|
|
34
|
+
export const MAX_TICKS = 13;
|
|
35
|
+
/** Hours between ticks, finest first; all divide a day so ticks land on the clock. */
|
|
36
|
+
const HOUR_STRIDES = [1, 2, 3, 4, 6, 12, 24] as const;
|
|
37
|
+
/** Days between ticks for the daily scale, finest first. */
|
|
38
|
+
const DAY_STRIDES = [1, 2, 3, 7, 14] as const;
|
|
39
|
+
/** Months between ticks, finest first. */
|
|
40
|
+
const MONTH_STRIDES = [1, 2, 3, 6, 12] as const;
|
|
41
|
+
/** Weekday names label every bucket up to this many days. */
|
|
42
|
+
const WEEKDAY_MAX = 8;
|
|
43
|
+
/** Blank columns required between two labels, for the wide and the narrow variant. */
|
|
44
|
+
const GAP = [3, 2] as const;
|
|
45
|
+
|
|
46
|
+
interface Mark {
|
|
47
|
+
bucket: number;
|
|
48
|
+
/** Wide to narrow. */
|
|
49
|
+
labels: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const pad2 = (n: number): string => (n < 10 ? `0${n}` : String(n));
|
|
53
|
+
|
|
54
|
+
/** `09:00`, or `05:30` where the zone's hours start at :30. */
|
|
55
|
+
export function hourWide(ms: number): string {
|
|
56
|
+
const d = new Date(ms);
|
|
57
|
+
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `9a`, `12p`: the short 12-hour form. Half-hour zones keep `05:30`, already as short as it gets. */
|
|
61
|
+
export function hourNarrow(ms: number): string {
|
|
62
|
+
const d = new Date(ms);
|
|
63
|
+
if (d.getMinutes()) return hourWide(ms);
|
|
64
|
+
return `${d.getHours() % 12 || 12}${d.getHours() < 12 ? "a" : "p"}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Bucket column on a `width`-column plot, the last bucket on the last column. */
|
|
68
|
+
export function bucketColumn(bucket: number, count: number, width: number): number {
|
|
69
|
+
if (count <= 1 || width <= 1) return 0;
|
|
70
|
+
return Math.min(width - 1, Math.round((bucket * width) / (count - 1)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every `stride`-th bucket from the first one on the clock. Stepping by bucket, not by local hour,
|
|
75
|
+
* keeps ticks evenly spaced across a DST change; labels after it shift by the hour gained or lost.
|
|
76
|
+
*/
|
|
77
|
+
function hourLevels(starts: readonly number[]): Mark[][] {
|
|
78
|
+
return HOUR_STRIDES.map((stride) => {
|
|
79
|
+
const marks: Mark[] = [];
|
|
80
|
+
const anchor = starts.findIndex((ms) => new Date(ms).getHours() % stride === 0);
|
|
81
|
+
for (let bucket = anchor; anchor >= 0 && bucket < starts.length; bucket += stride) {
|
|
82
|
+
const ms = starts[bucket] ?? 0;
|
|
83
|
+
marks.push({ bucket, labels: [hourWide(ms), hourNarrow(ms)] });
|
|
84
|
+
}
|
|
85
|
+
return marks;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Buckets `stride` apart counted back from the newest, so today is always labelled. */
|
|
90
|
+
function fromNewest(count: number, stride: number): number[] {
|
|
91
|
+
const out: number[] = [];
|
|
92
|
+
for (let k = count - 1; k >= 0; k -= stride) out.unshift(k);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function dayLevels(starts: readonly number[], size: number): Mark[][] {
|
|
97
|
+
const n = starts.length;
|
|
98
|
+
const levels: Mark[][] = [];
|
|
99
|
+
if (size === 1 && n <= WEEKDAY_MAX) {
|
|
100
|
+
levels.push(
|
|
101
|
+
starts.map((ms, bucket) => ({
|
|
102
|
+
bucket,
|
|
103
|
+
labels: [`${weekdayName(new Date(ms).getDay())} ${new Date(ms).getDate()}`],
|
|
104
|
+
})),
|
|
105
|
+
);
|
|
106
|
+
levels.push(starts.map((ms, bucket) => ({ bucket, labels: [weekdayName(new Date(ms).getDay())] })));
|
|
107
|
+
}
|
|
108
|
+
for (const days of DAY_STRIDES) {
|
|
109
|
+
if (days % size !== 0) continue;
|
|
110
|
+
const stride = days / size;
|
|
111
|
+
let buckets = fromNewest(n, stride);
|
|
112
|
+
if (size === 1 && days % 7 === 0) {
|
|
113
|
+
// Whole weeks: every `days`-th Monday, newest first.
|
|
114
|
+
const newest = starts.map((ms) => new Date(ms).getDay()).lastIndexOf(1);
|
|
115
|
+
buckets = newest < 0 ? [] : fromNewest(newest + 1, stride);
|
|
116
|
+
}
|
|
117
|
+
levels.push(buckets.map((bucket) => ({ bucket, labels: [shortDate(dayKey(starts[bucket] ?? 0))] })));
|
|
118
|
+
}
|
|
119
|
+
// A bucket carries the month whose first day it contains; the first label of a year carries it.
|
|
120
|
+
// A bucket ends where the next starts (the first is often partial, DST days are 23 or 25 hours);
|
|
121
|
+
// the last spans `size` calendar days.
|
|
122
|
+
const firsts: { bucket: number; month: number; year: number }[] = [];
|
|
123
|
+
starts.forEach((ms, bucket) => {
|
|
124
|
+
const d = new Date(ms);
|
|
125
|
+
const end = starts[bucket + 1] ?? new Date(d.getFullYear(), d.getMonth(), d.getDate() + size).getTime();
|
|
126
|
+
const first = d.getDate() === 1 ? d : new Date(d.getFullYear(), d.getMonth() + 1, 1);
|
|
127
|
+
if (first.getTime() < end) firsts.push({ bucket, month: first.getMonth(), year: first.getFullYear() });
|
|
128
|
+
});
|
|
129
|
+
for (const stride of MONTH_STRIDES) {
|
|
130
|
+
const picked = firsts.filter((f) => f.month % stride === 0);
|
|
131
|
+
// Years only when the ticks span more than one, on the first tick and every January; then no
|
|
132
|
+
// variant drops them, so `Jan`, `Jan` never repeats unlabelled.
|
|
133
|
+
const years = new Set(picked.map((f) => f.year)).size > 1;
|
|
134
|
+
levels.push(
|
|
135
|
+
picked.map((f, i) => {
|
|
136
|
+
const plain = monthName(f.month);
|
|
137
|
+
const tagged = years && (i === 0 || f.month === 0) ? `${plain} '${pad2(f.year % 100)}` : plain;
|
|
138
|
+
return { bucket: f.bucket, labels: years ? [tagged] : [plain] };
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return levels;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Label start columns when `marks` use label variant `variant`, or `undefined` if any collide or overflow. */
|
|
146
|
+
function place(marks: readonly Mark[], variant: number, count: number, width: number): Tick[] | undefined {
|
|
147
|
+
const gap = GAP[Math.min(variant, GAP.length - 1)] ?? 2;
|
|
148
|
+
const ticks: Tick[] = [];
|
|
149
|
+
let end = -gap;
|
|
150
|
+
for (const m of marks) {
|
|
151
|
+
const label = m.labels[Math.min(variant, m.labels.length - 1)] ?? "";
|
|
152
|
+
const column = bucketColumn(m.bucket, count, width);
|
|
153
|
+
if (label.length > width) return undefined;
|
|
154
|
+
const start = Math.max(0, Math.min(width - label.length, column - Math.floor(label.length / 2)));
|
|
155
|
+
if (start < end + gap) return undefined;
|
|
156
|
+
ticks.push({ bucket: m.bucket, column, label, start });
|
|
157
|
+
end = start + label.length;
|
|
158
|
+
}
|
|
159
|
+
return ticks;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Pick the ticks for buckets starting at `starts` (local instants) on a `width`-column plot. Falls
|
|
164
|
+
* back to the first and last bucket, then the last alone, when no regular set fits.
|
|
165
|
+
*/
|
|
166
|
+
export function chooseTicks(starts: readonly number[], scale: Timescale, width: number): Tick[] {
|
|
167
|
+
const n = starts.length;
|
|
168
|
+
if (n === 0 || width <= 0) return [];
|
|
169
|
+
const levels = scale.kind === "hour" ? hourLevels(starts) : dayLevels(starts, scale.size);
|
|
170
|
+
const endLabel = (b: number): Mark => ({
|
|
171
|
+
bucket: b,
|
|
172
|
+
labels: [scale.kind === "hour" ? hourWide(starts[b] ?? 0) : shortDate(dayKey(starts[b] ?? 0))],
|
|
173
|
+
});
|
|
174
|
+
const ends = [[endLabel(0), endLabel(n - 1)], [endLabel(n - 1)]];
|
|
175
|
+
for (const [i, marks] of [...levels, ...ends].entries()) {
|
|
176
|
+
const regular = i < levels.length;
|
|
177
|
+
if (marks.length === 0 || (regular && (marks.length < 2 || marks.length > MAX_TICKS))) continue;
|
|
178
|
+
if (n === 1 && marks.length > 1) continue;
|
|
179
|
+
for (let variant = 0; variant < 2; variant++) {
|
|
180
|
+
const ticks = place(marks, variant, n, width);
|
|
181
|
+
if (ticks) return ticks;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A row of `text` pieces at their start columns; callers pass non-overlapping pieces. */
|
|
188
|
+
function row(width: number, pieces: readonly { start: number; text: string }[]): string {
|
|
189
|
+
const cells = new Array<string>(width).fill(" ");
|
|
190
|
+
for (const p of pieces)
|
|
191
|
+
for (let i = 0; i < p.text.length && p.start + i < width; i++) cells[p.start + i] = p.text[i] ?? " ";
|
|
192
|
+
return cells.join("").trimEnd();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Dates under an hourly axis: one at every bucket that starts a new local day and, room allowing, one
|
|
197
|
+
* under the first bucket, so a 24h chart reads `Oct 12 ... Oct 13` across its midnight.
|
|
198
|
+
*/
|
|
199
|
+
function dateRow(starts: readonly number[], width: number): string {
|
|
200
|
+
const n = starts.length;
|
|
201
|
+
const pieces: { start: number; text: string }[] = [];
|
|
202
|
+
const free = (start: number, len: number) =>
|
|
203
|
+
pieces.every((p) => start >= p.start + p.text.length + 2 || start + len + 2 <= p.start);
|
|
204
|
+
const add = (bucket: number) => {
|
|
205
|
+
const text = shortDate(dayKey(starts[bucket] ?? 0));
|
|
206
|
+
const column = bucketColumn(bucket, n, width);
|
|
207
|
+
const start = Math.max(0, Math.min(width - text.length, column));
|
|
208
|
+
if (text.length <= width && free(start, text.length)) pieces.push({ start, text });
|
|
209
|
+
};
|
|
210
|
+
// Every bucket on a new local day: midnight, or 00:30 in half-hour zones, wherever DST puts it.
|
|
211
|
+
starts.forEach((ms, bucket) => {
|
|
212
|
+
if (bucket > 0 && dayKey(ms) !== dayKey(starts[bucket - 1] ?? ms)) add(bucket);
|
|
213
|
+
});
|
|
214
|
+
add(0);
|
|
215
|
+
return row(width, pieces);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The x-axis under a plot: a baseline (`└──┬──┬──`), the tick labels, and for hourly charts the dates.
|
|
220
|
+
* `gutter` is the plot's left margin (`GUTTER`); lines are plain text, painted by the caller.
|
|
221
|
+
*/
|
|
222
|
+
export function timeAxis(starts: readonly number[], scale: Timescale, width: number, gutter: number): TimeAxis {
|
|
223
|
+
const ticks = chooseTicks(starts, scale, width);
|
|
224
|
+
const base = new Array<string>(width).fill("─");
|
|
225
|
+
for (const t of ticks) base[t.column] = "┬";
|
|
226
|
+
const margin = " ".repeat(gutter);
|
|
227
|
+
const lines = [`${" ".repeat(Math.max(0, gutter - 1))}└${base.join("")}`];
|
|
228
|
+
if (ticks.length > 0)
|
|
229
|
+
lines.push(
|
|
230
|
+
margin +
|
|
231
|
+
row(
|
|
232
|
+
width,
|
|
233
|
+
ticks.map((t) => ({ start: t.start, text: t.label })),
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
if (scale.kind === "hour") {
|
|
237
|
+
const dates = dateRow(starts, width);
|
|
238
|
+
if (dates) lines.push(margin + dates);
|
|
239
|
+
}
|
|
240
|
+
return { ticks, lines };
|
|
241
|
+
}
|