bamtigraph 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/LICENSE +28 -0
- package/README.md +97 -0
- package/dist/bamtigraph.global.js +3112 -0
- package/dist/index.cjs +3161 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.d.cts +486 -0
- package/dist/index.d.ts +486 -0
- package/dist/index.js +3141 -0
- package/dist/index.js.map +7 -0
- package/package.json +76 -0
- package/src/index.ts +4069 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,4069 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
* Copyright (c) 2026, GoSuda
|
|
4
|
+
* BamtiGraph TypeScript implementation. See the root LICENSE.
|
|
5
|
+
*/
|
|
6
|
+
/** Unix seconds, an explicit Date, or an ISO datetime with a timezone offset. */
|
|
7
|
+
export type Timestamp = number | Date | string;
|
|
8
|
+
/** RGB or RGBA channels, hexadecimal notation, or a supported named color. */
|
|
9
|
+
export type Color =
|
|
10
|
+
| string
|
|
11
|
+
| readonly [number, number, number]
|
|
12
|
+
| readonly [number, number, number, number];
|
|
13
|
+
export type Values = ArrayLike<number | null | undefined>;
|
|
14
|
+
/** Normalized red, green, blue, and alpha bytes. */
|
|
15
|
+
export type RGBA = [number, number, number, number];
|
|
16
|
+
export type Interpolation = "linear" | "step-post";
|
|
17
|
+
/** Mutable preprocessing output, with NaN marking missing samples. */
|
|
18
|
+
export interface Samples {
|
|
19
|
+
timestamps: number[];
|
|
20
|
+
values: number[];
|
|
21
|
+
}
|
|
22
|
+
export interface LegendValues {
|
|
23
|
+
current: number | null;
|
|
24
|
+
average: number | null;
|
|
25
|
+
maximum: number | null;
|
|
26
|
+
}
|
|
27
|
+
export interface SeriesOptions {
|
|
28
|
+
kind?: "line" | "area";
|
|
29
|
+
color?: Color;
|
|
30
|
+
outline?: Color | null;
|
|
31
|
+
lineWidth?: number;
|
|
32
|
+
baseline?: number;
|
|
33
|
+
interpolation?: Interpolation;
|
|
34
|
+
gapAfter?: number;
|
|
35
|
+
legendValues?: Partial<LegendValues> | null;
|
|
36
|
+
}
|
|
37
|
+
export interface SeriesInput extends SeriesOptions {
|
|
38
|
+
name: string;
|
|
39
|
+
timestamps: ArrayLike<Timestamp>;
|
|
40
|
+
values: Values;
|
|
41
|
+
}
|
|
42
|
+
export interface Series extends Readonly<
|
|
43
|
+
Required<Omit<SeriesOptions, "legendValues" | "color" | "outline">>
|
|
44
|
+
> {
|
|
45
|
+
readonly color: Readonly<RGBA>;
|
|
46
|
+
readonly outline: Readonly<RGBA> | null;
|
|
47
|
+
readonly name: string;
|
|
48
|
+
readonly timestamps: readonly number[];
|
|
49
|
+
readonly values: readonly number[];
|
|
50
|
+
readonly legendValues: Readonly<LegendValues> | null;
|
|
51
|
+
}
|
|
52
|
+
export interface Tick {
|
|
53
|
+
time: Timestamp;
|
|
54
|
+
label: string;
|
|
55
|
+
}
|
|
56
|
+
export interface TimeAxis {
|
|
57
|
+
start?: Timestamp | null;
|
|
58
|
+
end?: Timestamp | null;
|
|
59
|
+
mode?: "auto" | "daily" | "weekly" | "monthly" | "yearly" | "custom";
|
|
60
|
+
timezone?: string;
|
|
61
|
+
minorSeconds?: number | null;
|
|
62
|
+
majorSeconds?: number | null;
|
|
63
|
+
labelSeconds?: number | null;
|
|
64
|
+
labelFormat?: string | null;
|
|
65
|
+
labelOffsetSeconds?: number;
|
|
66
|
+
ticks?: readonly Tick[] | null;
|
|
67
|
+
minorTicks?: readonly Timestamp[] | null;
|
|
68
|
+
majorTicks?: readonly Timestamp[] | null;
|
|
69
|
+
}
|
|
70
|
+
export interface YAxis {
|
|
71
|
+
minimum?: number | null;
|
|
72
|
+
maximum?: number | null;
|
|
73
|
+
majorStep?: number | null;
|
|
74
|
+
minorDivisions?: number;
|
|
75
|
+
base?: 1000 | 1024;
|
|
76
|
+
scaleFactor?: number | null;
|
|
77
|
+
suffix?: string | null;
|
|
78
|
+
decimals?: number | null;
|
|
79
|
+
legendDecimals?: number;
|
|
80
|
+
showZeroSuffix?: boolean;
|
|
81
|
+
}
|
|
82
|
+
export type ColumnAnchors = readonly [
|
|
83
|
+
readonly [number, number],
|
|
84
|
+
readonly [number, number],
|
|
85
|
+
readonly [number, number],
|
|
86
|
+
];
|
|
87
|
+
export interface LegendLayout {
|
|
88
|
+
nameX?: number;
|
|
89
|
+
swatchX?: number;
|
|
90
|
+
swatchWidth?: number;
|
|
91
|
+
swatchHeight?: number;
|
|
92
|
+
referenceWidth?: number;
|
|
93
|
+
autoScaleColumns?: boolean;
|
|
94
|
+
compact?: ColumnAnchors;
|
|
95
|
+
expanded?: ColumnAnchors;
|
|
96
|
+
aligned?: ColumnAnchors;
|
|
97
|
+
}
|
|
98
|
+
export interface Layout {
|
|
99
|
+
width?: number;
|
|
100
|
+
plotHeight?: number;
|
|
101
|
+
left?: number;
|
|
102
|
+
right?: number;
|
|
103
|
+
top?: number;
|
|
104
|
+
titleY?: number;
|
|
105
|
+
titleOffsetX?: number;
|
|
106
|
+
unitX?: number;
|
|
107
|
+
xLabelGap?: number;
|
|
108
|
+
yLabelGap?: number;
|
|
109
|
+
legendGap?: number;
|
|
110
|
+
legendRowHeight?: number;
|
|
111
|
+
legendBottom?: number;
|
|
112
|
+
legend?: "reference" | "aligned" | "none";
|
|
113
|
+
legendLayout?: LegendLayout;
|
|
114
|
+
antialias?: number;
|
|
115
|
+
pixelScale?: number;
|
|
116
|
+
}
|
|
117
|
+
export interface Theme {
|
|
118
|
+
background?: Color;
|
|
119
|
+
canvas?: Color;
|
|
120
|
+
shadeLight?: Color;
|
|
121
|
+
shadeDark?: Color;
|
|
122
|
+
text?: Color;
|
|
123
|
+
minorGrid?: Color;
|
|
124
|
+
majorGrid?: Color;
|
|
125
|
+
axis?: Color;
|
|
126
|
+
arrow?: Color;
|
|
127
|
+
watermark?: Color;
|
|
128
|
+
frame?: Color;
|
|
129
|
+
gridFront?: boolean;
|
|
130
|
+
gridDash?: readonly [number, number];
|
|
131
|
+
titleSize?: number;
|
|
132
|
+
axisSize?: number;
|
|
133
|
+
unitSize?: number;
|
|
134
|
+
legendSize?: number;
|
|
135
|
+
watermarkSize?: number;
|
|
136
|
+
captionSize?: number;
|
|
137
|
+
titleAdvance?: number;
|
|
138
|
+
axisAdvance?: number;
|
|
139
|
+
legendAdvance?: number;
|
|
140
|
+
}
|
|
141
|
+
export interface FontOptions {
|
|
142
|
+
mode?: "system" | "bitmap";
|
|
143
|
+
family?: string;
|
|
144
|
+
titleFamily?: string | null;
|
|
145
|
+
unitFamily?: string | null;
|
|
146
|
+
captionFamily?: string | null;
|
|
147
|
+
strictGlyphs?: boolean;
|
|
148
|
+
}
|
|
149
|
+
export interface RuleStyle {
|
|
150
|
+
color?: Color;
|
|
151
|
+
width?: number;
|
|
152
|
+
dash?: readonly [number, number] | null;
|
|
153
|
+
}
|
|
154
|
+
export interface HRule extends RuleStyle {
|
|
155
|
+
value: number;
|
|
156
|
+
}
|
|
157
|
+
export interface VRule extends RuleStyle {
|
|
158
|
+
time: Timestamp;
|
|
159
|
+
}
|
|
160
|
+
/** Immutable chart input. Nested objects merge; arrays replace their defaults. */
|
|
161
|
+
export interface ChartOptions {
|
|
162
|
+
series?: readonly SeriesInput[];
|
|
163
|
+
title?: string;
|
|
164
|
+
verticalLabel?: string;
|
|
165
|
+
watermark?: string;
|
|
166
|
+
timeAxis?: TimeAxis;
|
|
167
|
+
yAxis?: YAxis;
|
|
168
|
+
layout?: Layout;
|
|
169
|
+
theme?: Theme;
|
|
170
|
+
fonts?: FontOptions;
|
|
171
|
+
legendLabels?: readonly [string, string, string];
|
|
172
|
+
missingLabel?: string;
|
|
173
|
+
hRules?: readonly HRule[];
|
|
174
|
+
vRules?: readonly VRule[];
|
|
175
|
+
}
|
|
176
|
+
export interface TrafficOptions extends ChartOptions {
|
|
177
|
+
gapAfter?: number;
|
|
178
|
+
}
|
|
179
|
+
export interface Unit {
|
|
180
|
+
factor: number;
|
|
181
|
+
suffix: string;
|
|
182
|
+
}
|
|
183
|
+
export interface Statistics {
|
|
184
|
+
name: string;
|
|
185
|
+
current: number | null;
|
|
186
|
+
average: number | null;
|
|
187
|
+
maximum: number | null;
|
|
188
|
+
minimum: number | null;
|
|
189
|
+
count: number;
|
|
190
|
+
missing: number;
|
|
191
|
+
displayOverride: LegendValues | null;
|
|
192
|
+
}
|
|
193
|
+
/** Serializable chart manifest. Missing statistics are null, not NaN. */
|
|
194
|
+
export interface GraphMetadata {
|
|
195
|
+
version: string;
|
|
196
|
+
imageSize: [number, number];
|
|
197
|
+
logicalSize: [number, number];
|
|
198
|
+
plotBox: [number, number, number, number];
|
|
199
|
+
pixelScale: number;
|
|
200
|
+
title: string;
|
|
201
|
+
verticalLabel: string;
|
|
202
|
+
watermark: string;
|
|
203
|
+
timeRange: [number, number];
|
|
204
|
+
timezone: string;
|
|
205
|
+
timeMode: string;
|
|
206
|
+
yRange: [number, number];
|
|
207
|
+
yStep: number;
|
|
208
|
+
yUnit: Unit;
|
|
209
|
+
xLabels: { time: number; label: string; x: number }[];
|
|
210
|
+
statistics: Statistics[];
|
|
211
|
+
statisticsPolicy: string;
|
|
212
|
+
font: {
|
|
213
|
+
mode: "system" | "bitmap";
|
|
214
|
+
family: string | null;
|
|
215
|
+
pixelAlphabet: string | null;
|
|
216
|
+
};
|
|
217
|
+
layout: Layout;
|
|
218
|
+
theme: Theme;
|
|
219
|
+
warnings: string[];
|
|
220
|
+
}
|
|
221
|
+
/** Row-major straight-alpha bytes. Pixel storage intentionally remains mutable. */
|
|
222
|
+
export interface RGBAImage {
|
|
223
|
+
width: number;
|
|
224
|
+
height: number;
|
|
225
|
+
data: Uint8ClampedArray | Uint8Array;
|
|
226
|
+
}
|
|
227
|
+
export interface PNGOptions {
|
|
228
|
+
metadata?: boolean;
|
|
229
|
+
}
|
|
230
|
+
export interface NearestSample {
|
|
231
|
+
name: string;
|
|
232
|
+
index: number | null;
|
|
233
|
+
time: number | null;
|
|
234
|
+
value: number | null;
|
|
235
|
+
}
|
|
236
|
+
export interface MountOptions {
|
|
237
|
+
interactive?: boolean;
|
|
238
|
+
ariaLabel?: string;
|
|
239
|
+
onHover?: (event: { time: number; samples: NearestSample[] }) => void;
|
|
240
|
+
}
|
|
241
|
+
/** Mounted browser lifecycle. Destroy restores the canvas DOM position and attributes. */
|
|
242
|
+
export interface Controller {
|
|
243
|
+
readonly canvas: HTMLCanvasElement;
|
|
244
|
+
readonly chart: Chart;
|
|
245
|
+
readonly result: RenderResult;
|
|
246
|
+
readonly destroyed: boolean;
|
|
247
|
+
update(patch: ChartOptions | Chart): RenderResult;
|
|
248
|
+
destroy(): void;
|
|
249
|
+
}
|
|
250
|
+
export interface DashboardOptions {
|
|
251
|
+
gap?: number;
|
|
252
|
+
padding?: readonly [number, number, number, number];
|
|
253
|
+
background?: Color;
|
|
254
|
+
cropHeight?: number | null;
|
|
255
|
+
}
|
|
256
|
+
export interface DashboardPanel {
|
|
257
|
+
chart: Chart;
|
|
258
|
+
caption?: string;
|
|
259
|
+
}
|
|
260
|
+
export interface DashboardMetadata {
|
|
261
|
+
version: string;
|
|
262
|
+
imageSize: [number, number];
|
|
263
|
+
pixelScale: number;
|
|
264
|
+
panels: {
|
|
265
|
+
position: [number, number];
|
|
266
|
+
caption: string;
|
|
267
|
+
chart: GraphMetadata;
|
|
268
|
+
}[];
|
|
269
|
+
}
|
|
270
|
+
export interface CounterOptions {
|
|
271
|
+
factor?: number;
|
|
272
|
+
onDecrease?: "gap" | "wrap";
|
|
273
|
+
counterBits?: number | null;
|
|
274
|
+
maxRate?: number | null;
|
|
275
|
+
}
|
|
276
|
+
export interface AggregateOptions {
|
|
277
|
+
interval?: number;
|
|
278
|
+
method?: "mean" | "min" | "max" | "last" | "sum";
|
|
279
|
+
origin?: number;
|
|
280
|
+
minCoverage?: number;
|
|
281
|
+
expectedStep?: number | null;
|
|
282
|
+
maxBuckets?: number;
|
|
283
|
+
}
|
|
284
|
+
export interface CSVColumn extends SeriesOptions {
|
|
285
|
+
column: string;
|
|
286
|
+
name?: string;
|
|
287
|
+
}
|
|
288
|
+
export interface CSVOptions {
|
|
289
|
+
timestampColumn?: string;
|
|
290
|
+
columns?: readonly CSVColumn[];
|
|
291
|
+
maxRows?: number;
|
|
292
|
+
maxBytes?: number;
|
|
293
|
+
}
|
|
294
|
+
export interface PixelDifference {
|
|
295
|
+
pixels: number;
|
|
296
|
+
exactPixels: number;
|
|
297
|
+
exactRatio: number;
|
|
298
|
+
toleranceRatio: number;
|
|
299
|
+
meanAbsoluteError: number;
|
|
300
|
+
rootMeanSquareError: number;
|
|
301
|
+
maxError: number;
|
|
302
|
+
differenceBox: [number, number, number, number] | null;
|
|
303
|
+
}
|
|
304
|
+
export interface CompareOptions {
|
|
305
|
+
tolerance?: number;
|
|
306
|
+
box?: readonly [number, number, number, number] | null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** All chart fields after default merging and normalization. */
|
|
310
|
+
export interface ResolvedChartOptions extends Omit<
|
|
311
|
+
Required<ChartOptions>,
|
|
312
|
+
| "series"
|
|
313
|
+
| "timeAxis"
|
|
314
|
+
| "yAxis"
|
|
315
|
+
| "layout"
|
|
316
|
+
| "theme"
|
|
317
|
+
| "fonts"
|
|
318
|
+
| "hRules"
|
|
319
|
+
| "vRules"
|
|
320
|
+
> {
|
|
321
|
+
series: readonly Series[];
|
|
322
|
+
timeAxis: ResolvedTimeAxis;
|
|
323
|
+
yAxis: Required<YAxis>;
|
|
324
|
+
layout: ResolvedLayout;
|
|
325
|
+
theme: ResolvedTheme;
|
|
326
|
+
fonts: Required<FontOptions>;
|
|
327
|
+
hRules: readonly ResolvedHRule[];
|
|
328
|
+
vRules: readonly ResolvedVRule[];
|
|
329
|
+
}
|
|
330
|
+
/** A calendar axis whose timestamp inputs have been converted to Unix seconds. */
|
|
331
|
+
export interface ResolvedTimeAxis extends Omit<
|
|
332
|
+
Required<TimeAxis>,
|
|
333
|
+
"start" | "end" | "ticks" | "minorTicks" | "majorTicks"
|
|
334
|
+
> {
|
|
335
|
+
start: number | null;
|
|
336
|
+
end: number | null;
|
|
337
|
+
ticks: readonly ResolvedTick[] | null;
|
|
338
|
+
minorTicks: readonly number[] | null;
|
|
339
|
+
majorTicks: readonly number[] | null;
|
|
340
|
+
}
|
|
341
|
+
/** A label positioned at a numerical Unix timestamp. */
|
|
342
|
+
export interface ResolvedTick {
|
|
343
|
+
time: number;
|
|
344
|
+
label: string;
|
|
345
|
+
}
|
|
346
|
+
/** Complete logical-pixel layout including the legend geometry. */
|
|
347
|
+
export interface ResolvedLayout extends Required<Omit<Layout, "legendLayout">> {
|
|
348
|
+
legendLayout: Required<LegendLayout>;
|
|
349
|
+
}
|
|
350
|
+
type ThemeColorKey =
|
|
351
|
+
| "background"
|
|
352
|
+
| "canvas"
|
|
353
|
+
| "shadeLight"
|
|
354
|
+
| "shadeDark"
|
|
355
|
+
| "text"
|
|
356
|
+
| "minorGrid"
|
|
357
|
+
| "majorGrid"
|
|
358
|
+
| "axis"
|
|
359
|
+
| "arrow"
|
|
360
|
+
| "watermark"
|
|
361
|
+
| "frame";
|
|
362
|
+
/** Fully specified theme with parsed RGBA colors. */
|
|
363
|
+
export type ResolvedTheme = Required<Omit<Theme, ThemeColorKey>> & {
|
|
364
|
+
[K in ThemeColorKey]: Readonly<RGBA>;
|
|
365
|
+
};
|
|
366
|
+
/** Normalized horizontal rule in original data units. */
|
|
367
|
+
export interface ResolvedHRule {
|
|
368
|
+
value: number;
|
|
369
|
+
color: Readonly<RGBA>;
|
|
370
|
+
width: number;
|
|
371
|
+
dash: readonly [number, number] | null;
|
|
372
|
+
}
|
|
373
|
+
/** Normalized vertical rule in Unix seconds. */
|
|
374
|
+
export interface ResolvedVRule {
|
|
375
|
+
time: number;
|
|
376
|
+
color: Readonly<RGBA>;
|
|
377
|
+
width: number;
|
|
378
|
+
dash: readonly [number, number] | null;
|
|
379
|
+
}
|
|
380
|
+
/** Recursive readonly view of plain chart configuration and metadata. */
|
|
381
|
+
export type DeepReadonly<T> = T extends object
|
|
382
|
+
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
|
|
383
|
+
: T;
|
|
384
|
+
type CompleteChartInput = Omit<
|
|
385
|
+
Required<ChartOptions>,
|
|
386
|
+
"timeAxis" | "yAxis" | "layout" | "theme" | "fonts"
|
|
387
|
+
> & {
|
|
388
|
+
timeAxis: Required<TimeAxis>;
|
|
389
|
+
yAxis: Required<YAxis>;
|
|
390
|
+
layout: ResolvedLayout;
|
|
391
|
+
theme: Required<Theme>;
|
|
392
|
+
fonts: Required<FontOptions>;
|
|
393
|
+
};
|
|
394
|
+
type CompleteSeriesInput = Required<Omit<SeriesInput, "legendValues">> & {
|
|
395
|
+
legendValues: Partial<LegendValues> | null;
|
|
396
|
+
};
|
|
397
|
+
type ChartConfig = DeepReadonly<ResolvedChartOptions>;
|
|
398
|
+
type Point = [number, number];
|
|
399
|
+
type Canvas = HTMLCanvasElement | OffscreenCanvas;
|
|
400
|
+
type Context2D = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
|
401
|
+
type FontRole = "title" | "axis" | "unit" | "legend" | "watermark" | "caption";
|
|
402
|
+
interface WallParts {
|
|
403
|
+
year: number;
|
|
404
|
+
month: number;
|
|
405
|
+
day: number;
|
|
406
|
+
hour: number;
|
|
407
|
+
minute: number;
|
|
408
|
+
second: number;
|
|
409
|
+
}
|
|
410
|
+
interface YResolution extends Unit {
|
|
411
|
+
minimum: number;
|
|
412
|
+
maximum: number;
|
|
413
|
+
step: number;
|
|
414
|
+
major: number[];
|
|
415
|
+
minor: number[];
|
|
416
|
+
decimals: number;
|
|
417
|
+
}
|
|
418
|
+
interface XResolution {
|
|
419
|
+
minor: number[];
|
|
420
|
+
major: number[];
|
|
421
|
+
labels: ResolvedTick[];
|
|
422
|
+
mode: NonNullable<TimeAxis["mode"]>;
|
|
423
|
+
}
|
|
424
|
+
type MountedCanvas = HTMLCanvasElement & {
|
|
425
|
+
__bamtiGraphController?: BrowserController;
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
/** BamtiGraph library version. */
|
|
429
|
+
export const VERSION: string = "0.1.0";
|
|
430
|
+
/** Per-operation allocation and enumeration guards. */
|
|
431
|
+
export const LIMITS: Readonly<{
|
|
432
|
+
pixels: number;
|
|
433
|
+
layerPixels: number;
|
|
434
|
+
ticks: number;
|
|
435
|
+
series: number;
|
|
436
|
+
samples: number;
|
|
437
|
+
text: number;
|
|
438
|
+
}> = Object.freeze({
|
|
439
|
+
pixels: 16000000,
|
|
440
|
+
layerPixels: 40000000,
|
|
441
|
+
ticks: 5000,
|
|
442
|
+
series: 128,
|
|
443
|
+
samples: 2000000,
|
|
444
|
+
text: 4096,
|
|
445
|
+
});
|
|
446
|
+
const finite = (v: unknown): v is number => Number.isFinite(v);
|
|
447
|
+
const round = (v: number): number => Math.floor(v + 0.5);
|
|
448
|
+
const clamp = (v: number, a: number, b: number): number =>
|
|
449
|
+
Math.max(a, Math.min(b, v));
|
|
450
|
+
const own = (o: object, k: PropertyKey): boolean =>
|
|
451
|
+
Object.prototype.hasOwnProperty.call(o, k);
|
|
452
|
+
const missing = (v: unknown): boolean =>
|
|
453
|
+
v === null || v === undefined || (typeof v === "number" && Number.isNaN(v));
|
|
454
|
+
function check(ok: unknown, message: string): asserts ok {
|
|
455
|
+
if (!ok) throw new RangeError(message);
|
|
456
|
+
}
|
|
457
|
+
function text(s: unknown, name: string): string {
|
|
458
|
+
check(
|
|
459
|
+
typeof s === "string" && s.length <= LIMITS.text && !/[\r\n\u0000]/.test(s),
|
|
460
|
+
name + " must be a single-line string.",
|
|
461
|
+
);
|
|
462
|
+
return s;
|
|
463
|
+
}
|
|
464
|
+
function number(
|
|
465
|
+
v: unknown,
|
|
466
|
+
name: string,
|
|
467
|
+
lo: number = -Infinity,
|
|
468
|
+
hi: number = Infinity,
|
|
469
|
+
): number {
|
|
470
|
+
check(
|
|
471
|
+
finite(v) && v >= lo && v <= hi,
|
|
472
|
+
name + " is outside its finite range.",
|
|
473
|
+
);
|
|
474
|
+
return v;
|
|
475
|
+
}
|
|
476
|
+
function integer(v: unknown, name: string, lo: number, hi: number): number {
|
|
477
|
+
check(Number.isInteger(v), name + " must be an integer.");
|
|
478
|
+
return number(v, name, lo, hi);
|
|
479
|
+
}
|
|
480
|
+
function record(o: unknown): o is Record<string, unknown> {
|
|
481
|
+
return (
|
|
482
|
+
o !== null &&
|
|
483
|
+
typeof o === "object" &&
|
|
484
|
+
(Object.getPrototypeOf(o) === Object.prototype ||
|
|
485
|
+
Object.getPrototypeOf(o) === null)
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
// Configuration inputs are plain data. Typed views are restored only at this
|
|
489
|
+
// recursive ownership boundary; property values remain unknown while copied.
|
|
490
|
+
function clone<T>(v: T): T {
|
|
491
|
+
if (Array.isArray(v) || ArrayBuffer.isView(v))
|
|
492
|
+
return Array.from(v as ArrayLike<unknown>, (value) => clone(value)) as T;
|
|
493
|
+
if (v instanceof Date) return new Date(v.getTime()) as T;
|
|
494
|
+
if (record(v)) {
|
|
495
|
+
const r: Record<string, unknown> = {};
|
|
496
|
+
for (const k of Object.keys(v)) {
|
|
497
|
+
check(
|
|
498
|
+
!["__proto__", "prototype", "constructor"].includes(k),
|
|
499
|
+
"Unsafe property name.",
|
|
500
|
+
);
|
|
501
|
+
r[k] = clone(v[k]);
|
|
502
|
+
}
|
|
503
|
+
return r as T;
|
|
504
|
+
}
|
|
505
|
+
return v;
|
|
506
|
+
}
|
|
507
|
+
function merge<T extends object>(a: T, b: object): T {
|
|
508
|
+
check(record(b), "Options must be a plain object.");
|
|
509
|
+
const r = clone(a) as Record<string, unknown>;
|
|
510
|
+
for (const k of Object.keys(b)) {
|
|
511
|
+
check(
|
|
512
|
+
!["__proto__", "prototype", "constructor"].includes(k),
|
|
513
|
+
"Unsafe property name.",
|
|
514
|
+
);
|
|
515
|
+
const previous = r[k],
|
|
516
|
+
next = b[k];
|
|
517
|
+
r[k] =
|
|
518
|
+
record(previous) && record(next) ? merge(previous, next) : clone(next);
|
|
519
|
+
}
|
|
520
|
+
return r as T;
|
|
521
|
+
}
|
|
522
|
+
function freeze<T>(o: T): T {
|
|
523
|
+
if (o && typeof o === "object") {
|
|
524
|
+
for (const v of Object.values(o)) freeze(v);
|
|
525
|
+
Object.freeze(o);
|
|
526
|
+
}
|
|
527
|
+
return o;
|
|
528
|
+
}
|
|
529
|
+
/** Parse a supported color into integer RGBA bytes. */
|
|
530
|
+
export function color(value: Color): RGBA {
|
|
531
|
+
if (Array.isArray(value) || ArrayBuffer.isView(value)) {
|
|
532
|
+
check(
|
|
533
|
+
value.length === 3 || value.length === 4,
|
|
534
|
+
"Color needs 3 or 4 channels.",
|
|
535
|
+
);
|
|
536
|
+
const c = Array.from(value);
|
|
537
|
+
c.forEach((v) => integer(v, "Color channel", 0, 255));
|
|
538
|
+
if (c.length === 3) c.push(255);
|
|
539
|
+
return c as RGBA;
|
|
540
|
+
}
|
|
541
|
+
const named: Record<string, string> = {
|
|
542
|
+
black: "#000000",
|
|
543
|
+
white: "#ffffff",
|
|
544
|
+
red: "#ff0000",
|
|
545
|
+
green: "#008000",
|
|
546
|
+
blue: "#0000ff",
|
|
547
|
+
transparent: "#00000000",
|
|
548
|
+
};
|
|
549
|
+
check(
|
|
550
|
+
typeof value === "string",
|
|
551
|
+
"Color must be hexadecimal or an RGB(A) array.",
|
|
552
|
+
);
|
|
553
|
+
let h = named[value.toLowerCase()] || value;
|
|
554
|
+
check(
|
|
555
|
+
/^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(h),
|
|
556
|
+
"Invalid color: " + value,
|
|
557
|
+
);
|
|
558
|
+
h = h.slice(1);
|
|
559
|
+
if (h.length <= 4) h = [...h].map((c) => c + c).join("");
|
|
560
|
+
if (h.length === 6) h += "ff";
|
|
561
|
+
return [0, 2, 4, 6].map((i) => parseInt(h.slice(i, i + 2), 16)) as RGBA;
|
|
562
|
+
}
|
|
563
|
+
/** Normalize a timestamp to Unix seconds; naive dates and invalid calendars fail. */
|
|
564
|
+
export function epoch(value: Timestamp): number {
|
|
565
|
+
if (value instanceof Date) value = value.getTime() / 1000;
|
|
566
|
+
if (typeof value === "string") {
|
|
567
|
+
const v = value.trim();
|
|
568
|
+
if (/^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(v)) value = Number(v);
|
|
569
|
+
else {
|
|
570
|
+
const m =
|
|
571
|
+
/^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d)(?::(\d\d)(\.\d{1,3})?)?(Z|[+-]\d\d:\d\d)$/i.exec(
|
|
572
|
+
v,
|
|
573
|
+
);
|
|
574
|
+
check(
|
|
575
|
+
m,
|
|
576
|
+
"Datetime strings require ISO 8601 with an explicit offset; numeric timestamps are seconds.",
|
|
577
|
+
);
|
|
578
|
+
const [y, mo, d, h, mi, s] = [
|
|
579
|
+
m[1],
|
|
580
|
+
m[2],
|
|
581
|
+
m[3],
|
|
582
|
+
m[4],
|
|
583
|
+
m[5],
|
|
584
|
+
m[6] || "0",
|
|
585
|
+
].map(Number);
|
|
586
|
+
check(
|
|
587
|
+
y >= 1 && mo >= 1 && mo <= 12 && d >= 1 && h < 24 && mi < 60 && s < 60,
|
|
588
|
+
"Invalid calendar date.",
|
|
589
|
+
);
|
|
590
|
+
const test = new Date(0);
|
|
591
|
+
test.setUTCFullYear(y, mo - 1, d);
|
|
592
|
+
test.setUTCHours(h, mi, s, 0);
|
|
593
|
+
check(
|
|
594
|
+
test.getUTCMonth() === mo - 1 && test.getUTCDate() === d,
|
|
595
|
+
"Invalid calendar date.",
|
|
596
|
+
);
|
|
597
|
+
if (m[8].toUpperCase() !== "Z")
|
|
598
|
+
check(
|
|
599
|
+
Number(m[8].slice(1, 3)) <= 23 && Number(m[8].slice(4)) <= 59,
|
|
600
|
+
"Invalid timezone offset.",
|
|
601
|
+
);
|
|
602
|
+
value = Date.parse(v) / 1000;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return number(value, "Timestamp", -62135596800, 253402300799.999);
|
|
606
|
+
}
|
|
607
|
+
function samples(timestamps: ArrayLike<Timestamp>, values: Values): Samples {
|
|
608
|
+
check(
|
|
609
|
+
(Array.isArray(timestamps) || ArrayBuffer.isView(timestamps)) &&
|
|
610
|
+
(Array.isArray(values) || ArrayBuffer.isView(values)),
|
|
611
|
+
"Timestamps and values must be arrays.",
|
|
612
|
+
);
|
|
613
|
+
check(
|
|
614
|
+
timestamps.length === values.length && timestamps.length <= LIMITS.samples,
|
|
615
|
+
"Sample lengths must match and stay within the sample limit.",
|
|
616
|
+
);
|
|
617
|
+
const ts = Array.from(timestamps, epoch),
|
|
618
|
+
vs = Array.from(values, (v, i) =>
|
|
619
|
+
missing(v) ? NaN : number(v, "Value at " + i),
|
|
620
|
+
);
|
|
621
|
+
for (let i = 1; i < ts.length; i++)
|
|
622
|
+
check(
|
|
623
|
+
ts[i] > ts[i - 1],
|
|
624
|
+
"Timestamps must be strictly increasing (index " + i + ").",
|
|
625
|
+
);
|
|
626
|
+
return { timestamps: ts, values: vs };
|
|
627
|
+
}
|
|
628
|
+
const DEFAULTS: CompleteChartInput = freeze<CompleteChartInput>({
|
|
629
|
+
title: "",
|
|
630
|
+
verticalLabel: "",
|
|
631
|
+
watermark: "",
|
|
632
|
+
series: [],
|
|
633
|
+
timeAxis: {
|
|
634
|
+
start: null,
|
|
635
|
+
end: null,
|
|
636
|
+
mode: "auto",
|
|
637
|
+
timezone: "UTC",
|
|
638
|
+
minorSeconds: null,
|
|
639
|
+
majorSeconds: null,
|
|
640
|
+
labelSeconds: null,
|
|
641
|
+
labelFormat: null,
|
|
642
|
+
ticks: null,
|
|
643
|
+
majorTicks: null,
|
|
644
|
+
minorTicks: null,
|
|
645
|
+
labelOffsetSeconds: 0,
|
|
646
|
+
},
|
|
647
|
+
yAxis: {
|
|
648
|
+
minimum: 0,
|
|
649
|
+
maximum: null,
|
|
650
|
+
majorStep: null,
|
|
651
|
+
minorDivisions: 5,
|
|
652
|
+
base: 1000,
|
|
653
|
+
scaleFactor: null,
|
|
654
|
+
suffix: null,
|
|
655
|
+
decimals: null,
|
|
656
|
+
legendDecimals: 2,
|
|
657
|
+
showZeroSuffix: false,
|
|
658
|
+
},
|
|
659
|
+
layout: {
|
|
660
|
+
width: 595,
|
|
661
|
+
plotHeight: 122,
|
|
662
|
+
left: 64,
|
|
663
|
+
right: 31,
|
|
664
|
+
top: 34,
|
|
665
|
+
titleY: 8,
|
|
666
|
+
titleOffsetX: 27,
|
|
667
|
+
unitX: 5,
|
|
668
|
+
xLabelGap: 5,
|
|
669
|
+
yLabelGap: 6,
|
|
670
|
+
legendGap: 20,
|
|
671
|
+
legendRowHeight: 14,
|
|
672
|
+
legendBottom: 7,
|
|
673
|
+
legend: "reference",
|
|
674
|
+
antialias: 4,
|
|
675
|
+
pixelScale: 1,
|
|
676
|
+
legendLayout: {
|
|
677
|
+
nameX: 30,
|
|
678
|
+
swatchX: 15,
|
|
679
|
+
swatchWidth: 9,
|
|
680
|
+
swatchHeight: 10,
|
|
681
|
+
referenceWidth: 595,
|
|
682
|
+
autoScaleColumns: true,
|
|
683
|
+
compact: [
|
|
684
|
+
[102, 228],
|
|
685
|
+
[244, 370],
|
|
686
|
+
[386, 512],
|
|
687
|
+
],
|
|
688
|
+
expanded: [
|
|
689
|
+
[124, 250],
|
|
690
|
+
[289, 415],
|
|
691
|
+
[454, 580],
|
|
692
|
+
],
|
|
693
|
+
aligned: [
|
|
694
|
+
[102, 250],
|
|
695
|
+
[267, 415],
|
|
696
|
+
[432, 580],
|
|
697
|
+
],
|
|
698
|
+
},
|
|
699
|
+
},
|
|
700
|
+
theme: {
|
|
701
|
+
background: "#f3f3f3",
|
|
702
|
+
canvas: "#ffffff",
|
|
703
|
+
shadeLight: "#cfcfcf",
|
|
704
|
+
shadeDark: "#9e9e9e",
|
|
705
|
+
text: "#000000",
|
|
706
|
+
minorGrid: "#8f8f8f3c",
|
|
707
|
+
majorGrid: "#df4f4f3c",
|
|
708
|
+
axis: "#777777",
|
|
709
|
+
arrow: "#7f1f1f",
|
|
710
|
+
watermark: "#aaaaaa",
|
|
711
|
+
frame: "#000000",
|
|
712
|
+
gridFront: true,
|
|
713
|
+
gridDash: [1, 1],
|
|
714
|
+
titleSize: 14,
|
|
715
|
+
axisSize: 11,
|
|
716
|
+
unitSize: 10,
|
|
717
|
+
legendSize: 11,
|
|
718
|
+
watermarkSize: 8,
|
|
719
|
+
captionSize: 11,
|
|
720
|
+
titleAdvance: 8,
|
|
721
|
+
axisAdvance: 6,
|
|
722
|
+
legendAdvance: 7,
|
|
723
|
+
},
|
|
724
|
+
fonts: {
|
|
725
|
+
mode: "system",
|
|
726
|
+
family: '"DejaVu Sans Mono", "Liberation Mono", Consolas, monospace',
|
|
727
|
+
titleFamily: null,
|
|
728
|
+
unitFamily: null,
|
|
729
|
+
captionFamily: "Arial, sans-serif",
|
|
730
|
+
strictGlyphs: true,
|
|
731
|
+
},
|
|
732
|
+
legendLabels: ["Current:", "Average:", "Maximum:"],
|
|
733
|
+
missingLabel: "NaN",
|
|
734
|
+
hRules: [],
|
|
735
|
+
vRules: [],
|
|
736
|
+
});
|
|
737
|
+
/** Copy, validate, and deeply freeze an independently timestamped series. */
|
|
738
|
+
export function series(
|
|
739
|
+
name: string,
|
|
740
|
+
timestamps: ArrayLike<Timestamp>,
|
|
741
|
+
values: Values,
|
|
742
|
+
options: SeriesOptions = {},
|
|
743
|
+
): Series {
|
|
744
|
+
const s = merge<CompleteSeriesInput>(
|
|
745
|
+
{
|
|
746
|
+
name,
|
|
747
|
+
timestamps,
|
|
748
|
+
values,
|
|
749
|
+
kind: "line",
|
|
750
|
+
color: "#0000cc",
|
|
751
|
+
lineWidth: 0.7,
|
|
752
|
+
outline: null,
|
|
753
|
+
baseline: 0,
|
|
754
|
+
interpolation: "linear",
|
|
755
|
+
gapAfter: 0,
|
|
756
|
+
legendValues: null,
|
|
757
|
+
},
|
|
758
|
+
options,
|
|
759
|
+
);
|
|
760
|
+
text(s.name, "Series name");
|
|
761
|
+
const data = samples(s.timestamps, s.values);
|
|
762
|
+
s.timestamps = data.timestamps;
|
|
763
|
+
s.values = data.values;
|
|
764
|
+
check(["line", "area"].includes(s.kind), "Series kind must be line or area.");
|
|
765
|
+
check(
|
|
766
|
+
["linear", "step-post"].includes(s.interpolation),
|
|
767
|
+
"Interpolation must be linear or step-post.",
|
|
768
|
+
);
|
|
769
|
+
number(s.lineWidth, "Line width", 0.01, 128);
|
|
770
|
+
number(s.baseline, "Baseline");
|
|
771
|
+
number(s.gapAfter, "Gap threshold", 0);
|
|
772
|
+
s.color = color(s.color);
|
|
773
|
+
if (s.outline !== null) s.outline = color(s.outline);
|
|
774
|
+
if (s.legendValues !== null) {
|
|
775
|
+
check(record(s.legendValues), "Legend overrides must be an object.");
|
|
776
|
+
for (const k of ["current", "average", "maximum"] as const)
|
|
777
|
+
s.legendValues[k] = missing(s.legendValues[k])
|
|
778
|
+
? null
|
|
779
|
+
: number(s.legendValues[k], "Legend override");
|
|
780
|
+
}
|
|
781
|
+
return freeze(s as Series);
|
|
782
|
+
}
|
|
783
|
+
/** Create a series with fixed elapsed-second spacing and copied observations. */
|
|
784
|
+
export function regularSeries(
|
|
785
|
+
name: string,
|
|
786
|
+
values: Values,
|
|
787
|
+
start: Timestamp,
|
|
788
|
+
step: number = 300,
|
|
789
|
+
options: SeriesOptions = {},
|
|
790
|
+
): Series {
|
|
791
|
+
start = epoch(start);
|
|
792
|
+
number(step, "Step", Number.MIN_VALUE);
|
|
793
|
+
const first = start;
|
|
794
|
+
return series(
|
|
795
|
+
name,
|
|
796
|
+
Array.from(values, (_, i) => first + i * step),
|
|
797
|
+
values,
|
|
798
|
+
options,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
function modeAxis(
|
|
802
|
+
mode: NonNullable<TimeAxis["mode"]>,
|
|
803
|
+
options: TimeAxis | string = {},
|
|
804
|
+
): TimeAxis {
|
|
805
|
+
return merge(
|
|
806
|
+
DEFAULTS.timeAxis,
|
|
807
|
+
merge(
|
|
808
|
+
{ mode },
|
|
809
|
+
typeof options === "string" ? { timezone: options } : options,
|
|
810
|
+
),
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
/** Daily tick presentation, without resampling observations. */
|
|
814
|
+
export function daily(options: TimeAxis | string = {}): TimeAxis {
|
|
815
|
+
return modeAxis("daily", options);
|
|
816
|
+
}
|
|
817
|
+
/** Weekly calendar-noon labels, without resampling observations. */
|
|
818
|
+
export function weekly(options: TimeAxis | string = {}): TimeAxis {
|
|
819
|
+
return modeAxis("weekly", options);
|
|
820
|
+
}
|
|
821
|
+
/** Monthly tick presentation, without resampling observations. */
|
|
822
|
+
export function monthly(options: TimeAxis | string = {}): TimeAxis {
|
|
823
|
+
return modeAxis("monthly", options);
|
|
824
|
+
}
|
|
825
|
+
/** Real calendar-month ticks, without resampling observations. */
|
|
826
|
+
export function yearly(options: TimeAxis | string = {}): TimeAxis {
|
|
827
|
+
return modeAxis("yearly", options);
|
|
828
|
+
}
|
|
829
|
+
function dimensions(c: {
|
|
830
|
+
layout: DeepReadonly<ResolvedLayout>;
|
|
831
|
+
series: readonly unknown[];
|
|
832
|
+
}): [number, number] {
|
|
833
|
+
const l = c.layout;
|
|
834
|
+
return [
|
|
835
|
+
l.width,
|
|
836
|
+
l.top +
|
|
837
|
+
l.plotHeight +
|
|
838
|
+
(l.legend === "none"
|
|
839
|
+
? 18
|
|
840
|
+
: l.legendGap +
|
|
841
|
+
Math.max(1, c.series.length) * l.legendRowHeight +
|
|
842
|
+
l.legendBottom),
|
|
843
|
+
];
|
|
844
|
+
}
|
|
845
|
+
function dash(d: readonly [number, number] | null): void {
|
|
846
|
+
if (d === null) return;
|
|
847
|
+
check(Array.isArray(d) && d.length === 2, "Dash must be null or [on, off].");
|
|
848
|
+
d.forEach((v) => integer(v, "Dash interval", 1, 16384));
|
|
849
|
+
}
|
|
850
|
+
function normalize(options: ChartOptions): ChartConfig {
|
|
851
|
+
const c = merge(DEFAULTS, options);
|
|
852
|
+
check(
|
|
853
|
+
Array.isArray(c.series) && c.series.length <= LIMITS.series,
|
|
854
|
+
"Invalid series count.",
|
|
855
|
+
);
|
|
856
|
+
c.series = c.series.map((s) => series(s.name, s.timestamps, s.values, s));
|
|
857
|
+
for (const k of [
|
|
858
|
+
"title",
|
|
859
|
+
"verticalLabel",
|
|
860
|
+
"watermark",
|
|
861
|
+
"missingLabel",
|
|
862
|
+
] as const)
|
|
863
|
+
text(c[k], k);
|
|
864
|
+
check(
|
|
865
|
+
Array.isArray(c.legendLabels) && c.legendLabels.length === 3,
|
|
866
|
+
"Three statistic labels are required.",
|
|
867
|
+
);
|
|
868
|
+
c.legendLabels.forEach((v) => text(v, "Statistic label"));
|
|
869
|
+
const a = c.timeAxis,
|
|
870
|
+
l = c.layout,
|
|
871
|
+
y = c.yAxis,
|
|
872
|
+
t = c.theme,
|
|
873
|
+
f = c.fonts;
|
|
874
|
+
check(
|
|
875
|
+
["auto", "daily", "weekly", "monthly", "yearly", "custom"].includes(a.mode),
|
|
876
|
+
"Unknown time axis mode.",
|
|
877
|
+
);
|
|
878
|
+
text(a.timezone, "Timezone");
|
|
879
|
+
zoneFormatter(a.timezone);
|
|
880
|
+
for (const k of ["start", "end"] as const)
|
|
881
|
+
if (a[k] !== null) a[k] = epoch(a[k]);
|
|
882
|
+
if (a.start !== null && a.end !== null)
|
|
883
|
+
check(
|
|
884
|
+
(a.end as number) > (a.start as number),
|
|
885
|
+
"Time end must exceed start.",
|
|
886
|
+
);
|
|
887
|
+
for (const k of ["minorSeconds", "majorSeconds", "labelSeconds"] as const)
|
|
888
|
+
if (a[k] !== null) number(a[k], k, 0.001);
|
|
889
|
+
number(a.labelOffsetSeconds, "Label offset", -31622400, 31622400);
|
|
890
|
+
if (a.labelFormat !== null) {
|
|
891
|
+
text(a.labelFormat, "Label format");
|
|
892
|
+
formatTime(0, "UTC", a.labelFormat);
|
|
893
|
+
}
|
|
894
|
+
if (a.ticks !== null) {
|
|
895
|
+
check(
|
|
896
|
+
Array.isArray(a.ticks) && a.ticks.length <= LIMITS.ticks,
|
|
897
|
+
"Too many or invalid ticks.",
|
|
898
|
+
);
|
|
899
|
+
const ticks = a.ticks.map((v) => ({
|
|
900
|
+
time: epoch(v.time),
|
|
901
|
+
label: text(v.label, "Tick label"),
|
|
902
|
+
}));
|
|
903
|
+
for (let i = 1; i < ticks.length; i++)
|
|
904
|
+
check(
|
|
905
|
+
ticks[i].time > ticks[i - 1].time,
|
|
906
|
+
"Explicit ticks must be strictly increasing.",
|
|
907
|
+
);
|
|
908
|
+
a.ticks = ticks;
|
|
909
|
+
}
|
|
910
|
+
for (const k of ["minorTicks", "majorTicks"] as const)
|
|
911
|
+
if (a[k] !== null) {
|
|
912
|
+
const input = a[k];
|
|
913
|
+
check(
|
|
914
|
+
Array.isArray(input) && input.length <= LIMITS.ticks,
|
|
915
|
+
"Too many or invalid ticks.",
|
|
916
|
+
);
|
|
917
|
+
const ticks = input.map(epoch);
|
|
918
|
+
for (let i = 1; i < ticks.length; i++)
|
|
919
|
+
check(
|
|
920
|
+
ticks[i] > ticks[i - 1],
|
|
921
|
+
"Explicit ticks must be strictly increasing.",
|
|
922
|
+
);
|
|
923
|
+
a[k] = ticks;
|
|
924
|
+
}
|
|
925
|
+
for (const k of ["minimum", "maximum", "majorStep", "scaleFactor"] as const)
|
|
926
|
+
if (y[k] !== null) number(y[k], k);
|
|
927
|
+
for (const k of ["majorStep", "scaleFactor"] as const)
|
|
928
|
+
if (y[k] !== null) check(y[k] > 0, k + " must be positive.");
|
|
929
|
+
if (y.minimum !== null && y.maximum !== null)
|
|
930
|
+
check(y.maximum > y.minimum, "Y maximum must exceed minimum.");
|
|
931
|
+
check(y.base === 1000 || y.base === 1024, "Unit base must be 1000 or 1024.");
|
|
932
|
+
integer(y.minorDivisions, "Minor divisions", 1, 100);
|
|
933
|
+
integer(y.legendDecimals, "Legend decimals", 0, 12);
|
|
934
|
+
if (y.decimals !== null) integer(y.decimals, "Decimals", 0, 12);
|
|
935
|
+
if (y.suffix !== null) text(y.suffix, "Suffix");
|
|
936
|
+
integer(l.width, "Width", 400, 8192);
|
|
937
|
+
integer(l.plotHeight, "Plot height", 30, 4096);
|
|
938
|
+
for (const k of [
|
|
939
|
+
"left",
|
|
940
|
+
"right",
|
|
941
|
+
"top",
|
|
942
|
+
"titleY",
|
|
943
|
+
"unitX",
|
|
944
|
+
"xLabelGap",
|
|
945
|
+
"yLabelGap",
|
|
946
|
+
"legendGap",
|
|
947
|
+
"legendRowHeight",
|
|
948
|
+
"legendBottom",
|
|
949
|
+
] as const)
|
|
950
|
+
integer(l[k], k, 0, 16384);
|
|
951
|
+
check(
|
|
952
|
+
l.left >= 20 &&
|
|
953
|
+
l.right >= 12 &&
|
|
954
|
+
l.top >= 12 &&
|
|
955
|
+
l.width - l.left - l.right >= 100,
|
|
956
|
+
"Insufficient plot margins.",
|
|
957
|
+
);
|
|
958
|
+
number(l.titleOffsetX, "Title offset", -8192, 8192);
|
|
959
|
+
check(
|
|
960
|
+
["reference", "aligned", "none"].includes(l.legend),
|
|
961
|
+
"Invalid legend mode.",
|
|
962
|
+
);
|
|
963
|
+
if (l.legend !== "none")
|
|
964
|
+
check(
|
|
965
|
+
l.legendGap >= 14 && l.legendRowHeight >= 10,
|
|
966
|
+
"Legend spacing is insufficient.",
|
|
967
|
+
);
|
|
968
|
+
integer(l.antialias, "Antialias", 1, 8);
|
|
969
|
+
integer(l.pixelScale, "Pixel scale", 1, 8);
|
|
970
|
+
const ll = l.legendLayout;
|
|
971
|
+
for (const k of [
|
|
972
|
+
"nameX",
|
|
973
|
+
"swatchX",
|
|
974
|
+
"swatchWidth",
|
|
975
|
+
"swatchHeight",
|
|
976
|
+
"referenceWidth",
|
|
977
|
+
] as const)
|
|
978
|
+
integer(ll[k], k, 0, 16384);
|
|
979
|
+
check(
|
|
980
|
+
ll.swatchWidth >= 3 && ll.swatchHeight >= 3 && ll.referenceWidth > ll.nameX,
|
|
981
|
+
"Invalid legend geometry.",
|
|
982
|
+
);
|
|
983
|
+
for (const key of ["compact", "expanded", "aligned"] as const) {
|
|
984
|
+
const cols = ll[key];
|
|
985
|
+
check(Array.isArray(cols) && cols.length === 3, "Invalid legend columns.");
|
|
986
|
+
let last = ll.nameX;
|
|
987
|
+
for (const p of cols) {
|
|
988
|
+
check(
|
|
989
|
+
Array.isArray(p) &&
|
|
990
|
+
p.length === 2 &&
|
|
991
|
+
finite(p[0]) &&
|
|
992
|
+
finite(p[1]) &&
|
|
993
|
+
last < p[0] &&
|
|
994
|
+
p[0] < p[1] &&
|
|
995
|
+
p[1] < ll.referenceWidth,
|
|
996
|
+
"Legend columns must not overlap.",
|
|
997
|
+
);
|
|
998
|
+
last = p[1];
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
for (const k of [
|
|
1002
|
+
"background",
|
|
1003
|
+
"canvas",
|
|
1004
|
+
"shadeLight",
|
|
1005
|
+
"shadeDark",
|
|
1006
|
+
"text",
|
|
1007
|
+
"minorGrid",
|
|
1008
|
+
"majorGrid",
|
|
1009
|
+
"axis",
|
|
1010
|
+
"arrow",
|
|
1011
|
+
"watermark",
|
|
1012
|
+
"frame",
|
|
1013
|
+
] as const)
|
|
1014
|
+
t[k] = color(t[k]);
|
|
1015
|
+
check(
|
|
1016
|
+
t.background[3] === 255 && t.canvas[3] === 255,
|
|
1017
|
+
"Background and canvas must be opaque.",
|
|
1018
|
+
);
|
|
1019
|
+
dash(t.gridDash);
|
|
1020
|
+
check(t.gridDash !== null, "Grid dash is required.");
|
|
1021
|
+
for (const k of [
|
|
1022
|
+
"titleSize",
|
|
1023
|
+
"axisSize",
|
|
1024
|
+
"unitSize",
|
|
1025
|
+
"legendSize",
|
|
1026
|
+
"watermarkSize",
|
|
1027
|
+
"captionSize",
|
|
1028
|
+
"titleAdvance",
|
|
1029
|
+
"axisAdvance",
|
|
1030
|
+
"legendAdvance",
|
|
1031
|
+
] as const)
|
|
1032
|
+
number(t[k], k, 1, 128);
|
|
1033
|
+
check(
|
|
1034
|
+
["system", "bitmap"].includes(f.mode),
|
|
1035
|
+
"Font mode must be system or bitmap.",
|
|
1036
|
+
);
|
|
1037
|
+
text(f.family, "Font family");
|
|
1038
|
+
for (const k of ["titleFamily", "unitFamily", "captionFamily"] as const)
|
|
1039
|
+
if (f[k] !== null) text(f[k], k);
|
|
1040
|
+
function rule<R extends HRule | VRule>(
|
|
1041
|
+
input: R,
|
|
1042
|
+
field: "time" | "value",
|
|
1043
|
+
): R & Required<RuleStyle> {
|
|
1044
|
+
const r = merge(
|
|
1045
|
+
{ color: "#990000", width: 1, dash: [3, 2] },
|
|
1046
|
+
input,
|
|
1047
|
+
) as unknown as R & Required<RuleStyle>;
|
|
1048
|
+
if (field === "time") (r as VRule).time = epoch((r as VRule).time);
|
|
1049
|
+
else (r as HRule).value = number((r as HRule).value, "Rule value");
|
|
1050
|
+
number(r.width, "Rule width", 0.1, 128);
|
|
1051
|
+
dash(r.dash);
|
|
1052
|
+
r.color = color(r.color);
|
|
1053
|
+
return r;
|
|
1054
|
+
}
|
|
1055
|
+
check(
|
|
1056
|
+
Array.isArray(c.hRules) && c.hRules.length <= 1000,
|
|
1057
|
+
"Invalid rule count.",
|
|
1058
|
+
);
|
|
1059
|
+
c.hRules = c.hRules.map((r) => rule(r, "value"));
|
|
1060
|
+
check(
|
|
1061
|
+
Array.isArray(c.vRules) && c.vRules.length <= 1000,
|
|
1062
|
+
"Invalid rule count.",
|
|
1063
|
+
);
|
|
1064
|
+
c.vRules = c.vRules.map((r) => rule(r, "time"));
|
|
1065
|
+
const [w, h] = dimensions(c);
|
|
1066
|
+
check(
|
|
1067
|
+
w * h * l.pixelScale ** 2 <= LIMITS.pixels,
|
|
1068
|
+
"Output allocation exceeds the pixel limit.",
|
|
1069
|
+
);
|
|
1070
|
+
check(
|
|
1071
|
+
(l.width - l.left - l.right + 1) * (l.plotHeight + 1) * l.antialias ** 2 <=
|
|
1072
|
+
LIMITS.layerPixels,
|
|
1073
|
+
"Supersampled layer exceeds the pixel limit.",
|
|
1074
|
+
);
|
|
1075
|
+
// All timestamp, color, series and rule fields have now been normalized.
|
|
1076
|
+
return freeze(c as ResolvedChartOptions);
|
|
1077
|
+
}
|
|
1078
|
+
// A straight-alpha pixel surface: data geometry never depends on a browser path rasterizer.
|
|
1079
|
+
class Surface implements RGBAImage {
|
|
1080
|
+
readonly width: number;
|
|
1081
|
+
readonly height: number;
|
|
1082
|
+
readonly data: Uint8ClampedArray;
|
|
1083
|
+
constructor(
|
|
1084
|
+
width: number,
|
|
1085
|
+
height: number,
|
|
1086
|
+
fill: Readonly<RGBA> | null = null,
|
|
1087
|
+
) {
|
|
1088
|
+
integer(width, "Image width", 1, 65536);
|
|
1089
|
+
integer(height, "Image height", 1, 65536);
|
|
1090
|
+
check(width * height <= LIMITS.layerPixels, "Image is too large.");
|
|
1091
|
+
this.width = width;
|
|
1092
|
+
this.height = height;
|
|
1093
|
+
this.data = new Uint8ClampedArray(width * height * 4);
|
|
1094
|
+
if (fill) this.rect(0, 0, width, height, fill);
|
|
1095
|
+
}
|
|
1096
|
+
pixel(x: number, y: number, c: Readonly<RGBA>, blend: boolean = false): void {
|
|
1097
|
+
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
|
|
1098
|
+
const i = (y * this.width + x) * 4,
|
|
1099
|
+
d = this.data;
|
|
1100
|
+
if (!blend || c[3] === 255) {
|
|
1101
|
+
d[i] = c[0];
|
|
1102
|
+
d[i + 1] = c[1];
|
|
1103
|
+
d[i + 2] = c[2];
|
|
1104
|
+
d[i + 3] = c[3];
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
if (c[3] === 0) return;
|
|
1108
|
+
const sa = c[3],
|
|
1109
|
+
da = d[i + 3],
|
|
1110
|
+
alpha = sa * 255 + da * (255 - sa);
|
|
1111
|
+
for (let k = 0; k < 3; k++)
|
|
1112
|
+
d[i + k] = Math.floor(
|
|
1113
|
+
(c[k] * sa * 255 + d[i + k] * da * (255 - sa) + alpha / 2) / alpha,
|
|
1114
|
+
);
|
|
1115
|
+
d[i + 3] = Math.floor((alpha + 127) / 255);
|
|
1116
|
+
}
|
|
1117
|
+
rect(
|
|
1118
|
+
x0: number,
|
|
1119
|
+
y0: number,
|
|
1120
|
+
x1: number,
|
|
1121
|
+
y1: number,
|
|
1122
|
+
c: Readonly<RGBA>,
|
|
1123
|
+
): void {
|
|
1124
|
+
x0 = Math.max(0, Math.ceil(x0));
|
|
1125
|
+
y0 = Math.max(0, Math.ceil(y0));
|
|
1126
|
+
x1 = Math.min(this.width, Math.ceil(x1));
|
|
1127
|
+
y1 = Math.min(this.height, Math.ceil(y1));
|
|
1128
|
+
for (let y = y0; y < y1; y++)
|
|
1129
|
+
for (let x = x0, i = (y * this.width + x0) * 4; x < x1; x++, i += 4) {
|
|
1130
|
+
this.data[i] = c[0];
|
|
1131
|
+
this.data[i + 1] = c[1];
|
|
1132
|
+
this.data[i + 2] = c[2];
|
|
1133
|
+
this.data[i + 3] = c[3];
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
over(src: RGBAImage, dx: number = 0, dy: number = 0): void {
|
|
1137
|
+
const p: RGBA = [0, 0, 0, 0];
|
|
1138
|
+
for (
|
|
1139
|
+
let y = Math.max(0, -dy);
|
|
1140
|
+
y < Math.min(src.height, this.height - dy);
|
|
1141
|
+
y++
|
|
1142
|
+
)
|
|
1143
|
+
for (
|
|
1144
|
+
let x = Math.max(0, -dx);
|
|
1145
|
+
x < Math.min(src.width, this.width - dx);
|
|
1146
|
+
x++
|
|
1147
|
+
) {
|
|
1148
|
+
const i = (y * src.width + x) * 4;
|
|
1149
|
+
if (src.data[i + 3]) {
|
|
1150
|
+
p[0] = src.data[i];
|
|
1151
|
+
p[1] = src.data[i + 1];
|
|
1152
|
+
p[2] = src.data[i + 2];
|
|
1153
|
+
p[3] = src.data[i + 3];
|
|
1154
|
+
this.pixel(x + dx, y + dy, p, true);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
polygon(points: readonly Point[], c: Readonly<RGBA>): void {
|
|
1159
|
+
if (points.length < 3) return;
|
|
1160
|
+
let lo = Infinity,
|
|
1161
|
+
hi = -Infinity;
|
|
1162
|
+
for (const p of points) {
|
|
1163
|
+
lo = Math.min(lo, p[1]);
|
|
1164
|
+
hi = Math.max(hi, p[1]);
|
|
1165
|
+
}
|
|
1166
|
+
for (
|
|
1167
|
+
let y = Math.max(0, Math.ceil(lo));
|
|
1168
|
+
y <= Math.min(this.height - 1, Math.floor(hi));
|
|
1169
|
+
y++
|
|
1170
|
+
) {
|
|
1171
|
+
const xs = [];
|
|
1172
|
+
let a = points[points.length - 1];
|
|
1173
|
+
for (const b of points) {
|
|
1174
|
+
if (a[1] === b[1]) {
|
|
1175
|
+
if (y === a[1])
|
|
1176
|
+
this.rect(
|
|
1177
|
+
Math.ceil(Math.min(a[0], b[0])),
|
|
1178
|
+
y,
|
|
1179
|
+
Math.floor(Math.max(a[0], b[0])) + 1,
|
|
1180
|
+
y + 1,
|
|
1181
|
+
c,
|
|
1182
|
+
);
|
|
1183
|
+
} else if ((a[1] <= y && y < b[1]) || (b[1] <= y && y < a[1]))
|
|
1184
|
+
xs.push(a[0] + ((y - a[1]) / (b[1] - a[1])) * (b[0] - a[0]));
|
|
1185
|
+
a = b;
|
|
1186
|
+
}
|
|
1187
|
+
xs.sort((a, b) => a - b);
|
|
1188
|
+
for (let i = 0; i + 1 < xs.length; i += 2)
|
|
1189
|
+
this.rect(
|
|
1190
|
+
Math.ceil(xs[i] - 1e-9),
|
|
1191
|
+
y,
|
|
1192
|
+
Math.floor(xs[i + 1] + 1e-9) + 1,
|
|
1193
|
+
y + 1,
|
|
1194
|
+
c,
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
line(a: Point, b: Point, c: Readonly<RGBA>, width: number = 1): void {
|
|
1199
|
+
let x = round(a[0]),
|
|
1200
|
+
y = round(a[1]),
|
|
1201
|
+
x1 = round(b[0]),
|
|
1202
|
+
y1 = round(b[1]);
|
|
1203
|
+
width = Math.max(1, round(width));
|
|
1204
|
+
if (width > 1) {
|
|
1205
|
+
const r = (width - 1) / 2;
|
|
1206
|
+
if (x === x1) {
|
|
1207
|
+
this.rect(
|
|
1208
|
+
x - Math.floor(width / 2),
|
|
1209
|
+
Math.min(y, y1),
|
|
1210
|
+
x + Math.floor((width - 1) / 2) + 1,
|
|
1211
|
+
Math.max(y, y1) + 1,
|
|
1212
|
+
c,
|
|
1213
|
+
);
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
if (y === y1) {
|
|
1217
|
+
this.rect(
|
|
1218
|
+
Math.min(x, x1),
|
|
1219
|
+
y - Math.floor(width / 2),
|
|
1220
|
+
Math.max(x, x1) + 1,
|
|
1221
|
+
y + Math.floor((width - 1) / 2) + 1,
|
|
1222
|
+
c,
|
|
1223
|
+
);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
const len = Math.hypot(x1 - x, y1 - y),
|
|
1227
|
+
ox = (-(y1 - y) / len) * r,
|
|
1228
|
+
oy = ((x1 - x) / len) * r;
|
|
1229
|
+
this.polygon(
|
|
1230
|
+
[
|
|
1231
|
+
[x + ox, y + oy],
|
|
1232
|
+
[x1 + ox, y1 + oy],
|
|
1233
|
+
[x1 - ox, y1 - oy],
|
|
1234
|
+
[x - ox, y - oy],
|
|
1235
|
+
].map((p): Point => [round(p[0]), round(p[1])]),
|
|
1236
|
+
c,
|
|
1237
|
+
);
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
const dx = Math.abs(x1 - x),
|
|
1241
|
+
dy = -Math.abs(y1 - y),
|
|
1242
|
+
sx = x < x1 ? 1 : -1,
|
|
1243
|
+
sy = y < y1 ? 1 : -1;
|
|
1244
|
+
let err = dx + dy;
|
|
1245
|
+
for (;;) {
|
|
1246
|
+
this.pixel(x, y, c);
|
|
1247
|
+
if (x === x1 && y === y1) break;
|
|
1248
|
+
const e = 2 * err;
|
|
1249
|
+
if (e >= dy) {
|
|
1250
|
+
err += dy;
|
|
1251
|
+
x += sx;
|
|
1252
|
+
}
|
|
1253
|
+
if (e <= dx) {
|
|
1254
|
+
err += dx;
|
|
1255
|
+
y += sy;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
dashed(
|
|
1260
|
+
a: Point,
|
|
1261
|
+
b: Point,
|
|
1262
|
+
c: Readonly<RGBA>,
|
|
1263
|
+
pattern: readonly [number, number] | null = [1, 1],
|
|
1264
|
+
width: number = 1,
|
|
1265
|
+
): void {
|
|
1266
|
+
if (pattern === null) {
|
|
1267
|
+
this.line(a, b, c, width);
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
1271
|
+
if (!len) {
|
|
1272
|
+
this.pixel(round(a[0]), round(a[1]), c);
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
const dx = (b[0] - a[0]) / len,
|
|
1276
|
+
dy = (b[1] - a[1]) / len;
|
|
1277
|
+
for (let s = 0; s <= Math.ceil(len); s += pattern[0] + pattern[1]) {
|
|
1278
|
+
const e = Math.min(len, s + pattern[0] - 1);
|
|
1279
|
+
this.line(
|
|
1280
|
+
[a[0] + dx * s, a[1] + dy * s],
|
|
1281
|
+
[a[0] + dx * e, a[1] + dy * e],
|
|
1282
|
+
c,
|
|
1283
|
+
width,
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
circle(x: number, y: number, r: number, c: Readonly<RGBA>): void {
|
|
1288
|
+
for (
|
|
1289
|
+
let yy = Math.max(0, Math.floor(y - r));
|
|
1290
|
+
yy <= Math.min(this.height - 1, Math.ceil(y + r));
|
|
1291
|
+
yy++
|
|
1292
|
+
)
|
|
1293
|
+
for (
|
|
1294
|
+
let xx = Math.max(0, Math.floor(x - r));
|
|
1295
|
+
xx <= Math.min(this.width - 1, Math.ceil(x + r));
|
|
1296
|
+
xx++
|
|
1297
|
+
)
|
|
1298
|
+
if ((xx - x) ** 2 + (yy - y) ** 2 <= r * r) this.pixel(xx, yy, c);
|
|
1299
|
+
}
|
|
1300
|
+
down(scale: number): Surface {
|
|
1301
|
+
if (scale === 1) return this;
|
|
1302
|
+
const out = new Surface(this.width / scale, this.height / scale),
|
|
1303
|
+
p: RGBA = [0, 0, 0, 0],
|
|
1304
|
+
n = scale * scale;
|
|
1305
|
+
for (let y = 0; y < out.height; y++)
|
|
1306
|
+
for (let x = 0; x < out.width; x++) {
|
|
1307
|
+
let a = 0,
|
|
1308
|
+
r = 0,
|
|
1309
|
+
g = 0,
|
|
1310
|
+
b = 0;
|
|
1311
|
+
for (let sy = 0; sy < scale; sy++)
|
|
1312
|
+
for (let sx = 0; sx < scale; sx++) {
|
|
1313
|
+
const i = ((y * scale + sy) * this.width + x * scale + sx) * 4,
|
|
1314
|
+
ca = this.data[i + 3];
|
|
1315
|
+
a += ca;
|
|
1316
|
+
r += this.data[i] * ca;
|
|
1317
|
+
g += this.data[i + 1] * ca;
|
|
1318
|
+
b += this.data[i + 2] * ca;
|
|
1319
|
+
}
|
|
1320
|
+
if (a) {
|
|
1321
|
+
p[0] = Math.floor(r / a + 0.5);
|
|
1322
|
+
p[1] = Math.floor(g / a + 0.5);
|
|
1323
|
+
p[2] = Math.floor(b / a + 0.5);
|
|
1324
|
+
p[3] = Math.floor(a / n + 0.5);
|
|
1325
|
+
out.pixel(x, y, p);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
return out;
|
|
1329
|
+
}
|
|
1330
|
+
scale(n: number): Surface {
|
|
1331
|
+
if (n === 1) return this;
|
|
1332
|
+
const out = new Surface(this.width * n, this.height * n);
|
|
1333
|
+
for (let y = 0; y < out.height; y++)
|
|
1334
|
+
for (let x = 0; x < out.width; x++) {
|
|
1335
|
+
const s = (Math.floor(y / n) * this.width + Math.floor(x / n)) * 4,
|
|
1336
|
+
i = (y * out.width + x) * 4;
|
|
1337
|
+
out.data[i] = this.data[s];
|
|
1338
|
+
out.data[i + 1] = this.data[s + 1];
|
|
1339
|
+
out.data[i + 2] = this.data[s + 2];
|
|
1340
|
+
out.data[i + 3] = this.data[s + 3];
|
|
1341
|
+
}
|
|
1342
|
+
return out;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
function clipLine(
|
|
1346
|
+
a: Point,
|
|
1347
|
+
b: Point,
|
|
1348
|
+
w: number,
|
|
1349
|
+
h: number,
|
|
1350
|
+
): [Point, Point] | null {
|
|
1351
|
+
let lo = 0,
|
|
1352
|
+
hi = 1;
|
|
1353
|
+
const dx = b[0] - a[0],
|
|
1354
|
+
dy = b[1] - a[1];
|
|
1355
|
+
for (const [p, q] of [
|
|
1356
|
+
[-dx, a[0]],
|
|
1357
|
+
[dx, w - a[0]],
|
|
1358
|
+
[-dy, a[1]],
|
|
1359
|
+
[dy, h - a[1]],
|
|
1360
|
+
]) {
|
|
1361
|
+
if (p === 0) {
|
|
1362
|
+
if (q < 0) return null;
|
|
1363
|
+
} else {
|
|
1364
|
+
const u = q / p;
|
|
1365
|
+
if (p < 0) lo = Math.max(lo, u);
|
|
1366
|
+
else hi = Math.min(hi, u);
|
|
1367
|
+
if (lo > hi) return null;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
return [
|
|
1371
|
+
[a[0] + lo * dx, a[1] + lo * dy],
|
|
1372
|
+
[a[0] + hi * dx, a[1] + hi * dy],
|
|
1373
|
+
];
|
|
1374
|
+
}
|
|
1375
|
+
function clipPolygon(points: Point[], w: number, h: number): Point[] {
|
|
1376
|
+
let ps = points;
|
|
1377
|
+
for (const [axis, bound, greater] of [
|
|
1378
|
+
[0, 0, true],
|
|
1379
|
+
[0, w, false],
|
|
1380
|
+
[1, 0, true],
|
|
1381
|
+
[1, h, false],
|
|
1382
|
+
] as const) {
|
|
1383
|
+
if (!ps.length) break;
|
|
1384
|
+
const out: Point[] = [],
|
|
1385
|
+
inside = (p: Point): boolean =>
|
|
1386
|
+
greater ? p[axis] >= bound : p[axis] <= bound;
|
|
1387
|
+
let a = ps[ps.length - 1],
|
|
1388
|
+
ai = inside(a);
|
|
1389
|
+
for (const b of ps) {
|
|
1390
|
+
const bi = inside(b);
|
|
1391
|
+
if (ai !== bi) {
|
|
1392
|
+
const q = (bound - a[axis]) / (b[axis] - a[axis]);
|
|
1393
|
+
const p: Point = [a[0] + q * (b[0] - a[0]), a[1] + q * (b[1] - a[1])];
|
|
1394
|
+
p[axis] = bound;
|
|
1395
|
+
out.push(p);
|
|
1396
|
+
}
|
|
1397
|
+
if (bi) out.push(b);
|
|
1398
|
+
a = b;
|
|
1399
|
+
ai = bi;
|
|
1400
|
+
}
|
|
1401
|
+
ps = out;
|
|
1402
|
+
}
|
|
1403
|
+
return ps;
|
|
1404
|
+
}
|
|
1405
|
+
function lowerBound(a: readonly number[], x: number): number {
|
|
1406
|
+
let l = 0,
|
|
1407
|
+
r = a.length;
|
|
1408
|
+
while (l < r) {
|
|
1409
|
+
const m = (l + r) >>> 1;
|
|
1410
|
+
if (a[m] < x) l = m + 1;
|
|
1411
|
+
else r = m;
|
|
1412
|
+
}
|
|
1413
|
+
return l;
|
|
1414
|
+
}
|
|
1415
|
+
function upperBound(a: readonly number[], x: number): number {
|
|
1416
|
+
let l = 0,
|
|
1417
|
+
r = a.length;
|
|
1418
|
+
while (l < r) {
|
|
1419
|
+
const m = (l + r) >>> 1;
|
|
1420
|
+
if (a[m] <= x) l = m + 1;
|
|
1421
|
+
else r = m;
|
|
1422
|
+
}
|
|
1423
|
+
return l;
|
|
1424
|
+
}
|
|
1425
|
+
function visibleRuns(s: Series, start: number, end: number): Point[][] {
|
|
1426
|
+
const ts = s.timestamps,
|
|
1427
|
+
vs = s.values,
|
|
1428
|
+
result: Point[][] = [];
|
|
1429
|
+
let run: Point[] = [];
|
|
1430
|
+
const push = () => {
|
|
1431
|
+
if (run.length) {
|
|
1432
|
+
result.push(run);
|
|
1433
|
+
run = [];
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
const left = Math.max(0, lowerBound(ts, start) - 1),
|
|
1437
|
+
right = Math.min(ts.length, upperBound(ts, end) + 1);
|
|
1438
|
+
for (let i = left; i < right; i++) {
|
|
1439
|
+
if (!finite(vs[i])) {
|
|
1440
|
+
push();
|
|
1441
|
+
continue;
|
|
1442
|
+
}
|
|
1443
|
+
if (i > left && s.gapAfter > 0 && ts[i] - ts[i - 1] > s.gapAfter) push();
|
|
1444
|
+
if (run.length && s.interpolation === "step-post")
|
|
1445
|
+
run.push([ts[i], run[run.length - 1][1]]);
|
|
1446
|
+
run.push([ts[i], vs[i]]);
|
|
1447
|
+
}
|
|
1448
|
+
push();
|
|
1449
|
+
const clipped: Point[][] = [];
|
|
1450
|
+
for (const r of result) {
|
|
1451
|
+
const out: Point[] = [];
|
|
1452
|
+
if (r.length === 1) {
|
|
1453
|
+
if (r[0][0] >= start && r[0][0] <= end) out.push(r[0]);
|
|
1454
|
+
}
|
|
1455
|
+
for (let i = 1; i < r.length; i++) {
|
|
1456
|
+
const a = r[i - 1],
|
|
1457
|
+
b = r[i];
|
|
1458
|
+
if (b[0] < start || a[0] > end) continue;
|
|
1459
|
+
const interp = (x: number): number =>
|
|
1460
|
+
a[1] * (1 - (x - a[0]) / (b[0] - a[0])) +
|
|
1461
|
+
b[1] * ((x - a[0]) / (b[0] - a[0]));
|
|
1462
|
+
const p: Point = a[0] < start ? [start, interp(start)] : a,
|
|
1463
|
+
q: Point = b[0] > end ? [end, interp(end)] : b;
|
|
1464
|
+
if (
|
|
1465
|
+
!out.length ||
|
|
1466
|
+
out[out.length - 1][0] !== p[0] ||
|
|
1467
|
+
out[out.length - 1][1] !== p[1]
|
|
1468
|
+
)
|
|
1469
|
+
out.push(p);
|
|
1470
|
+
out.push(q);
|
|
1471
|
+
}
|
|
1472
|
+
if (out.length) clipped.push(out);
|
|
1473
|
+
}
|
|
1474
|
+
return clipped;
|
|
1475
|
+
}
|
|
1476
|
+
function decimate(
|
|
1477
|
+
ps: Point[],
|
|
1478
|
+
start: number,
|
|
1479
|
+
end: number,
|
|
1480
|
+
width: number,
|
|
1481
|
+
): Point[] {
|
|
1482
|
+
if (ps.length <= width * 4) return ps;
|
|
1483
|
+
const out = [];
|
|
1484
|
+
let pos = 0;
|
|
1485
|
+
while (pos < ps.length) {
|
|
1486
|
+
const col = Math.floor(((ps[pos][0] - start) / (end - start)) * width),
|
|
1487
|
+
first = pos;
|
|
1488
|
+
let mn = pos,
|
|
1489
|
+
mx = pos;
|
|
1490
|
+
while (
|
|
1491
|
+
pos + 1 < ps.length &&
|
|
1492
|
+
Math.floor(((ps[pos + 1][0] - start) / (end - start)) * width) === col
|
|
1493
|
+
) {
|
|
1494
|
+
pos++;
|
|
1495
|
+
if (ps[pos][1] < ps[mn][1]) mn = pos;
|
|
1496
|
+
if (ps[pos][1] > ps[mx][1]) mx = pos;
|
|
1497
|
+
}
|
|
1498
|
+
for (const i of [...new Set([first, mn, mx, pos])].sort((a, b) => a - b))
|
|
1499
|
+
out.push(ps[i]);
|
|
1500
|
+
pos++;
|
|
1501
|
+
}
|
|
1502
|
+
return out;
|
|
1503
|
+
}
|
|
1504
|
+
function stableMean(values: readonly number[]): number | null {
|
|
1505
|
+
if (!values.length) return null;
|
|
1506
|
+
let max = 0;
|
|
1507
|
+
for (const v of values) max = Math.max(max, Math.abs(v));
|
|
1508
|
+
if (max === 0) return 0;
|
|
1509
|
+
let sum = 0,
|
|
1510
|
+
c = 0;
|
|
1511
|
+
for (const v of values) {
|
|
1512
|
+
const a = v / max - c,
|
|
1513
|
+
t = sum + a;
|
|
1514
|
+
c = t - sum - a;
|
|
1515
|
+
sum = t;
|
|
1516
|
+
}
|
|
1517
|
+
return clamp(sum / values.length, -1, 1) * max;
|
|
1518
|
+
}
|
|
1519
|
+
function statistics(s: Series, start: number, end: number): Statistics {
|
|
1520
|
+
const a = lowerBound(s.timestamps, start),
|
|
1521
|
+
b = upperBound(s.timestamps, end),
|
|
1522
|
+
values = [];
|
|
1523
|
+
let mn = Infinity,
|
|
1524
|
+
mx = -Infinity,
|
|
1525
|
+
miss = 0;
|
|
1526
|
+
for (let i = a; i < b; i++) {
|
|
1527
|
+
const v = s.values[i];
|
|
1528
|
+
if (!finite(v)) miss++;
|
|
1529
|
+
else {
|
|
1530
|
+
values.push(v);
|
|
1531
|
+
mn = Math.min(mn, v);
|
|
1532
|
+
mx = Math.max(mx, v);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
return {
|
|
1536
|
+
name: s.name,
|
|
1537
|
+
current: b > a && finite(s.values[b - 1]) ? s.values[b - 1] : null,
|
|
1538
|
+
average: stableMean(values),
|
|
1539
|
+
maximum: values.length ? mx : null,
|
|
1540
|
+
minimum: values.length ? mn : null,
|
|
1541
|
+
count: values.length,
|
|
1542
|
+
missing: miss,
|
|
1543
|
+
displayOverride: s.legendValues,
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
// Numeric units and ticks. All limits and data use original (unscaled) units.
|
|
1547
|
+
function multiples(lo: number, hi: number, step: number): number[] {
|
|
1548
|
+
check(
|
|
1549
|
+
finite(step) &&
|
|
1550
|
+
step > 0 &&
|
|
1551
|
+
finite((hi - lo) / step) &&
|
|
1552
|
+
(hi - lo) / step <= LIMITS.ticks,
|
|
1553
|
+
"Too many ticks or invalid step.",
|
|
1554
|
+
);
|
|
1555
|
+
const a = Math.ceil(lo / step - 1e-10),
|
|
1556
|
+
b = Math.floor(hi / step + 1e-10);
|
|
1557
|
+
check(
|
|
1558
|
+
Math.abs(a) < 9e15 && Math.abs(b) < 9e15 && b - a <= LIMITS.ticks,
|
|
1559
|
+
"Tick precision limit exceeded.",
|
|
1560
|
+
);
|
|
1561
|
+
return Array.from(
|
|
1562
|
+
{ length: Math.max(0, b - a + 1) },
|
|
1563
|
+
(_, i) => (a + i) * step || 0,
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
function nice(v: number): number {
|
|
1567
|
+
check(finite(v) && v >= 1e-300, "Unsupported numeric axis span.");
|
|
1568
|
+
const p = 10 ** Math.floor(Math.log10(v));
|
|
1569
|
+
for (const m of [1, 2, 5, 10]) if (v <= m * p * (1 + 1e-12)) return m * p;
|
|
1570
|
+
return 10 * p;
|
|
1571
|
+
}
|
|
1572
|
+
function unitFor(v: number, base: 1000 | 1024): Unit {
|
|
1573
|
+
if (!v) return { factor: 1, suffix: "" };
|
|
1574
|
+
let i = Math.floor(Math.log(Math.abs(v)) / Math.log(base) + 1e-12);
|
|
1575
|
+
i = clamp(i, base === 1024 ? 0 : -8, 8);
|
|
1576
|
+
return {
|
|
1577
|
+
factor: base ** i,
|
|
1578
|
+
suffix: (base === 1024
|
|
1579
|
+
? ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi", "Yi"]
|
|
1580
|
+
: [
|
|
1581
|
+
"y",
|
|
1582
|
+
"z",
|
|
1583
|
+
"a",
|
|
1584
|
+
"f",
|
|
1585
|
+
"p",
|
|
1586
|
+
"n",
|
|
1587
|
+
"u",
|
|
1588
|
+
"m",
|
|
1589
|
+
"",
|
|
1590
|
+
"k",
|
|
1591
|
+
"M",
|
|
1592
|
+
"G",
|
|
1593
|
+
"T",
|
|
1594
|
+
"P",
|
|
1595
|
+
"E",
|
|
1596
|
+
"Z",
|
|
1597
|
+
"Y",
|
|
1598
|
+
])[base === 1024 ? i : i + 8],
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
function resolveY(
|
|
1602
|
+
a: Readonly<Required<YAxis>>,
|
|
1603
|
+
loData: number,
|
|
1604
|
+
hiData: number,
|
|
1605
|
+
): YResolution {
|
|
1606
|
+
let lo = a.minimum === null ? Math.min(0, loData) : a.minimum,
|
|
1607
|
+
hi = a.maximum === null ? Math.max(0, hiData) : a.maximum;
|
|
1608
|
+
if (a.maximum === null && hi <= lo)
|
|
1609
|
+
hi = lo + Math.max(Math.abs(lo) * 0.05, 1);
|
|
1610
|
+
if (a.minimum === null && lo >= hi)
|
|
1611
|
+
lo = hi - Math.max(Math.abs(hi) * 0.05, 1);
|
|
1612
|
+
const span = hi - lo;
|
|
1613
|
+
check(finite(span) && span > 0, "Invalid Y span.");
|
|
1614
|
+
const af =
|
|
1615
|
+
a.scaleFactor === null
|
|
1616
|
+
? unitFor(Math.max(Math.abs(lo), Math.abs(hi)), a.base).factor
|
|
1617
|
+
: a.scaleFactor;
|
|
1618
|
+
const step = a.majorStep === null ? nice(span / af / 5) * af : a.majorStep,
|
|
1619
|
+
q = step / a.minorDivisions;
|
|
1620
|
+
check(finite(q) && q > 0, "Invalid Y quantum.");
|
|
1621
|
+
if (a.minimum === null && lo < 0) lo = Math.floor((lo - span * 0.02) / q) * q;
|
|
1622
|
+
if (a.maximum === null) hi = Math.ceil((hi + span * 0.02) / q) * q;
|
|
1623
|
+
check(finite(hi - lo) && hi > lo, "Degenerate Y range.");
|
|
1624
|
+
const major = multiples(lo, hi, step),
|
|
1625
|
+
minor = multiples(lo, hi, q).filter(
|
|
1626
|
+
(v) => Math.abs(v / step - round(v / step)) > 1e-8,
|
|
1627
|
+
);
|
|
1628
|
+
const unit = unitFor(Math.max(Math.abs(lo), Math.abs(hi)), a.base);
|
|
1629
|
+
if (a.scaleFactor !== null) {
|
|
1630
|
+
unit.factor = a.scaleFactor;
|
|
1631
|
+
if (a.suffix === null) unit.suffix = "";
|
|
1632
|
+
}
|
|
1633
|
+
if (a.suffix !== null) unit.suffix = a.suffix;
|
|
1634
|
+
let decimals = a.decimals;
|
|
1635
|
+
if (decimals === null) {
|
|
1636
|
+
const s = step / unit.factor;
|
|
1637
|
+
decimals = 9;
|
|
1638
|
+
for (let d = 0; d < 10; d++)
|
|
1639
|
+
if (
|
|
1640
|
+
Math.abs(s - round(s * 10 ** d) / 10 ** d) <=
|
|
1641
|
+
Math.max(1e-10, Math.abs(s) * 1e-9)
|
|
1642
|
+
) {
|
|
1643
|
+
decimals = d;
|
|
1644
|
+
break;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
return { minimum: lo, maximum: hi, step, major, minor, ...unit, decimals };
|
|
1648
|
+
}
|
|
1649
|
+
/** Format a value using an already validated common unit and decimal precision. */
|
|
1650
|
+
export function formatValue(
|
|
1651
|
+
value: number | null,
|
|
1652
|
+
unit: Unit,
|
|
1653
|
+
decimals: number = 2,
|
|
1654
|
+
missingText: string = "NaN",
|
|
1655
|
+
): string {
|
|
1656
|
+
if (!finite(value)) return missingText;
|
|
1657
|
+
let v = value / unit.factor;
|
|
1658
|
+
if (Math.abs(v) < 0.5 * 10 ** -decimals) v = 0;
|
|
1659
|
+
return v.toFixed(decimals) + (unit.suffix ? " " + unit.suffix : "");
|
|
1660
|
+
}
|
|
1661
|
+
const formatterCache = new Map<string, Intl.DateTimeFormat>();
|
|
1662
|
+
function zoneFormatter(zone: string): Intl.DateTimeFormat {
|
|
1663
|
+
if (formatterCache.has(zone)) return formatterCache.get(zone)!;
|
|
1664
|
+
let f;
|
|
1665
|
+
try {
|
|
1666
|
+
f = new Intl.DateTimeFormat("en-GB-u-ca-gregory-nu-latn", {
|
|
1667
|
+
timeZone: zone,
|
|
1668
|
+
year: "numeric",
|
|
1669
|
+
month: "2-digit",
|
|
1670
|
+
day: "2-digit",
|
|
1671
|
+
hour: "2-digit",
|
|
1672
|
+
minute: "2-digit",
|
|
1673
|
+
second: "2-digit",
|
|
1674
|
+
hourCycle: "h23",
|
|
1675
|
+
});
|
|
1676
|
+
} catch (e) {
|
|
1677
|
+
throw new RangeError("Unsupported time zone: " + zone);
|
|
1678
|
+
}
|
|
1679
|
+
if (formatterCache.size >= 32)
|
|
1680
|
+
formatterCache.delete(formatterCache.keys().next().value!);
|
|
1681
|
+
formatterCache.set(zone, f);
|
|
1682
|
+
return f;
|
|
1683
|
+
}
|
|
1684
|
+
function utcFromParts(
|
|
1685
|
+
y: number,
|
|
1686
|
+
m: number,
|
|
1687
|
+
d: number,
|
|
1688
|
+
h: number = 0,
|
|
1689
|
+
mi: number = 0,
|
|
1690
|
+
s: number = 0,
|
|
1691
|
+
): number {
|
|
1692
|
+
const t = new Date(0);
|
|
1693
|
+
t.setUTCFullYear(y, m - 1, d);
|
|
1694
|
+
t.setUTCHours(h, mi, s, 0);
|
|
1695
|
+
return t.getTime() / 1000;
|
|
1696
|
+
}
|
|
1697
|
+
function wallParts(t: number, zone: string): WallParts {
|
|
1698
|
+
if (zone === "UTC") {
|
|
1699
|
+
const d = new Date(t * 1000);
|
|
1700
|
+
return {
|
|
1701
|
+
year: d.getUTCFullYear(),
|
|
1702
|
+
month: d.getUTCMonth() + 1,
|
|
1703
|
+
day: d.getUTCDate(),
|
|
1704
|
+
hour: d.getUTCHours(),
|
|
1705
|
+
minute: d.getUTCMinutes(),
|
|
1706
|
+
second: d.getUTCSeconds(),
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
const r = {} as WallParts;
|
|
1710
|
+
for (const p of zoneFormatter(zone).formatToParts(new Date(t * 1000)))
|
|
1711
|
+
if (p.type !== "literal") r[p.type as keyof WallParts] = Number(p.value);
|
|
1712
|
+
if (r.hour === 24) r.hour = 0;
|
|
1713
|
+
return r;
|
|
1714
|
+
}
|
|
1715
|
+
function wallEpoch(t: number, zone: string): number {
|
|
1716
|
+
const p = wallParts(t, zone);
|
|
1717
|
+
return (
|
|
1718
|
+
utcFromParts(p.year, p.month, p.day, p.hour, p.minute, p.second) +
|
|
1719
|
+
(t - Math.floor(t))
|
|
1720
|
+
);
|
|
1721
|
+
}
|
|
1722
|
+
function offsetAt(t: number, zone: string): number {
|
|
1723
|
+
return Math.round(wallEpoch(t, zone) - t);
|
|
1724
|
+
}
|
|
1725
|
+
function localCandidates(w: number, zone: string): number[] {
|
|
1726
|
+
if (zone === "UTC") return [w];
|
|
1727
|
+
const offsets = new Set(
|
|
1728
|
+
[-172800, -86400, 0, 86400, 172800].map((d) => offsetAt(w + d, zone)),
|
|
1729
|
+
),
|
|
1730
|
+
out = [];
|
|
1731
|
+
for (const off of offsets) {
|
|
1732
|
+
const t = w - off;
|
|
1733
|
+
if (Math.abs(wallEpoch(t, zone) - w) < 0.001) out.push(t);
|
|
1734
|
+
}
|
|
1735
|
+
return out.sort((a, b) => a - b);
|
|
1736
|
+
}
|
|
1737
|
+
function wallTicks(
|
|
1738
|
+
start: number,
|
|
1739
|
+
end: number,
|
|
1740
|
+
step: number,
|
|
1741
|
+
zone: string,
|
|
1742
|
+
): number[] {
|
|
1743
|
+
if (zone === "UTC") return multiples(start, end, step);
|
|
1744
|
+
const sa = wallEpoch(start, zone),
|
|
1745
|
+
sb = wallEpoch(end, zone),
|
|
1746
|
+
a = Math.floor(Math.min(sa, sb) / step) - 2,
|
|
1747
|
+
b = Math.ceil(Math.max(sa, sb) / step) + 2;
|
|
1748
|
+
check(
|
|
1749
|
+
finite(b - a) &&
|
|
1750
|
+
b - a <= LIMITS.ticks &&
|
|
1751
|
+
Math.abs(a) < 9e15 &&
|
|
1752
|
+
Math.abs(b) < 9e15,
|
|
1753
|
+
"Too many time ticks; increase the interval.",
|
|
1754
|
+
);
|
|
1755
|
+
// One offset set for a tick range; 12-hour probes plus endpoint probes preserve ordinary IANA folds.
|
|
1756
|
+
const offsets = new Set<number>();
|
|
1757
|
+
const probe = Math.max(43200, (end - start) / 4096);
|
|
1758
|
+
for (let t = start - 172800; t <= end + 172800; t += probe)
|
|
1759
|
+
offsets.add(offsetAt(t, zone));
|
|
1760
|
+
offsets.add(offsetAt(end, zone));
|
|
1761
|
+
const out = new Set<number>();
|
|
1762
|
+
for (let i = 0; i <= b - a; i++) {
|
|
1763
|
+
const w = (a + i) * step;
|
|
1764
|
+
for (const off of offsets) {
|
|
1765
|
+
const t = w - off;
|
|
1766
|
+
if (t >= start && t <= end && Math.abs(wallEpoch(t, zone) - w) < 0.001)
|
|
1767
|
+
out.add(t);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
return [...out].sort((a, b) => a - b);
|
|
1771
|
+
}
|
|
1772
|
+
function monthTicks(
|
|
1773
|
+
start: number,
|
|
1774
|
+
end: number,
|
|
1775
|
+
zone: string,
|
|
1776
|
+
stride: number = 1,
|
|
1777
|
+
): number[] {
|
|
1778
|
+
const a = wallParts(start, zone),
|
|
1779
|
+
b = wallParts(end, zone),
|
|
1780
|
+
first = Math.floor((a.year * 12 + a.month - 1) / stride) * stride,
|
|
1781
|
+
last = b.year * 12 + b.month - 1 + stride;
|
|
1782
|
+
check((last - first) / stride <= LIMITS.ticks, "Too many calendar ticks.");
|
|
1783
|
+
const out = [];
|
|
1784
|
+
for (let i = first; i <= last; i += stride) {
|
|
1785
|
+
const y = Math.floor(i / 12),
|
|
1786
|
+
m = (i % 12) + 1;
|
|
1787
|
+
if (y < 1 || y > 9999) continue;
|
|
1788
|
+
for (const t of localCandidates(utcFromParts(y, m, 1), zone))
|
|
1789
|
+
if (t >= start && t <= end) out.push(t);
|
|
1790
|
+
}
|
|
1791
|
+
return out.sort((a, b) => a - b);
|
|
1792
|
+
}
|
|
1793
|
+
const MONTHS = [
|
|
1794
|
+
"Jan",
|
|
1795
|
+
"Feb",
|
|
1796
|
+
"Mar",
|
|
1797
|
+
"Apr",
|
|
1798
|
+
"May",
|
|
1799
|
+
"Jun",
|
|
1800
|
+
"Jul",
|
|
1801
|
+
"Aug",
|
|
1802
|
+
"Sep",
|
|
1803
|
+
"Oct",
|
|
1804
|
+
"Nov",
|
|
1805
|
+
"Dec",
|
|
1806
|
+
];
|
|
1807
|
+
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
1808
|
+
const pad = (v: number, n: number = 2): string => String(v).padStart(n, "0");
|
|
1809
|
+
/** Format a timestamp with fixed English calendar names and IANA timezone rules. */
|
|
1810
|
+
export function formatTime(
|
|
1811
|
+
t: Timestamp,
|
|
1812
|
+
zone: string = "UTC",
|
|
1813
|
+
format: string = "%H:%M",
|
|
1814
|
+
): string {
|
|
1815
|
+
t = epoch(t);
|
|
1816
|
+
const p = wallParts(t, zone),
|
|
1817
|
+
d = new Date(utcFromParts(p.year, p.month, p.day) * 1000),
|
|
1818
|
+
off = offsetAt(t, zone),
|
|
1819
|
+
day = d.getUTCDay();
|
|
1820
|
+
const fields: Record<string, string> = {
|
|
1821
|
+
H: pad(p.hour),
|
|
1822
|
+
I: pad(p.hour % 12 || 12),
|
|
1823
|
+
M: pad(p.minute),
|
|
1824
|
+
S: pad(p.second),
|
|
1825
|
+
d: pad(p.day),
|
|
1826
|
+
e: String(p.day).padStart(2, " "),
|
|
1827
|
+
m: pad(p.month),
|
|
1828
|
+
Y: pad(p.year, 4),
|
|
1829
|
+
y: pad(p.year % 100),
|
|
1830
|
+
a: DAYS[day],
|
|
1831
|
+
A: [
|
|
1832
|
+
"Sunday",
|
|
1833
|
+
"Monday",
|
|
1834
|
+
"Tuesday",
|
|
1835
|
+
"Wednesday",
|
|
1836
|
+
"Thursday",
|
|
1837
|
+
"Friday",
|
|
1838
|
+
"Saturday",
|
|
1839
|
+
][day],
|
|
1840
|
+
b: MONTHS[p.month - 1],
|
|
1841
|
+
h: MONTHS[p.month - 1],
|
|
1842
|
+
B: [
|
|
1843
|
+
"January",
|
|
1844
|
+
"February",
|
|
1845
|
+
"March",
|
|
1846
|
+
"April",
|
|
1847
|
+
"May",
|
|
1848
|
+
"June",
|
|
1849
|
+
"July",
|
|
1850
|
+
"August",
|
|
1851
|
+
"September",
|
|
1852
|
+
"October",
|
|
1853
|
+
"November",
|
|
1854
|
+
"December",
|
|
1855
|
+
][p.month - 1],
|
|
1856
|
+
p: p.hour < 12 ? "AM" : "PM",
|
|
1857
|
+
w: String(day),
|
|
1858
|
+
u: String(day || 7),
|
|
1859
|
+
j: pad(
|
|
1860
|
+
Math.floor((d.getTime() / 1000 - utcFromParts(p.year, 1, 1)) / 86400) + 1,
|
|
1861
|
+
3,
|
|
1862
|
+
),
|
|
1863
|
+
z:
|
|
1864
|
+
(off < 0 ? "-" : "+") +
|
|
1865
|
+
pad(Math.floor(Math.abs(off) / 3600)) +
|
|
1866
|
+
pad(Math.floor((Math.abs(off) % 3600) / 60)),
|
|
1867
|
+
Z: zone,
|
|
1868
|
+
"%": "%",
|
|
1869
|
+
};
|
|
1870
|
+
fields.F = fields.Y + "-" + fields.m + "-" + fields.d;
|
|
1871
|
+
fields.T = fields.H + ":" + fields.M + ":" + fields.S;
|
|
1872
|
+
fields.R = fields.H + ":" + fields.M;
|
|
1873
|
+
let out = "";
|
|
1874
|
+
for (let i = 0; i < format.length; i++) {
|
|
1875
|
+
if (format[i] !== "%") {
|
|
1876
|
+
out += format[i];
|
|
1877
|
+
continue;
|
|
1878
|
+
}
|
|
1879
|
+
const k = format[++i];
|
|
1880
|
+
check(own(fields, k), "Unsupported date directive: %" + (k || ""));
|
|
1881
|
+
out += fields[k];
|
|
1882
|
+
}
|
|
1883
|
+
return out;
|
|
1884
|
+
}
|
|
1885
|
+
function resolveX(
|
|
1886
|
+
a: DeepReadonly<ResolvedTimeAxis>,
|
|
1887
|
+
start: number,
|
|
1888
|
+
end: number,
|
|
1889
|
+
width: number,
|
|
1890
|
+
): XResolution {
|
|
1891
|
+
const span = end - start,
|
|
1892
|
+
zone = a.timezone,
|
|
1893
|
+
mode =
|
|
1894
|
+
a.mode === "auto"
|
|
1895
|
+
? span <= 172800
|
|
1896
|
+
? "daily"
|
|
1897
|
+
: span <= 864000
|
|
1898
|
+
? "weekly"
|
|
1899
|
+
: span <= 5356800
|
|
1900
|
+
? "monthly"
|
|
1901
|
+
: "yearly"
|
|
1902
|
+
: a.mode;
|
|
1903
|
+
let minor: number[] = [],
|
|
1904
|
+
major: number[] = [],
|
|
1905
|
+
labelTimes: number[] = [],
|
|
1906
|
+
format = "%H:%M";
|
|
1907
|
+
if (mode === "yearly") {
|
|
1908
|
+
const stride =
|
|
1909
|
+
span > 550 * 86400 ? Math.max(1, Math.ceil(span / (365.25 * 86400))) : 1;
|
|
1910
|
+
format = stride > 1 ? "%b %Y" : "%b";
|
|
1911
|
+
if (a.majorTicks === null || a.ticks === null) {
|
|
1912
|
+
major = monthTicks(start, end, zone, stride);
|
|
1913
|
+
labelTimes = major;
|
|
1914
|
+
}
|
|
1915
|
+
if (a.minorTicks === null) minor = monthTicks(start, end, zone, 1);
|
|
1916
|
+
if (a.majorTicks === null && a.majorSeconds !== null)
|
|
1917
|
+
major = wallTicks(start, end, a.majorSeconds, zone);
|
|
1918
|
+
if (a.minorTicks === null && a.minorSeconds !== null)
|
|
1919
|
+
minor = wallTicks(start, end, a.minorSeconds, zone);
|
|
1920
|
+
if (a.ticks === null && a.labelSeconds !== null)
|
|
1921
|
+
labelTimes = wallTicks(start, end, a.labelSeconds, zone);
|
|
1922
|
+
} else {
|
|
1923
|
+
let mi = 1800,
|
|
1924
|
+
ma = 7200,
|
|
1925
|
+
ls = 7200;
|
|
1926
|
+
if (mode === "weekly") {
|
|
1927
|
+
mi = 21600;
|
|
1928
|
+
ma = ls = 86400;
|
|
1929
|
+
format = "%d";
|
|
1930
|
+
}
|
|
1931
|
+
if (mode === "monthly") {
|
|
1932
|
+
mi = 86400;
|
|
1933
|
+
ma = ls = 604800;
|
|
1934
|
+
format = "%d %b";
|
|
1935
|
+
}
|
|
1936
|
+
if (a.mode === "auto" && span < 43200) {
|
|
1937
|
+
const target = span / Math.max(2, Math.floor(width / 48));
|
|
1938
|
+
ls =
|
|
1939
|
+
[1, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200].find(
|
|
1940
|
+
(v) => v >= target,
|
|
1941
|
+
) || 7200;
|
|
1942
|
+
mi = Math.max(1, ls / 4);
|
|
1943
|
+
ma = ls;
|
|
1944
|
+
if (ls < 60) format = "%H:%M:%S";
|
|
1945
|
+
}
|
|
1946
|
+
mi = Math.max(mi, span / 2000);
|
|
1947
|
+
if (a.minorSeconds !== null) mi = a.minorSeconds;
|
|
1948
|
+
if (a.majorSeconds !== null) ma = a.majorSeconds;
|
|
1949
|
+
if (a.labelSeconds !== null) ls = a.labelSeconds;
|
|
1950
|
+
if (a.minorTicks === null) minor = wallTicks(start, end, mi, zone);
|
|
1951
|
+
if (a.majorTicks === null) major = wallTicks(start, end, ma, zone);
|
|
1952
|
+
if (a.ticks === null) labelTimes = wallTicks(start, end, ls, zone);
|
|
1953
|
+
}
|
|
1954
|
+
if (a.minorTicks !== null)
|
|
1955
|
+
minor = a.minorTicks.filter((v) => v >= start && v <= end);
|
|
1956
|
+
if (a.majorTicks !== null)
|
|
1957
|
+
major = a.majorTicks.filter((v) => v >= start && v <= end);
|
|
1958
|
+
if (a.labelFormat !== null) format = a.labelFormat;
|
|
1959
|
+
let labels: ResolvedTick[] = [];
|
|
1960
|
+
if (a.ticks !== null)
|
|
1961
|
+
labels = a.ticks.filter((v) => v.time >= start && v.time <= end);
|
|
1962
|
+
else if (
|
|
1963
|
+
mode === "weekly" &&
|
|
1964
|
+
a.labelSeconds === null &&
|
|
1965
|
+
a.labelOffsetSeconds === 0
|
|
1966
|
+
) {
|
|
1967
|
+
for (const t of wallTicks(start - 172800, end, 86400, zone)) {
|
|
1968
|
+
const w = wallEpoch(t, zone),
|
|
1969
|
+
next = localCandidates(w + 86400, zone);
|
|
1970
|
+
if (t < start || !next.length || next[next.length - 1] > end) continue;
|
|
1971
|
+
for (const noon of localCandidates(w + 43200, zone))
|
|
1972
|
+
if (noon >= start && noon <= end)
|
|
1973
|
+
labels.push({ time: noon, label: formatTime(t, zone, format) });
|
|
1974
|
+
}
|
|
1975
|
+
} else
|
|
1976
|
+
for (const t of labelTimes) {
|
|
1977
|
+
const pos = t + a.labelOffsetSeconds;
|
|
1978
|
+
if (pos >= start && pos <= end)
|
|
1979
|
+
labels.push({ time: pos, label: formatTime(t, zone, format) });
|
|
1980
|
+
}
|
|
1981
|
+
labels.sort((a, b) => a.time - b.time);
|
|
1982
|
+
if (a.ticks === null && mode === "daily" && span <= 95040) {
|
|
1983
|
+
const seen = new Map<string, Set<number>>();
|
|
1984
|
+
for (const t of labels) {
|
|
1985
|
+
if (!seen.has(t.label)) seen.set(t.label, new Set());
|
|
1986
|
+
seen.get(t.label)!.add(offsetAt(t.time, zone));
|
|
1987
|
+
}
|
|
1988
|
+
labels = labels.map((t) =>
|
|
1989
|
+
seen.get(t.label)!.size > 1
|
|
1990
|
+
? {
|
|
1991
|
+
time: t.time,
|
|
1992
|
+
label: t.label + " " + formatTime(t.time, zone, "%z"),
|
|
1993
|
+
}
|
|
1994
|
+
: t,
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
const majorSet = new Set(major);
|
|
1998
|
+
minor = minor.filter((v) => !majorSet.has(v));
|
|
1999
|
+
return { minor, major, labels, mode };
|
|
2000
|
+
}
|
|
2001
|
+
function timeRange(c: ChartConfig): [number, number] {
|
|
2002
|
+
let start = Infinity,
|
|
2003
|
+
end = -Infinity;
|
|
2004
|
+
for (const s of c.series)
|
|
2005
|
+
if (s.timestamps.length) {
|
|
2006
|
+
start = Math.min(start, s.timestamps[0]);
|
|
2007
|
+
end = Math.max(end, s.timestamps[s.timestamps.length - 1]);
|
|
2008
|
+
}
|
|
2009
|
+
if (c.timeAxis.start !== null) start = c.timeAxis.start;
|
|
2010
|
+
if (c.timeAxis.end !== null) end = c.timeAxis.end;
|
|
2011
|
+
if (start === end && c.timeAxis.start === null && c.timeAxis.end === null) {
|
|
2012
|
+
start -= 150;
|
|
2013
|
+
end += 150;
|
|
2014
|
+
}
|
|
2015
|
+
epoch(start);
|
|
2016
|
+
epoch(end);
|
|
2017
|
+
check(
|
|
2018
|
+
end > start,
|
|
2019
|
+
"Empty data needs explicit start and end; range must be nonzero.",
|
|
2020
|
+
);
|
|
2021
|
+
return [start, end];
|
|
2022
|
+
}
|
|
2023
|
+
// Original 5×7 pixel-letter descriptions. Not extracted from a font, no font asset shipped.
|
|
2024
|
+
// This optional ASCII renderer trades typographic likeness for host-independent pixels.
|
|
2025
|
+
const PIXEL_GLYPHS: Readonly<Record<string, readonly string[]>> = {
|
|
2026
|
+
" ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000"],
|
|
2027
|
+
"0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
|
|
2028
|
+
"1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
|
|
2029
|
+
"2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111"],
|
|
2030
|
+
"3": ["11110", "00001", "00001", "01110", "00001", "00001", "11110"],
|
|
2031
|
+
"4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
|
|
2032
|
+
"5": ["11111", "10000", "10000", "11110", "00001", "00001", "11110"],
|
|
2033
|
+
"6": ["01110", "10000", "10000", "11110", "10001", "10001", "01110"],
|
|
2034
|
+
"7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
|
|
2035
|
+
"8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
|
|
2036
|
+
"9": ["01110", "10001", "10001", "01111", "00001", "00001", "01110"],
|
|
2037
|
+
A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
|
|
2038
|
+
B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"],
|
|
2039
|
+
C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"],
|
|
2040
|
+
D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
|
|
2041
|
+
E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
|
|
2042
|
+
F: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"],
|
|
2043
|
+
G: ["01110", "10001", "10000", "10111", "10001", "10001", "01111"],
|
|
2044
|
+
H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"],
|
|
2045
|
+
I: ["01110", "00100", "00100", "00100", "00100", "00100", "01110"],
|
|
2046
|
+
J: ["00111", "00010", "00010", "00010", "00010", "10010", "01100"],
|
|
2047
|
+
K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"],
|
|
2048
|
+
L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"],
|
|
2049
|
+
M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"],
|
|
2050
|
+
N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
|
|
2051
|
+
O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
|
|
2052
|
+
P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
|
|
2053
|
+
Q: ["01110", "10001", "10001", "10001", "10101", "10010", "01101"],
|
|
2054
|
+
R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
|
|
2055
|
+
S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
|
|
2056
|
+
T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
|
|
2057
|
+
U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
|
|
2058
|
+
V: ["10001", "10001", "10001", "10001", "10001", "01010", "00100"],
|
|
2059
|
+
W: ["10001", "10001", "10001", "10101", "10101", "11011", "10001"],
|
|
2060
|
+
X: ["10001", "10001", "01010", "00100", "01010", "10001", "10001"],
|
|
2061
|
+
Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"],
|
|
2062
|
+
Z: ["11111", "00001", "00010", "00100", "01000", "10000", "11111"],
|
|
2063
|
+
a: ["00000", "00000", "01110", "00001", "01111", "10001", "01111"],
|
|
2064
|
+
b: ["10000", "10000", "10110", "11001", "10001", "10001", "11110"],
|
|
2065
|
+
c: ["00000", "00000", "01111", "10000", "10000", "10000", "01111"],
|
|
2066
|
+
d: ["00001", "00001", "01101", "10011", "10001", "10001", "01111"],
|
|
2067
|
+
e: ["00000", "00000", "01110", "10001", "11111", "10000", "01110"],
|
|
2068
|
+
f: ["00110", "01001", "01000", "11100", "01000", "01000", "01000"],
|
|
2069
|
+
g: ["00000", "01111", "10001", "10001", "01111", "00001", "01110"],
|
|
2070
|
+
h: ["10000", "10000", "10110", "11001", "10001", "10001", "10001"],
|
|
2071
|
+
i: ["00100", "00000", "01100", "00100", "00100", "00100", "01110"],
|
|
2072
|
+
j: ["00010", "00000", "00110", "00010", "00010", "10010", "01100"],
|
|
2073
|
+
k: ["10000", "10000", "10010", "10100", "11000", "10100", "10010"],
|
|
2074
|
+
l: ["01100", "00100", "00100", "00100", "00100", "00100", "01110"],
|
|
2075
|
+
m: ["00000", "00000", "11010", "10101", "10101", "10101", "10101"],
|
|
2076
|
+
n: ["00000", "00000", "10110", "11001", "10001", "10001", "10001"],
|
|
2077
|
+
o: ["00000", "00000", "01110", "10001", "10001", "10001", "01110"],
|
|
2078
|
+
p: ["00000", "00000", "11110", "10001", "11110", "10000", "10000"],
|
|
2079
|
+
q: ["00000", "00000", "01111", "10001", "01111", "00001", "00001"],
|
|
2080
|
+
r: ["00000", "00000", "10111", "11000", "10000", "10000", "10000"],
|
|
2081
|
+
s: ["00000", "00000", "01111", "10000", "01110", "00001", "11110"],
|
|
2082
|
+
t: ["01000", "01000", "11100", "01000", "01000", "01001", "00110"],
|
|
2083
|
+
u: ["00000", "00000", "10001", "10001", "10001", "10011", "01101"],
|
|
2084
|
+
v: ["00000", "00000", "10001", "10001", "10001", "01010", "00100"],
|
|
2085
|
+
w: ["00000", "00000", "10001", "10001", "10101", "10101", "01010"],
|
|
2086
|
+
x: ["00000", "00000", "10001", "01010", "00100", "01010", "10001"],
|
|
2087
|
+
y: ["00000", "00000", "10001", "10001", "01111", "00001", "01110"],
|
|
2088
|
+
z: ["00000", "00000", "11111", "00010", "00100", "01000", "11111"],
|
|
2089
|
+
"-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"],
|
|
2090
|
+
_: ["00000", "00000", "00000", "00000", "00000", "00000", "11111"],
|
|
2091
|
+
":": ["00000", "00100", "00100", "00000", "00100", "00100", "00000"],
|
|
2092
|
+
".": ["00000", "00000", "00000", "00000", "00000", "00100", "00100"],
|
|
2093
|
+
",": ["00000", "00000", "00000", "00000", "00100", "00100", "01000"],
|
|
2094
|
+
"/": ["00001", "00010", "00010", "00100", "01000", "01000", "10000"],
|
|
2095
|
+
"\\": ["10000", "01000", "01000", "00100", "00010", "00010", "00001"],
|
|
2096
|
+
"(": ["00010", "00100", "01000", "01000", "01000", "00100", "00010"],
|
|
2097
|
+
")": ["01000", "00100", "00010", "00010", "00010", "00100", "01000"],
|
|
2098
|
+
"[": ["01110", "01000", "01000", "01000", "01000", "01000", "01110"],
|
|
2099
|
+
"]": ["01110", "00010", "00010", "00010", "00010", "00010", "01110"],
|
|
2100
|
+
"+": ["00000", "00100", "00100", "11111", "00100", "00100", "00000"],
|
|
2101
|
+
"=": ["00000", "00000", "11111", "00000", "11111", "00000", "00000"],
|
|
2102
|
+
"%": ["11001", "11010", "00010", "00100", "01000", "01011", "10011"],
|
|
2103
|
+
"?": ["01110", "10001", "00001", "00010", "00100", "00000", "00100"],
|
|
2104
|
+
"!": ["00100", "00100", "00100", "00100", "00100", "00000", "00100"],
|
|
2105
|
+
"#": ["01010", "01010", "11111", "01010", "11111", "01010", "01010"],
|
|
2106
|
+
"*": ["00000", "10101", "01110", "11111", "01110", "10101", "00000"],
|
|
2107
|
+
"<": ["00010", "00100", "01000", "10000", "01000", "00100", "00010"],
|
|
2108
|
+
">": ["01000", "00100", "00010", "00001", "00010", "00100", "01000"],
|
|
2109
|
+
"|": ["00100", "00100", "00100", "00100", "00100", "00100", "00100"],
|
|
2110
|
+
'"': ["01010", "01010", "00000", "00000", "00000", "00000", "00000"],
|
|
2111
|
+
"'": ["00100", "00100", "00000", "00000", "00000", "00000", "00000"],
|
|
2112
|
+
";": ["00000", "00100", "00100", "00000", "00100", "00100", "01000"],
|
|
2113
|
+
"@": ["01110", "10001", "10111", "10101", "10111", "10000", "01111"],
|
|
2114
|
+
$: ["00100", "01111", "10100", "01110", "00101", "11110", "00100"],
|
|
2115
|
+
"&": ["01100", "10010", "10100", "01000", "10101", "10010", "01101"],
|
|
2116
|
+
"^": ["00100", "01010", "10001", "00000", "00000", "00000", "00000"],
|
|
2117
|
+
"`": ["01000", "00100", "00000", "00000", "00000", "00000", "00000"],
|
|
2118
|
+
"~": ["00000", "00000", "01001", "10110", "00000", "00000", "00000"],
|
|
2119
|
+
"{": ["00011", "00100", "00100", "01000", "00100", "00100", "00011"],
|
|
2120
|
+
"}": ["11000", "00100", "00100", "00010", "00100", "00100", "11000"],
|
|
2121
|
+
};
|
|
2122
|
+
function createCanvas(w: number, h: number): Canvas {
|
|
2123
|
+
let canvas;
|
|
2124
|
+
if (typeof document !== "undefined" && document.createElement)
|
|
2125
|
+
canvas = document.createElement("canvas");
|
|
2126
|
+
else if (typeof OffscreenCanvas !== "undefined")
|
|
2127
|
+
canvas = new OffscreenCanvas(w, h);
|
|
2128
|
+
else
|
|
2129
|
+
throw new Error(
|
|
2130
|
+
'System fonts need Canvas 2D. Use fonts: { mode: "bitmap" } in a DOM-free runtime.',
|
|
2131
|
+
);
|
|
2132
|
+
canvas.width = w;
|
|
2133
|
+
canvas.height = h;
|
|
2134
|
+
return canvas;
|
|
2135
|
+
}
|
|
2136
|
+
function wide(ch: string): boolean {
|
|
2137
|
+
const n = ch.codePointAt(0)!;
|
|
2138
|
+
return (
|
|
2139
|
+
n >= 0x1100 &&
|
|
2140
|
+
(n <= 0x115f ||
|
|
2141
|
+
(n >= 0x2e80 && n <= 0xa4cf) ||
|
|
2142
|
+
(n >= 0xac00 && n <= 0xd7a3) ||
|
|
2143
|
+
(n >= 0xf900 && n <= 0xfaff) ||
|
|
2144
|
+
(n >= 0xff00 && n <= 0xff60) ||
|
|
2145
|
+
n >= 0x1f300)
|
|
2146
|
+
);
|
|
2147
|
+
}
|
|
2148
|
+
class Fonts {
|
|
2149
|
+
readonly config: Readonly<Required<FontOptions>>;
|
|
2150
|
+
readonly theme: DeepReadonly<ResolvedTheme>;
|
|
2151
|
+
private readonly cache: Map<string, Surface>;
|
|
2152
|
+
private canvas!: Canvas;
|
|
2153
|
+
private ctx!: Context2D;
|
|
2154
|
+
constructor(
|
|
2155
|
+
config: Readonly<Required<FontOptions>>,
|
|
2156
|
+
theme: DeepReadonly<ResolvedTheme>,
|
|
2157
|
+
) {
|
|
2158
|
+
this.config = config;
|
|
2159
|
+
this.theme = theme;
|
|
2160
|
+
this.cache = new Map();
|
|
2161
|
+
if (config.mode === "system") {
|
|
2162
|
+
this.canvas = createCanvas(8, 8);
|
|
2163
|
+
const ctx = this.canvas.getContext("2d", {
|
|
2164
|
+
willReadFrequently: true,
|
|
2165
|
+
}) as Context2D | null;
|
|
2166
|
+
check(ctx, "Canvas 2D is unavailable.");
|
|
2167
|
+
this.ctx = ctx;
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
size(role: FontRole, scale: number = 1): number {
|
|
2171
|
+
return this.theme[`${role}Size`] * scale;
|
|
2172
|
+
}
|
|
2173
|
+
family(role: FontRole): string {
|
|
2174
|
+
return (
|
|
2175
|
+
(role === "title" || role === "unit" || role === "caption"
|
|
2176
|
+
? this.config[`${role}Family`]
|
|
2177
|
+
: null) || this.config.family
|
|
2178
|
+
);
|
|
2179
|
+
}
|
|
2180
|
+
setup(role: FontRole, size: number): void {
|
|
2181
|
+
this.ctx.font =
|
|
2182
|
+
(role === "caption" ? "bold " : "") + size + "px " + this.family(role);
|
|
2183
|
+
this.ctx.textBaseline = "alphabetic";
|
|
2184
|
+
this.ctx.fillStyle = "#000000";
|
|
2185
|
+
if ("fontKerning" in this.ctx) this.ctx.fontKerning = "none";
|
|
2186
|
+
}
|
|
2187
|
+
width(
|
|
2188
|
+
value: string,
|
|
2189
|
+
role: FontRole,
|
|
2190
|
+
advance: number = 0,
|
|
2191
|
+
scale: number = 1,
|
|
2192
|
+
): number {
|
|
2193
|
+
if (advance > 0)
|
|
2194
|
+
return [...value].reduce((n, ch) => n + advance * (wide(ch) ? 2 : 1), 0);
|
|
2195
|
+
if (this.config.mode === "bitmap")
|
|
2196
|
+
return [...value].length * this.size(role, scale) * 0.6;
|
|
2197
|
+
this.setup(role, this.size(role, scale));
|
|
2198
|
+
return this.ctx.measureText(value).width;
|
|
2199
|
+
}
|
|
2200
|
+
fit(
|
|
2201
|
+
value: string,
|
|
2202
|
+
maxWidth: number,
|
|
2203
|
+
role: FontRole,
|
|
2204
|
+
advance: number,
|
|
2205
|
+
scale: number = 1,
|
|
2206
|
+
): string {
|
|
2207
|
+
if (this.width(value, role, advance, scale) <= maxWidth) return value;
|
|
2208
|
+
const end = "...";
|
|
2209
|
+
if (this.width(end, role, advance, scale) > maxWidth) return "";
|
|
2210
|
+
const a = [...value];
|
|
2211
|
+
while (
|
|
2212
|
+
a.length &&
|
|
2213
|
+
this.width(a.join("") + end, role, advance, scale) > maxWidth
|
|
2214
|
+
)
|
|
2215
|
+
a.pop();
|
|
2216
|
+
return a.join("") + end;
|
|
2217
|
+
}
|
|
2218
|
+
raster(
|
|
2219
|
+
value: string,
|
|
2220
|
+
role: FontRole,
|
|
2221
|
+
advance: number = 0,
|
|
2222
|
+
scale: number = 1,
|
|
2223
|
+
): Surface {
|
|
2224
|
+
const key = JSON.stringify([value, role, advance, scale]);
|
|
2225
|
+
if (this.cache.has(key)) return this.cache.get(key)!;
|
|
2226
|
+
const size = this.size(role, scale),
|
|
2227
|
+
w = Math.max(1, Math.ceil(this.width(value, role, advance, scale)) + 4);
|
|
2228
|
+
let out;
|
|
2229
|
+
check(
|
|
2230
|
+
w <= 32768 && w * size < LIMITS.pixels,
|
|
2231
|
+
"Text allocation limit exceeded.",
|
|
2232
|
+
);
|
|
2233
|
+
if (this.config.mode === "bitmap") {
|
|
2234
|
+
const h = Math.max(1, round(size * 0.72));
|
|
2235
|
+
out = new Surface(w, h + 2);
|
|
2236
|
+
let x = 1;
|
|
2237
|
+
for (const ch of value) {
|
|
2238
|
+
let rows = PIXEL_GLYPHS[ch];
|
|
2239
|
+
if (!rows) {
|
|
2240
|
+
check(
|
|
2241
|
+
!this.config.strictGlyphs,
|
|
2242
|
+
"Bitmap text supports printable ASCII only: " + ch,
|
|
2243
|
+
);
|
|
2244
|
+
rows = PIXEL_GLYPHS["?"];
|
|
2245
|
+
}
|
|
2246
|
+
const cell = advance > 0 ? advance : size * 0.6,
|
|
2247
|
+
gw = Math.max(1, Math.min(round(size * 0.5), Math.floor(cell) - 1));
|
|
2248
|
+
for (let yy = 0; yy < h; yy++)
|
|
2249
|
+
for (let xx = 0; xx < gw; xx++)
|
|
2250
|
+
if (
|
|
2251
|
+
rows[Math.min(6, Math.floor((yy / h) * 7))][
|
|
2252
|
+
Math.min(4, Math.floor((xx / gw) * 5))
|
|
2253
|
+
] === "1"
|
|
2254
|
+
)
|
|
2255
|
+
out.pixel(round(x) + xx, yy + 1, [0, 0, 0, 255]);
|
|
2256
|
+
x += cell * (wide(ch) ? 2 : 1);
|
|
2257
|
+
}
|
|
2258
|
+
} else {
|
|
2259
|
+
this.setup(role, size);
|
|
2260
|
+
const metric = this.ctx.measureText(value || "0");
|
|
2261
|
+
const ascent = Math.ceil(metric.actualBoundingBoxAscent || size * 0.8),
|
|
2262
|
+
descent = Math.ceil(metric.actualBoundingBoxDescent || 0),
|
|
2263
|
+
h = Math.max(1, ascent + descent) + 2;
|
|
2264
|
+
this.canvas.width = w;
|
|
2265
|
+
this.canvas.height = h;
|
|
2266
|
+
this.setup(role, size);
|
|
2267
|
+
let x = 1;
|
|
2268
|
+
for (const ch of value) {
|
|
2269
|
+
this.ctx.fillText(ch, round(x), 1 + ascent);
|
|
2270
|
+
x +=
|
|
2271
|
+
advance > 0
|
|
2272
|
+
? advance * (wide(ch) ? 2 : 1)
|
|
2273
|
+
: this.ctx.measureText(ch).width;
|
|
2274
|
+
}
|
|
2275
|
+
const d = this.ctx.getImageData(0, 0, w, h);
|
|
2276
|
+
out = new Surface(w, h);
|
|
2277
|
+
out.data.set(d.data);
|
|
2278
|
+
}
|
|
2279
|
+
if (this.cache.size >= 512)
|
|
2280
|
+
this.cache.delete(this.cache.keys().next().value!);
|
|
2281
|
+
this.cache.set(key, out);
|
|
2282
|
+
return out;
|
|
2283
|
+
}
|
|
2284
|
+
draw(
|
|
2285
|
+
im: Surface,
|
|
2286
|
+
x: number,
|
|
2287
|
+
y: number,
|
|
2288
|
+
value: string,
|
|
2289
|
+
role: FontRole,
|
|
2290
|
+
fill: Readonly<RGBA>,
|
|
2291
|
+
advance: number = 0,
|
|
2292
|
+
align: "left" | "center" | "right" = "left",
|
|
2293
|
+
centerY: boolean = false,
|
|
2294
|
+
scale: number = 1,
|
|
2295
|
+
): void {
|
|
2296
|
+
if (!value) return;
|
|
2297
|
+
const r = this.raster(value, role, advance, scale),
|
|
2298
|
+
width = this.width(value, role, advance, scale);
|
|
2299
|
+
if (align === "center") x -= width / 2;
|
|
2300
|
+
else if (align === "right") x -= width;
|
|
2301
|
+
if (centerY) y -= (r.height - 2) / 2;
|
|
2302
|
+
const xx = round(x) - 1,
|
|
2303
|
+
yy = round(y) - 1,
|
|
2304
|
+
p: RGBA = [fill[0], fill[1], fill[2], 0];
|
|
2305
|
+
for (let sy = 0; sy < r.height; sy++)
|
|
2306
|
+
for (let sx = 0; sx < r.width; sx++) {
|
|
2307
|
+
const a = r.data[(sy * r.width + sx) * 4 + 3];
|
|
2308
|
+
if (a) {
|
|
2309
|
+
p[3] = round((a * fill[3]) / 255);
|
|
2310
|
+
im.pixel(xx + sx, yy + sy, p, true);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
rotated(
|
|
2315
|
+
value: string,
|
|
2316
|
+
role: FontRole,
|
|
2317
|
+
fill: Readonly<RGBA>,
|
|
2318
|
+
clockwise: boolean = false,
|
|
2319
|
+
): Surface {
|
|
2320
|
+
const r = this.raster(value, role),
|
|
2321
|
+
out = new Surface(r.height, r.width),
|
|
2322
|
+
p: RGBA = [fill[0], fill[1], fill[2], 0];
|
|
2323
|
+
for (let y = 0; y < r.height; y++)
|
|
2324
|
+
for (let x = 0; x < r.width; x++) {
|
|
2325
|
+
p[3] = round((r.data[(y * r.width + x) * 4 + 3] * fill[3]) / 255);
|
|
2326
|
+
if (p[3])
|
|
2327
|
+
out.pixel(
|
|
2328
|
+
clockwise ? r.height - 1 - y : y,
|
|
2329
|
+
clockwise ? x : r.width - 1 - x,
|
|
2330
|
+
p,
|
|
2331
|
+
);
|
|
2332
|
+
}
|
|
2333
|
+
return out;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
function renderChart(c: ChartConfig): RenderResult {
|
|
2337
|
+
const l = c.layout,
|
|
2338
|
+
t = c.theme,
|
|
2339
|
+
[width, height] = dimensions(c),
|
|
2340
|
+
left = l.left,
|
|
2341
|
+
top = l.top,
|
|
2342
|
+
right = l.width - l.right,
|
|
2343
|
+
bottom = l.top + l.plotHeight,
|
|
2344
|
+
pw = right - left,
|
|
2345
|
+
ph = bottom - top;
|
|
2346
|
+
const [start, end] = timeRange(c);
|
|
2347
|
+
const runs = c.series.map((s) => visibleRuns(s, start, end));
|
|
2348
|
+
let loData = Infinity,
|
|
2349
|
+
hiData = -Infinity;
|
|
2350
|
+
for (let i = 0; i < runs.length; i++) {
|
|
2351
|
+
for (const run of runs[i])
|
|
2352
|
+
for (const p of run) {
|
|
2353
|
+
check(finite(p[1]), "Interpolated value overflow.");
|
|
2354
|
+
loData = Math.min(loData, p[1]);
|
|
2355
|
+
hiData = Math.max(hiData, p[1]);
|
|
2356
|
+
}
|
|
2357
|
+
if (c.series[i].kind === "area") {
|
|
2358
|
+
loData = Math.min(loData, c.series[i].baseline);
|
|
2359
|
+
hiData = Math.max(hiData, c.series[i].baseline);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
if (loData === Infinity) loData = hiData = 0;
|
|
2363
|
+
const ys = resolveY(c.yAxis, loData, hiData),
|
|
2364
|
+
xs = resolveX(c.timeAxis, start, end, pw),
|
|
2365
|
+
fonts = new Fonts(c.fonts, t),
|
|
2366
|
+
im = new Surface(width, height, t.background);
|
|
2367
|
+
im.rect(left, top, right + 1, bottom + 1, t.canvas);
|
|
2368
|
+
const xx = (v: number): number => ((v - start) / (end - start)) * pw,
|
|
2369
|
+
yy = (v: number): number =>
|
|
2370
|
+
ph *
|
|
2371
|
+
(1 -
|
|
2372
|
+
(v / (ys.maximum - ys.minimum) -
|
|
2373
|
+
ys.minimum / (ys.maximum - ys.minimum)));
|
|
2374
|
+
const projected = runs.map((rr) =>
|
|
2375
|
+
rr.map((r) =>
|
|
2376
|
+
decimate(r, start, end, pw).map((p): Point => {
|
|
2377
|
+
const x = xx(p[0]),
|
|
2378
|
+
y = yy(p[1]);
|
|
2379
|
+
check(
|
|
2380
|
+
finite(x) && finite(y) && Math.abs(y) < 1e15,
|
|
2381
|
+
"Data magnitude is too large relative to the Y axis.",
|
|
2382
|
+
);
|
|
2383
|
+
return [x, y];
|
|
2384
|
+
}),
|
|
2385
|
+
),
|
|
2386
|
+
);
|
|
2387
|
+
function grid(): void {
|
|
2388
|
+
const lay = new Surface(pw + 1, ph + 1);
|
|
2389
|
+
for (const [values, col] of [
|
|
2390
|
+
[ys.minor, t.minorGrid],
|
|
2391
|
+
[ys.major, t.majorGrid],
|
|
2392
|
+
] as const)
|
|
2393
|
+
for (const v of values) {
|
|
2394
|
+
const y = round(yy(v));
|
|
2395
|
+
if (y >= 0 && y <= ph) lay.dashed([0, y], [pw, y], col, t.gridDash);
|
|
2396
|
+
}
|
|
2397
|
+
for (const [values, col] of [
|
|
2398
|
+
[xs.minor, t.minorGrid],
|
|
2399
|
+
[xs.major, t.majorGrid],
|
|
2400
|
+
] as const)
|
|
2401
|
+
for (const v of values) {
|
|
2402
|
+
const x = round(xx(v));
|
|
2403
|
+
lay.dashed([x, 0], [x, ph], col, t.gridDash);
|
|
2404
|
+
}
|
|
2405
|
+
im.over(lay, left, top);
|
|
2406
|
+
}
|
|
2407
|
+
const aa = l.antialias,
|
|
2408
|
+
layerWidth = (pw + 1) * aa,
|
|
2409
|
+
layerHeight = (ph + 1) * aa;
|
|
2410
|
+
if (!t.gridFront) grid();
|
|
2411
|
+
for (let i = 0; i < c.series.length; i++) {
|
|
2412
|
+
const s = c.series[i];
|
|
2413
|
+
if (s.kind !== "area") continue;
|
|
2414
|
+
const base = yy(s.baseline);
|
|
2415
|
+
check(
|
|
2416
|
+
finite(base) && Math.abs(base) < 1e15,
|
|
2417
|
+
"Baseline magnitude is too large.",
|
|
2418
|
+
);
|
|
2419
|
+
const layer = new Surface(layerWidth, layerHeight);
|
|
2420
|
+
for (const ps of projected[i])
|
|
2421
|
+
if (ps.length > 1) {
|
|
2422
|
+
const poly = clipPolygon(
|
|
2423
|
+
[[ps[0][0], base], ...ps, [ps[ps.length - 1][0], base]],
|
|
2424
|
+
pw,
|
|
2425
|
+
ph,
|
|
2426
|
+
).map((p): Point => [round(p[0] * aa), round(p[1] * aa)]);
|
|
2427
|
+
layer.polygon(poly, s.color);
|
|
2428
|
+
}
|
|
2429
|
+
im.over(layer.down(aa), left, top);
|
|
2430
|
+
}
|
|
2431
|
+
if (t.gridFront) grid();
|
|
2432
|
+
for (let i = 0; i < c.series.length; i++) {
|
|
2433
|
+
const s = c.series[i],
|
|
2434
|
+
col = s.kind === "line" ? s.color : s.outline;
|
|
2435
|
+
if (col === null) continue;
|
|
2436
|
+
const layer = new Surface(layerWidth, layerHeight),
|
|
2437
|
+
lw = Math.max(1, round(s.lineWidth * aa));
|
|
2438
|
+
for (const ps of projected[i]) {
|
|
2439
|
+
if (ps.length === 1) {
|
|
2440
|
+
const p = ps[0];
|
|
2441
|
+
if (p[0] >= 0 && p[0] <= pw && p[1] >= 0 && p[1] <= ph)
|
|
2442
|
+
layer.circle(p[0] * aa, p[1] * aa, Math.max(aa / 2, lw / 2), col);
|
|
2443
|
+
}
|
|
2444
|
+
for (let k = 1; k < ps.length; k++) {
|
|
2445
|
+
const seg = clipLine(ps[k - 1], ps[k], pw, ph);
|
|
2446
|
+
if (seg)
|
|
2447
|
+
layer.line(
|
|
2448
|
+
[round(seg[0][0] * aa), round(seg[0][1] * aa)],
|
|
2449
|
+
[round(seg[1][0] * aa), round(seg[1][1] * aa)],
|
|
2450
|
+
col,
|
|
2451
|
+
lw,
|
|
2452
|
+
);
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
im.over(layer.down(aa), left, top);
|
|
2456
|
+
}
|
|
2457
|
+
const rules = new Surface(pw + 1, ph + 1);
|
|
2458
|
+
for (const r of c.hRules)
|
|
2459
|
+
if (r.value >= ys.minimum && r.value <= ys.maximum)
|
|
2460
|
+
rules.dashed(
|
|
2461
|
+
[0, round(yy(r.value))],
|
|
2462
|
+
[pw, round(yy(r.value))],
|
|
2463
|
+
r.color,
|
|
2464
|
+
r.dash,
|
|
2465
|
+
Math.max(1, round(r.width)),
|
|
2466
|
+
);
|
|
2467
|
+
for (const r of c.vRules)
|
|
2468
|
+
if (r.time >= start && r.time <= end)
|
|
2469
|
+
rules.dashed(
|
|
2470
|
+
[round(xx(r.time)), 0],
|
|
2471
|
+
[round(xx(r.time)), ph],
|
|
2472
|
+
r.color,
|
|
2473
|
+
r.dash,
|
|
2474
|
+
Math.max(1, round(r.width)),
|
|
2475
|
+
);
|
|
2476
|
+
im.over(rules, left, top);
|
|
2477
|
+
im.line([left, top - 3], [left, bottom + 4], t.axis);
|
|
2478
|
+
im.line([left - 4, bottom], [right + 4, bottom], t.axis);
|
|
2479
|
+
im.polygon(
|
|
2480
|
+
[
|
|
2481
|
+
[left, top - 5],
|
|
2482
|
+
[left - 3, top],
|
|
2483
|
+
[left + 3, top],
|
|
2484
|
+
],
|
|
2485
|
+
t.arrow,
|
|
2486
|
+
);
|
|
2487
|
+
im.polygon(
|
|
2488
|
+
[
|
|
2489
|
+
[right + 7, bottom],
|
|
2490
|
+
[right + 2, bottom - 3],
|
|
2491
|
+
[right + 2, bottom + 3],
|
|
2492
|
+
],
|
|
2493
|
+
t.arrow,
|
|
2494
|
+
);
|
|
2495
|
+
for (const v of ys.major) {
|
|
2496
|
+
const y = top + yy(v),
|
|
2497
|
+
unit =
|
|
2498
|
+
v === 0 && !c.yAxis.showZeroSuffix
|
|
2499
|
+
? { factor: ys.factor, suffix: "" }
|
|
2500
|
+
: ys,
|
|
2501
|
+
label = formatValue(v, unit, ys.decimals);
|
|
2502
|
+
check(
|
|
2503
|
+
fonts.width(label, "axis", t.axisAdvance) <= left - l.yLabelGap - 20,
|
|
2504
|
+
"Y labels overlap the vertical label. Increase layout.left or adjust units.",
|
|
2505
|
+
);
|
|
2506
|
+
im.line([left - 3, round(y)], [left, round(y)], t.axis);
|
|
2507
|
+
fonts.draw(
|
|
2508
|
+
im,
|
|
2509
|
+
left - l.yLabelGap,
|
|
2510
|
+
y,
|
|
2511
|
+
label,
|
|
2512
|
+
"axis",
|
|
2513
|
+
t.text,
|
|
2514
|
+
t.axisAdvance,
|
|
2515
|
+
"right",
|
|
2516
|
+
true,
|
|
2517
|
+
);
|
|
2518
|
+
}
|
|
2519
|
+
const xLabels = [];
|
|
2520
|
+
let lastRight = -Infinity;
|
|
2521
|
+
for (const tick of xs.labels) {
|
|
2522
|
+
const x = left + xx(tick.time),
|
|
2523
|
+
tw = fonts.width(tick.label, "axis", t.axisAdvance),
|
|
2524
|
+
a = x - tw / 2,
|
|
2525
|
+
b = x + tw / 2;
|
|
2526
|
+
if (c.timeAxis.ticks === null && a < lastRight + 3) continue;
|
|
2527
|
+
if (a < 2 || b > width - 3) continue;
|
|
2528
|
+
im.line(
|
|
2529
|
+
[round(x), bottom],
|
|
2530
|
+
[round(x), bottom + 3],
|
|
2531
|
+
[t.majorGrid[0], t.majorGrid[1], t.majorGrid[2], 255],
|
|
2532
|
+
);
|
|
2533
|
+
fonts.draw(
|
|
2534
|
+
im,
|
|
2535
|
+
x,
|
|
2536
|
+
bottom + l.xLabelGap,
|
|
2537
|
+
tick.label,
|
|
2538
|
+
"axis",
|
|
2539
|
+
t.text,
|
|
2540
|
+
t.axisAdvance,
|
|
2541
|
+
"center",
|
|
2542
|
+
);
|
|
2543
|
+
lastRight = b;
|
|
2544
|
+
xLabels.push({ ...tick, x });
|
|
2545
|
+
}
|
|
2546
|
+
const titleX = (left + right) / 2 + l.titleOffsetX,
|
|
2547
|
+
title = fonts.fit(
|
|
2548
|
+
c.title,
|
|
2549
|
+
2 * Math.min(titleX - 5, width - 14 - titleX),
|
|
2550
|
+
"title",
|
|
2551
|
+
t.titleAdvance,
|
|
2552
|
+
);
|
|
2553
|
+
fonts.draw(
|
|
2554
|
+
im,
|
|
2555
|
+
titleX,
|
|
2556
|
+
l.titleY,
|
|
2557
|
+
title,
|
|
2558
|
+
"title",
|
|
2559
|
+
t.text,
|
|
2560
|
+
t.titleAdvance,
|
|
2561
|
+
"center",
|
|
2562
|
+
);
|
|
2563
|
+
if (c.verticalLabel) {
|
|
2564
|
+
const unit = fonts.rotated(c.verticalLabel, "unit", t.text);
|
|
2565
|
+
check(
|
|
2566
|
+
unit.height <= ph + 18 && l.unitX + unit.width <= left - l.yLabelGap,
|
|
2567
|
+
"Vertical label does not fit.",
|
|
2568
|
+
);
|
|
2569
|
+
im.over(unit, l.unitX, round((top + bottom - unit.height) / 2));
|
|
2570
|
+
}
|
|
2571
|
+
if (c.watermark) {
|
|
2572
|
+
const mark = fonts.rotated(c.watermark, "watermark", t.watermark, true);
|
|
2573
|
+
check(
|
|
2574
|
+
mark.height <= height - 8 && mark.width <= l.right - 9,
|
|
2575
|
+
"Watermark does not fit.",
|
|
2576
|
+
);
|
|
2577
|
+
im.over(mark, width - mark.width - 4, 4);
|
|
2578
|
+
}
|
|
2579
|
+
const stats = c.series.map((s) => statistics(s, start, end));
|
|
2580
|
+
if (l.legend !== "none") {
|
|
2581
|
+
const scale = Math.min(1, (width - 40) / 555),
|
|
2582
|
+
advance = t.legendAdvance * scale,
|
|
2583
|
+
ll = l.legendLayout,
|
|
2584
|
+
factor = ll.autoScaleColumns
|
|
2585
|
+
? (width - ll.nameX) / (ll.referenceWidth - ll.nameX)
|
|
2586
|
+
: 1,
|
|
2587
|
+
anchor = (x: number): number => ll.nameX + (x - ll.nameX) * factor;
|
|
2588
|
+
c.series.forEach((s, i) => {
|
|
2589
|
+
const pairs =
|
|
2590
|
+
l.legend === "aligned"
|
|
2591
|
+
? ll.aligned
|
|
2592
|
+
: l.legend === "reference" && i === c.series.length - 1 && i > 0
|
|
2593
|
+
? ll.expanded
|
|
2594
|
+
: ll.compact,
|
|
2595
|
+
y = bottom + l.legendGap + i * l.legendRowHeight;
|
|
2596
|
+
check(
|
|
2597
|
+
ll.swatchHeight <= l.legendRowHeight &&
|
|
2598
|
+
ll.swatchX + ll.swatchWidth < width &&
|
|
2599
|
+
y + ll.swatchHeight <= height - 2,
|
|
2600
|
+
"Legend swatch does not fit.",
|
|
2601
|
+
);
|
|
2602
|
+
im.rect(
|
|
2603
|
+
ll.swatchX,
|
|
2604
|
+
y,
|
|
2605
|
+
ll.swatchX + ll.swatchWidth,
|
|
2606
|
+
y + ll.swatchHeight,
|
|
2607
|
+
t.frame,
|
|
2608
|
+
);
|
|
2609
|
+
const swatch = new Surface(
|
|
2610
|
+
ll.swatchWidth - 2,
|
|
2611
|
+
ll.swatchHeight - 2,
|
|
2612
|
+
s.color,
|
|
2613
|
+
);
|
|
2614
|
+
im.over(swatch, ll.swatchX + 1, y + 1);
|
|
2615
|
+
const name = fonts.fit(
|
|
2616
|
+
s.name,
|
|
2617
|
+
anchor(pairs[0][0]) - ll.nameX - 12,
|
|
2618
|
+
"legend",
|
|
2619
|
+
advance,
|
|
2620
|
+
scale,
|
|
2621
|
+
);
|
|
2622
|
+
fonts.draw(
|
|
2623
|
+
im,
|
|
2624
|
+
ll.nameX,
|
|
2625
|
+
y,
|
|
2626
|
+
name,
|
|
2627
|
+
"legend",
|
|
2628
|
+
t.text,
|
|
2629
|
+
advance,
|
|
2630
|
+
"left",
|
|
2631
|
+
false,
|
|
2632
|
+
scale,
|
|
2633
|
+
);
|
|
2634
|
+
const display = s.legendValues || stats[i];
|
|
2635
|
+
(["current", "average", "maximum"] as const).forEach((key, j) => {
|
|
2636
|
+
const label = c.legendLabels[j],
|
|
2637
|
+
value = formatValue(
|
|
2638
|
+
display[key],
|
|
2639
|
+
ys,
|
|
2640
|
+
c.yAxis.legendDecimals,
|
|
2641
|
+
c.missingLabel,
|
|
2642
|
+
),
|
|
2643
|
+
a = anchor(pairs[j][0]),
|
|
2644
|
+
b = anchor(pairs[j][1]);
|
|
2645
|
+
check(b < width - 3, "Legend column is outside the panel.");
|
|
2646
|
+
check(
|
|
2647
|
+
fonts.width(label, "legend", advance, scale) +
|
|
2648
|
+
fonts.width(value, "legend", advance, scale) +
|
|
2649
|
+
7 * scale <=
|
|
2650
|
+
b - a + 1,
|
|
2651
|
+
"Legend statistic does not fit. Increase width, adjust anchors or reduce legendDecimals.",
|
|
2652
|
+
);
|
|
2653
|
+
fonts.draw(
|
|
2654
|
+
im,
|
|
2655
|
+
a,
|
|
2656
|
+
y,
|
|
2657
|
+
label,
|
|
2658
|
+
"legend",
|
|
2659
|
+
t.text,
|
|
2660
|
+
advance,
|
|
2661
|
+
"left",
|
|
2662
|
+
false,
|
|
2663
|
+
scale,
|
|
2664
|
+
);
|
|
2665
|
+
fonts.draw(
|
|
2666
|
+
im,
|
|
2667
|
+
b,
|
|
2668
|
+
y,
|
|
2669
|
+
value,
|
|
2670
|
+
"legend",
|
|
2671
|
+
t.text,
|
|
2672
|
+
advance,
|
|
2673
|
+
"right",
|
|
2674
|
+
false,
|
|
2675
|
+
scale,
|
|
2676
|
+
);
|
|
2677
|
+
});
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
for (const [a, b] of [
|
|
2681
|
+
[
|
|
2682
|
+
[0, 0],
|
|
2683
|
+
[width - 1, 0],
|
|
2684
|
+
],
|
|
2685
|
+
[
|
|
2686
|
+
[1, 1],
|
|
2687
|
+
[width - 2, 1],
|
|
2688
|
+
],
|
|
2689
|
+
[
|
|
2690
|
+
[0, 0],
|
|
2691
|
+
[0, height - 1],
|
|
2692
|
+
],
|
|
2693
|
+
[
|
|
2694
|
+
[1, 1],
|
|
2695
|
+
[1, height - 2],
|
|
2696
|
+
],
|
|
2697
|
+
] as [Point, Point][])
|
|
2698
|
+
im.line(a, b, t.shadeLight);
|
|
2699
|
+
for (const [a, b] of [
|
|
2700
|
+
[
|
|
2701
|
+
[width - 2, 1],
|
|
2702
|
+
[width - 2, height - 1],
|
|
2703
|
+
],
|
|
2704
|
+
[
|
|
2705
|
+
[width - 1, 0],
|
|
2706
|
+
[width - 1, height - 1],
|
|
2707
|
+
],
|
|
2708
|
+
[
|
|
2709
|
+
[1, height - 2],
|
|
2710
|
+
[width - 1, height - 2],
|
|
2711
|
+
],
|
|
2712
|
+
[
|
|
2713
|
+
[0, height - 1],
|
|
2714
|
+
[width - 1, height - 1],
|
|
2715
|
+
],
|
|
2716
|
+
] as [Point, Point][])
|
|
2717
|
+
im.line(a, b, t.shadeDark);
|
|
2718
|
+
for (let i = 3; i < im.data.length; i += 4) im.data[i] = 255;
|
|
2719
|
+
const out = im.scale(l.pixelScale);
|
|
2720
|
+
const metadata: GraphMetadata = {
|
|
2721
|
+
version: VERSION,
|
|
2722
|
+
imageSize: [out.width, out.height],
|
|
2723
|
+
logicalSize: [width, height],
|
|
2724
|
+
plotBox: [left, top, right, bottom],
|
|
2725
|
+
pixelScale: l.pixelScale,
|
|
2726
|
+
title: c.title,
|
|
2727
|
+
verticalLabel: c.verticalLabel,
|
|
2728
|
+
watermark: c.watermark,
|
|
2729
|
+
timeRange: [start, end],
|
|
2730
|
+
timezone: c.timeAxis.timezone,
|
|
2731
|
+
timeMode: xs.mode,
|
|
2732
|
+
yRange: [ys.minimum, ys.maximum],
|
|
2733
|
+
yStep: ys.step,
|
|
2734
|
+
yUnit: { factor: ys.factor, suffix: ys.suffix },
|
|
2735
|
+
xLabels,
|
|
2736
|
+
statistics: stats,
|
|
2737
|
+
statisticsPolicy:
|
|
2738
|
+
"inclusive viewport; original sample arithmetic mean; current includes final missing sample",
|
|
2739
|
+
font: {
|
|
2740
|
+
mode: c.fonts.mode,
|
|
2741
|
+
family: c.fonts.mode === "system" ? c.fonts.family : null,
|
|
2742
|
+
pixelAlphabet: c.fonts.mode === "bitmap" ? "ascii-5x7-v1" : null,
|
|
2743
|
+
},
|
|
2744
|
+
layout: clone(l),
|
|
2745
|
+
theme: clone(t),
|
|
2746
|
+
warnings:
|
|
2747
|
+
c.fonts.mode === "system"
|
|
2748
|
+
? ["System font selection and glyph rasterization depend on the host."]
|
|
2749
|
+
: [],
|
|
2750
|
+
};
|
|
2751
|
+
return new RenderResult(out, metadata);
|
|
2752
|
+
}
|
|
2753
|
+
// Dependency-free PNG encoder: adaptive row filters + deterministic fixed-Huffman DEFLATE.
|
|
2754
|
+
// No Canvas encoder or compression library is involved in exported PNG bytes.
|
|
2755
|
+
const CRC_TABLE = (() => {
|
|
2756
|
+
const t = new Uint32Array(256);
|
|
2757
|
+
for (let i = 0; i < 256; i++) {
|
|
2758
|
+
let c = i;
|
|
2759
|
+
for (let j = 0; j < 8; j++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
2760
|
+
t[i] = c >>> 0;
|
|
2761
|
+
}
|
|
2762
|
+
return t;
|
|
2763
|
+
})();
|
|
2764
|
+
function crc32(bytes: Uint8Array): number {
|
|
2765
|
+
let c = 0xffffffff;
|
|
2766
|
+
for (const b of bytes) c = CRC_TABLE[(c ^ b) & 255] ^ (c >>> 8);
|
|
2767
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
2768
|
+
}
|
|
2769
|
+
function adler32(bytes: Uint8Array): number {
|
|
2770
|
+
let a = 1,
|
|
2771
|
+
b = 0;
|
|
2772
|
+
for (let i = 0; i < bytes.length;) {
|
|
2773
|
+
const end = Math.min(i + 5552, bytes.length);
|
|
2774
|
+
for (; i < end; i++) {
|
|
2775
|
+
a += bytes[i];
|
|
2776
|
+
b += a;
|
|
2777
|
+
}
|
|
2778
|
+
a %= 65521;
|
|
2779
|
+
b %= 65521;
|
|
2780
|
+
}
|
|
2781
|
+
return ((b << 16) | a) >>> 0;
|
|
2782
|
+
}
|
|
2783
|
+
function concat(parts: readonly Uint8Array[]): Uint8Array<ArrayBuffer> {
|
|
2784
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
2785
|
+
let at = 0;
|
|
2786
|
+
for (const p of parts) {
|
|
2787
|
+
out.set(p, at);
|
|
2788
|
+
at += p.length;
|
|
2789
|
+
}
|
|
2790
|
+
return out;
|
|
2791
|
+
}
|
|
2792
|
+
function u32(n: number): Uint8Array<ArrayBuffer> {
|
|
2793
|
+
return new Uint8Array([
|
|
2794
|
+
(n >>> 24) & 255,
|
|
2795
|
+
(n >>> 16) & 255,
|
|
2796
|
+
(n >>> 8) & 255,
|
|
2797
|
+
n & 255,
|
|
2798
|
+
]);
|
|
2799
|
+
}
|
|
2800
|
+
function chunk(type: string, data: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
2801
|
+
const code = Uint8Array.from(type, (c) => c.charCodeAt(0)),
|
|
2802
|
+
body = concat([code, data]);
|
|
2803
|
+
return concat([u32(data.length), body, u32(crc32(body))]);
|
|
2804
|
+
}
|
|
2805
|
+
function reverseBits(n: number, bits: number): number {
|
|
2806
|
+
let out = 0;
|
|
2807
|
+
for (let i = 0; i < bits; i++) {
|
|
2808
|
+
out = (out << 1) | (n & 1);
|
|
2809
|
+
n >>>= 1;
|
|
2810
|
+
}
|
|
2811
|
+
return out;
|
|
2812
|
+
}
|
|
2813
|
+
const FIXED = (() => {
|
|
2814
|
+
const out = [];
|
|
2815
|
+
for (let i = 0; i < 288; i++) {
|
|
2816
|
+
let code, bits;
|
|
2817
|
+
if (i <= 143) {
|
|
2818
|
+
bits = 8;
|
|
2819
|
+
code = 0x30 + i;
|
|
2820
|
+
} else if (i <= 255) {
|
|
2821
|
+
bits = 9;
|
|
2822
|
+
code = 0x190 + i - 144;
|
|
2823
|
+
} else if (i <= 279) {
|
|
2824
|
+
bits = 7;
|
|
2825
|
+
code = i - 256;
|
|
2826
|
+
} else {
|
|
2827
|
+
bits = 8;
|
|
2828
|
+
code = 0xc0 + i - 280;
|
|
2829
|
+
}
|
|
2830
|
+
out.push([reverseBits(code, bits), bits]);
|
|
2831
|
+
}
|
|
2832
|
+
return out;
|
|
2833
|
+
})();
|
|
2834
|
+
const LEN_BASE = [
|
|
2835
|
+
3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67,
|
|
2836
|
+
83, 99, 115, 131, 163, 195, 227, 258,
|
|
2837
|
+
];
|
|
2838
|
+
const LEN_EXTRA = [
|
|
2839
|
+
0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5,
|
|
2840
|
+
5, 5, 0,
|
|
2841
|
+
];
|
|
2842
|
+
const DIST_BASE = [
|
|
2843
|
+
1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769,
|
|
2844
|
+
1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577,
|
|
2845
|
+
];
|
|
2846
|
+
const DIST_EXTRA = [
|
|
2847
|
+
0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11,
|
|
2848
|
+
11, 12, 12, 13, 13,
|
|
2849
|
+
];
|
|
2850
|
+
function deflate(data: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
2851
|
+
let bytes = new Uint8Array(Math.max(128, data.length + 64)),
|
|
2852
|
+
len = 0,
|
|
2853
|
+
buffer = 0,
|
|
2854
|
+
count = 0;
|
|
2855
|
+
function byte(b: number): void {
|
|
2856
|
+
if (len === bytes.length) {
|
|
2857
|
+
const next = new Uint8Array(bytes.length * 2);
|
|
2858
|
+
next.set(bytes);
|
|
2859
|
+
bytes = next;
|
|
2860
|
+
}
|
|
2861
|
+
bytes[len++] = b;
|
|
2862
|
+
}
|
|
2863
|
+
function bits(value: number, n: number): void {
|
|
2864
|
+
buffer |= value << count;
|
|
2865
|
+
count += n;
|
|
2866
|
+
while (count >= 8) {
|
|
2867
|
+
byte(buffer & 255);
|
|
2868
|
+
buffer >>>= 8;
|
|
2869
|
+
count -= 8;
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
const sym = (v: number): void => bits(FIXED[v][0], FIXED[v][1]);
|
|
2873
|
+
byte(0x78);
|
|
2874
|
+
byte(0x01);
|
|
2875
|
+
bits(3, 3); // final block, fixed Huffman
|
|
2876
|
+
const head = new Int32Array(65536).fill(-1),
|
|
2877
|
+
prev = new Int32Array(32768).fill(-1);
|
|
2878
|
+
const hash = (i: number): number =>
|
|
2879
|
+
((data[i] * 251 + data[i + 1]) * 251 + data[i + 2]) & 65535;
|
|
2880
|
+
function insert(i: number): void {
|
|
2881
|
+
if (i + 2 >= data.length) return;
|
|
2882
|
+
const h = hash(i);
|
|
2883
|
+
prev[i & 32767] = head[h];
|
|
2884
|
+
head[h] = i;
|
|
2885
|
+
}
|
|
2886
|
+
for (let i = 0; i < data.length;) {
|
|
2887
|
+
let best = 0,
|
|
2888
|
+
dist = 0;
|
|
2889
|
+
if (i + 2 < data.length) {
|
|
2890
|
+
let candidate = head[hash(i)],
|
|
2891
|
+
tries = 64;
|
|
2892
|
+
const max = Math.min(258, data.length - i);
|
|
2893
|
+
while (
|
|
2894
|
+
candidate >= 0 &&
|
|
2895
|
+
i - candidate <= 32768 &&
|
|
2896
|
+
candidate < i &&
|
|
2897
|
+
tries--
|
|
2898
|
+
) {
|
|
2899
|
+
if (
|
|
2900
|
+
data[candidate] === data[i] &&
|
|
2901
|
+
data[candidate + best] === data[i + best]
|
|
2902
|
+
) {
|
|
2903
|
+
let n = 0;
|
|
2904
|
+
while (n < max && data[candidate + n] === data[i + n]) n++;
|
|
2905
|
+
if (n > best && n >= 3) {
|
|
2906
|
+
best = n;
|
|
2907
|
+
dist = i - candidate;
|
|
2908
|
+
if (n === max) break;
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
const next = prev[candidate & 32767];
|
|
2912
|
+
if (next >= candidate) break;
|
|
2913
|
+
candidate = next;
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
if (best >= 3) {
|
|
2917
|
+
let lc = 0;
|
|
2918
|
+
while (lc < 28 && LEN_BASE[lc + 1] <= best) lc++;
|
|
2919
|
+
sym(257 + lc);
|
|
2920
|
+
bits(best - LEN_BASE[lc], LEN_EXTRA[lc]);
|
|
2921
|
+
let dc = 0;
|
|
2922
|
+
while (dc < 29 && DIST_BASE[dc + 1] <= dist) dc++;
|
|
2923
|
+
bits(reverseBits(dc, 5), 5);
|
|
2924
|
+
bits(dist - DIST_BASE[dc], DIST_EXTRA[dc]);
|
|
2925
|
+
for (let j = 0; j < best; j++) insert(i + j);
|
|
2926
|
+
i += best;
|
|
2927
|
+
} else {
|
|
2928
|
+
sym(data[i]);
|
|
2929
|
+
insert(i);
|
|
2930
|
+
i++;
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
sym(256);
|
|
2934
|
+
if (count) byte(buffer & 255);
|
|
2935
|
+
const sum = adler32(data);
|
|
2936
|
+
for (const b of u32(sum)) byte(b);
|
|
2937
|
+
return bytes.slice(0, len);
|
|
2938
|
+
}
|
|
2939
|
+
function paeth(a: number, b: number, c: number): number {
|
|
2940
|
+
const p = a + b - c,
|
|
2941
|
+
pa = Math.abs(p - a),
|
|
2942
|
+
pb = Math.abs(p - b),
|
|
2943
|
+
pc = Math.abs(p - c);
|
|
2944
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
2945
|
+
}
|
|
2946
|
+
/** Encode RGBA pixels as PNG with optional uncompressed UTF-8 chart metadata. */
|
|
2947
|
+
export function encodePNG(
|
|
2948
|
+
image: RGBAImage,
|
|
2949
|
+
metadata: object | null = null,
|
|
2950
|
+
): Uint8Array<ArrayBuffer> {
|
|
2951
|
+
validateImage(image);
|
|
2952
|
+
check(
|
|
2953
|
+
image.width * image.height <= LIMITS.pixels,
|
|
2954
|
+
"PNG image exceeds output limit.",
|
|
2955
|
+
);
|
|
2956
|
+
const w = image.width,
|
|
2957
|
+
h = image.height,
|
|
2958
|
+
stride = w * 4,
|
|
2959
|
+
raw = new Uint8Array((stride + 1) * h),
|
|
2960
|
+
candidates = Array.from({ length: 5 }, () => new Uint8Array(stride));
|
|
2961
|
+
for (let y = 0; y < h; y++) {
|
|
2962
|
+
const scores = [0, 0, 0, 0, 0],
|
|
2963
|
+
off = y * stride;
|
|
2964
|
+
for (let x = 0; x < stride; x++) {
|
|
2965
|
+
const v = image.data[off + x],
|
|
2966
|
+
a = x >= 4 ? image.data[off + x - 4] : 0,
|
|
2967
|
+
b = y ? image.data[off + x - stride] : 0,
|
|
2968
|
+
c = y && x >= 4 ? image.data[off + x - stride - 4] : 0,
|
|
2969
|
+
predict = [0, a, b, Math.floor((a + b) / 2), paeth(a, b, c)];
|
|
2970
|
+
for (let f = 0; f < 5; f++) {
|
|
2971
|
+
const n = (v - predict[f]) & 255;
|
|
2972
|
+
candidates[f][x] = n;
|
|
2973
|
+
scores[f] += Math.min(n, 256 - n);
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
let best = 0;
|
|
2977
|
+
for (let f = 1; f < 5; f++) if (scores[f] < scores[best]) best = f;
|
|
2978
|
+
raw[y * (stride + 1)] = best;
|
|
2979
|
+
raw.set(candidates[best], y * (stride + 1) + 1);
|
|
2980
|
+
}
|
|
2981
|
+
const ihdr = concat([u32(w), u32(h), new Uint8Array([8, 6, 0, 0, 0])]),
|
|
2982
|
+
parts = [
|
|
2983
|
+
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
2984
|
+
chunk("IHDR", ihdr),
|
|
2985
|
+
chunk("sRGB", new Uint8Array([0])),
|
|
2986
|
+
];
|
|
2987
|
+
if (metadata !== null) {
|
|
2988
|
+
const json = JSON.stringify(metadata);
|
|
2989
|
+
check(json.length <= 1048576, "PNG metadata is too large.");
|
|
2990
|
+
parts.push(
|
|
2991
|
+
chunk(
|
|
2992
|
+
"iTXt",
|
|
2993
|
+
concat([
|
|
2994
|
+
new TextEncoder().encode("chart"),
|
|
2995
|
+
new Uint8Array([0, 0, 0, 0, 0]),
|
|
2996
|
+
new TextEncoder().encode(json),
|
|
2997
|
+
]),
|
|
2998
|
+
),
|
|
2999
|
+
);
|
|
3000
|
+
}
|
|
3001
|
+
parts.push(chunk("IDAT", deflate(raw)), chunk("IEND", new Uint8Array(0)));
|
|
3002
|
+
return concat(parts);
|
|
3003
|
+
}
|
|
3004
|
+
function validateImage(im: RGBAImage): void {
|
|
3005
|
+
check(
|
|
3006
|
+
im &&
|
|
3007
|
+
Number.isInteger(im.width) &&
|
|
3008
|
+
Number.isInteger(im.height) &&
|
|
3009
|
+
im.width > 0 &&
|
|
3010
|
+
im.height > 0 &&
|
|
3011
|
+
im.width * im.height <= LIMITS.layerPixels &&
|
|
3012
|
+
im.data &&
|
|
3013
|
+
im.data.length === im.width * im.height * 4,
|
|
3014
|
+
"Expected an RGBA image with matching dimensions.",
|
|
3015
|
+
);
|
|
3016
|
+
}
|
|
3017
|
+
/** Draw image bytes without browser resampling and return the target canvas. */
|
|
3018
|
+
export function drawImageToCanvas(image: RGBAImage, canvas: Canvas): Canvas {
|
|
3019
|
+
validateImage(image);
|
|
3020
|
+
check(
|
|
3021
|
+
canvas && typeof canvas.getContext === "function",
|
|
3022
|
+
"Expected a canvas element.",
|
|
3023
|
+
);
|
|
3024
|
+
const ctx = canvas.getContext("2d") as Context2D | null;
|
|
3025
|
+
check(ctx, "Canvas 2D context is unavailable.");
|
|
3026
|
+
canvas.width = image.width;
|
|
3027
|
+
canvas.height = image.height;
|
|
3028
|
+
const data = ctx.createImageData(image.width, image.height);
|
|
3029
|
+
data.data.set(image.data);
|
|
3030
|
+
ctx.putImageData(data, 0, 0);
|
|
3031
|
+
return canvas;
|
|
3032
|
+
}
|
|
3033
|
+
/** Download bytes in a browser; filenames may not contain path separators. */
|
|
3034
|
+
export function downloadBytes(
|
|
3035
|
+
bytes: Uint8Array,
|
|
3036
|
+
name: string,
|
|
3037
|
+
type: string = "application/octet-stream",
|
|
3038
|
+
): void {
|
|
3039
|
+
check(
|
|
3040
|
+
typeof document !== "undefined",
|
|
3041
|
+
"Downloads require a browser document.",
|
|
3042
|
+
);
|
|
3043
|
+
text(name, "Filename");
|
|
3044
|
+
check(
|
|
3045
|
+
!/[\\/\x00-\x1f]/.test(name),
|
|
3046
|
+
"Filename must not contain path separators or controls.",
|
|
3047
|
+
);
|
|
3048
|
+
const data =
|
|
3049
|
+
bytes.buffer instanceof ArrayBuffer
|
|
3050
|
+
? (bytes as Uint8Array<ArrayBuffer>)
|
|
3051
|
+
: new Uint8Array(bytes);
|
|
3052
|
+
const blob = new Blob([data], { type }),
|
|
3053
|
+
url = URL.createObjectURL(blob),
|
|
3054
|
+
a = document.createElement("a");
|
|
3055
|
+
a.href = url;
|
|
3056
|
+
a.download = name;
|
|
3057
|
+
a.hidden = true;
|
|
3058
|
+
document.body.appendChild(a);
|
|
3059
|
+
a.click();
|
|
3060
|
+
a.remove();
|
|
3061
|
+
setTimeout(() => URL.revokeObjectURL(url), 30000);
|
|
3062
|
+
}
|
|
3063
|
+
/** Rendered pixels and a frozen manifest; PNG encoding never requires Canvas. */
|
|
3064
|
+
export class RenderResult<M extends object = GraphMetadata> {
|
|
3065
|
+
/** Pixels intentionally remain mutable; exports observe subsequent edits. */
|
|
3066
|
+
readonly image: RGBAImage;
|
|
3067
|
+
/** Immutable metadata captured at rendering time. */
|
|
3068
|
+
readonly metadata: Readonly<M>;
|
|
3069
|
+
constructor(image: RGBAImage, metadata: M) {
|
|
3070
|
+
this.image = image;
|
|
3071
|
+
this.metadata = freeze(metadata);
|
|
3072
|
+
}
|
|
3073
|
+
get width(): number {
|
|
3074
|
+
return this.image.width;
|
|
3075
|
+
}
|
|
3076
|
+
get height(): number {
|
|
3077
|
+
return this.image.height;
|
|
3078
|
+
}
|
|
3079
|
+
get data(): RGBAImage["data"] {
|
|
3080
|
+
return this.image.data;
|
|
3081
|
+
}
|
|
3082
|
+
draw(canvas: Canvas): Canvas {
|
|
3083
|
+
return drawImageToCanvas(this.image, canvas);
|
|
3084
|
+
}
|
|
3085
|
+
toPNG(options: PNGOptions = {}): Uint8Array<ArrayBuffer> {
|
|
3086
|
+
return encodePNG(
|
|
3087
|
+
this.image,
|
|
3088
|
+
options.metadata === false ? null : this.metadata,
|
|
3089
|
+
);
|
|
3090
|
+
}
|
|
3091
|
+
toBlob(options: PNGOptions = {}): Blob {
|
|
3092
|
+
return new Blob([this.toPNG(options)], { type: "image/png" });
|
|
3093
|
+
}
|
|
3094
|
+
download(filename: string = "chart.png", options: PNGOptions = {}): void {
|
|
3095
|
+
downloadBytes(this.toPNG(options), filename, "image/png");
|
|
3096
|
+
}
|
|
3097
|
+
}
|
|
3098
|
+
/** An immutable validated chart. Use with() to derive a changed chart. */
|
|
3099
|
+
export class Chart {
|
|
3100
|
+
/** Fully normalized, deeply frozen options used by every render. */
|
|
3101
|
+
readonly config: DeepReadonly<ResolvedChartOptions>;
|
|
3102
|
+
constructor(options: ChartOptions = {}) {
|
|
3103
|
+
this.config = normalize(options);
|
|
3104
|
+
Object.freeze(this);
|
|
3105
|
+
}
|
|
3106
|
+
with(patch: ChartOptions): Chart {
|
|
3107
|
+
return new Chart(merge(this.config, patch));
|
|
3108
|
+
}
|
|
3109
|
+
render(): RenderResult {
|
|
3110
|
+
return renderChart(this.config);
|
|
3111
|
+
}
|
|
3112
|
+
draw(canvas: Canvas): RenderResult {
|
|
3113
|
+
const r = this.render();
|
|
3114
|
+
r.draw(canvas);
|
|
3115
|
+
return r;
|
|
3116
|
+
}
|
|
3117
|
+
toPNG(options: PNGOptions = {}): Uint8Array<ArrayBuffer> {
|
|
3118
|
+
return this.render().toPNG(options);
|
|
3119
|
+
}
|
|
3120
|
+
mount(canvas: HTMLCanvasElement, options: MountOptions = {}): Controller {
|
|
3121
|
+
return new BrowserController(this, canvas, options);
|
|
3122
|
+
}
|
|
3123
|
+
nearest(input: Timestamp): NearestSample[] {
|
|
3124
|
+
const time = epoch(input);
|
|
3125
|
+
const [start, end] = timeRange(this.config);
|
|
3126
|
+
return this.config.series.map((s) => {
|
|
3127
|
+
const lo = lowerBound(s.timestamps, start),
|
|
3128
|
+
hi = upperBound(s.timestamps, end);
|
|
3129
|
+
if (lo === hi)
|
|
3130
|
+
return { name: s.name, index: null, time: null, value: null };
|
|
3131
|
+
let i = clamp(lowerBound(s.timestamps, time), lo, hi - 1);
|
|
3132
|
+
if (
|
|
3133
|
+
i > lo &&
|
|
3134
|
+
Math.abs(s.timestamps[i - 1] - time) <= Math.abs(s.timestamps[i] - time)
|
|
3135
|
+
)
|
|
3136
|
+
i--;
|
|
3137
|
+
return {
|
|
3138
|
+
name: s.name,
|
|
3139
|
+
index: i,
|
|
3140
|
+
time: s.timestamps[i],
|
|
3141
|
+
value: finite(s.values[i]) ? s.values[i] : null,
|
|
3142
|
+
};
|
|
3143
|
+
});
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
/** Build the conventional inbound area and outbound line without unit conversion. */
|
|
3147
|
+
export function traffic(
|
|
3148
|
+
timestamps: ArrayLike<Timestamp>,
|
|
3149
|
+
inbound: Values,
|
|
3150
|
+
outbound: Values,
|
|
3151
|
+
options: TrafficOptions = {},
|
|
3152
|
+
): Chart {
|
|
3153
|
+
const gap = options.gapAfter === undefined ? 0 : options.gapAfter,
|
|
3154
|
+
opts = { ...options };
|
|
3155
|
+
delete opts.gapAfter;
|
|
3156
|
+
return new Chart(
|
|
3157
|
+
merge(
|
|
3158
|
+
{
|
|
3159
|
+
title: "Traffic - ether1",
|
|
3160
|
+
verticalLabel: "bits per second",
|
|
3161
|
+
series: [
|
|
3162
|
+
series("Inbound", timestamps, inbound, {
|
|
3163
|
+
kind: "area",
|
|
3164
|
+
color: "#00cc00",
|
|
3165
|
+
outline: "#003000",
|
|
3166
|
+
gapAfter: gap,
|
|
3167
|
+
}),
|
|
3168
|
+
series("Outbound", timestamps, outbound, {
|
|
3169
|
+
kind: "line",
|
|
3170
|
+
color: "#0000cc",
|
|
3171
|
+
gapAfter: gap,
|
|
3172
|
+
}),
|
|
3173
|
+
],
|
|
3174
|
+
},
|
|
3175
|
+
opts,
|
|
3176
|
+
),
|
|
3177
|
+
);
|
|
3178
|
+
}
|
|
3179
|
+
/** Compose native chart renders and optional captions at a shared pixel scale. */
|
|
3180
|
+
export function dashboard(
|
|
3181
|
+
panels: readonly (DashboardPanel | Chart)[],
|
|
3182
|
+
options: DashboardOptions = {},
|
|
3183
|
+
): RenderResult<DashboardMetadata> {
|
|
3184
|
+
check(
|
|
3185
|
+
Array.isArray(panels) && panels.length > 0 && panels.length <= 128,
|
|
3186
|
+
"Dashboard requires 1..128 panels.",
|
|
3187
|
+
);
|
|
3188
|
+
const o = merge<Required<DashboardOptions>>(
|
|
3189
|
+
{ gap: 24, padding: [4, 3, 6, 6], background: "#f3f3f3", cropHeight: null },
|
|
3190
|
+
options,
|
|
3191
|
+
);
|
|
3192
|
+
integer(o.gap, "Panel gap", 0, 4096);
|
|
3193
|
+
check(
|
|
3194
|
+
Array.isArray(o.padding) && o.padding.length === 4,
|
|
3195
|
+
"Padding is [left, top, right, bottom].",
|
|
3196
|
+
);
|
|
3197
|
+
o.padding.forEach((v) => integer(v, "Padding", 0, 4096));
|
|
3198
|
+
if (o.cropHeight !== null) integer(o.cropHeight, "Crop height", 1, 65536);
|
|
3199
|
+
const pp = panels.map((p) =>
|
|
3200
|
+
p instanceof Chart ? { chart: p, caption: "" } : p,
|
|
3201
|
+
);
|
|
3202
|
+
for (const p of pp) {
|
|
3203
|
+
check(p.chart instanceof Chart, "Panel needs a Chart.");
|
|
3204
|
+
text(p.caption || "", "Caption");
|
|
3205
|
+
}
|
|
3206
|
+
const scale = pp[0].chart.config.layout.pixelScale;
|
|
3207
|
+
check(
|
|
3208
|
+
pp.every((p) => p.chart.config.layout.pixelScale === scale),
|
|
3209
|
+
"Dashboard panels must have equal pixelScale.",
|
|
3210
|
+
);
|
|
3211
|
+
const rendered = pp.map((p) => p.chart.render()),
|
|
3212
|
+
left = o.padding[0] * scale,
|
|
3213
|
+
top = o.padding[1] * scale,
|
|
3214
|
+
gap = o.gap * scale;
|
|
3215
|
+
const width =
|
|
3216
|
+
Math.max(...rendered.map((r) => r.width)) +
|
|
3217
|
+
(o.padding[0] + o.padding[2]) * scale;
|
|
3218
|
+
let height =
|
|
3219
|
+
rendered.reduce((n, r) => n + r.height, 0) +
|
|
3220
|
+
(o.padding[1] + o.padding[3]) * scale +
|
|
3221
|
+
gap * (pp.length - 1 + (pp[pp.length - 1].caption ? 1 : 0));
|
|
3222
|
+
if (o.cropHeight !== null) height = Math.min(height, o.cropHeight * scale);
|
|
3223
|
+
check(width * height <= LIMITS.pixels, "Dashboard is too large.");
|
|
3224
|
+
const out = new Surface(width, height, color(o.background));
|
|
3225
|
+
let y = top;
|
|
3226
|
+
const meta: DashboardMetadata["panels"] = [];
|
|
3227
|
+
for (let i = 0; i < pp.length; i++) {
|
|
3228
|
+
const r = rendered[i],
|
|
3229
|
+
p = pp[i];
|
|
3230
|
+
out.over(r.image, left, y);
|
|
3231
|
+
meta.push({
|
|
3232
|
+
position: [left, y],
|
|
3233
|
+
caption: p.caption || "",
|
|
3234
|
+
chart: r.metadata,
|
|
3235
|
+
});
|
|
3236
|
+
y += r.height;
|
|
3237
|
+
if (p.caption) {
|
|
3238
|
+
const f = new Fonts(p.chart.config.fonts, p.chart.config.theme),
|
|
3239
|
+
w = f.width(p.caption, "caption"),
|
|
3240
|
+
h = p.chart.config.theme.captionSize;
|
|
3241
|
+
check(
|
|
3242
|
+
w <= width / scale - 8 && h + 4 <= o.gap,
|
|
3243
|
+
"Caption does not fit the dashboard gap.",
|
|
3244
|
+
);
|
|
3245
|
+
const line = new Surface(width / scale, o.gap);
|
|
3246
|
+
f.draw(
|
|
3247
|
+
line,
|
|
3248
|
+
width / scale / 2,
|
|
3249
|
+
4,
|
|
3250
|
+
p.caption,
|
|
3251
|
+
"caption",
|
|
3252
|
+
p.chart.config.theme.text,
|
|
3253
|
+
0,
|
|
3254
|
+
"center",
|
|
3255
|
+
);
|
|
3256
|
+
out.over(line.scale(scale), 0, y);
|
|
3257
|
+
}
|
|
3258
|
+
y += gap;
|
|
3259
|
+
}
|
|
3260
|
+
return new RenderResult<DashboardMetadata>(out, {
|
|
3261
|
+
version: VERSION,
|
|
3262
|
+
imageSize: [width, height],
|
|
3263
|
+
pixelScale: scale,
|
|
3264
|
+
panels: meta,
|
|
3265
|
+
});
|
|
3266
|
+
}
|
|
3267
|
+
/** Compute right-endpoint rates with exact BigInt counter subtraction. */
|
|
3268
|
+
export function counterRate(
|
|
3269
|
+
timestamps: ArrayLike<Timestamp>,
|
|
3270
|
+
counters: ArrayLike<number | bigint | null | undefined>,
|
|
3271
|
+
options: CounterOptions = {},
|
|
3272
|
+
): Samples {
|
|
3273
|
+
const o = merge<Required<CounterOptions>>(
|
|
3274
|
+
{ factor: 1, onDecrease: "gap", counterBits: null, maxRate: null },
|
|
3275
|
+
options,
|
|
3276
|
+
);
|
|
3277
|
+
number(o.factor, "Rate factor", Number.MIN_VALUE);
|
|
3278
|
+
check(
|
|
3279
|
+
["gap", "wrap"].includes(o.onDecrease),
|
|
3280
|
+
"Decrease policy must be gap or wrap.",
|
|
3281
|
+
);
|
|
3282
|
+
if (o.counterBits !== null) integer(o.counterBits, "Counter bits", 1, 128);
|
|
3283
|
+
if (o.maxRate !== null) number(o.maxRate, "Maximum rate", 0);
|
|
3284
|
+
check(
|
|
3285
|
+
o.onDecrease !== "wrap" || o.counterBits !== null,
|
|
3286
|
+
"Wrapping requires an explicit counterBits.",
|
|
3287
|
+
);
|
|
3288
|
+
check(
|
|
3289
|
+
(Array.isArray(counters) || ArrayBuffer.isView(counters)) &&
|
|
3290
|
+
counters.length === timestamps.length,
|
|
3291
|
+
"Counter and timestamp lengths must match.",
|
|
3292
|
+
);
|
|
3293
|
+
const s = samples(
|
|
3294
|
+
timestamps,
|
|
3295
|
+
Array.from(counters, () => 0),
|
|
3296
|
+
),
|
|
3297
|
+
max = (1n << BigInt(o.counterBits || 128)) - 1n;
|
|
3298
|
+
const cs = Array.from(counters, (v, i) => {
|
|
3299
|
+
if (missing(v)) return null;
|
|
3300
|
+
if (typeof v === "number") {
|
|
3301
|
+
check(
|
|
3302
|
+
Number.isSafeInteger(v) && v >= 0,
|
|
3303
|
+
"Counter " +
|
|
3304
|
+
i +
|
|
3305
|
+
" must be a safe nonnegative integer; use BigInt for large counters.",
|
|
3306
|
+
);
|
|
3307
|
+
v = BigInt(v);
|
|
3308
|
+
}
|
|
3309
|
+
check(
|
|
3310
|
+
typeof v === "bigint" && v >= 0n && v <= max,
|
|
3311
|
+
"Counter must fit the configured unsigned bit width.",
|
|
3312
|
+
);
|
|
3313
|
+
return v;
|
|
3314
|
+
});
|
|
3315
|
+
s.values.fill(NaN);
|
|
3316
|
+
for (let i = 1; i < cs.length; i++) {
|
|
3317
|
+
const current = cs[i],
|
|
3318
|
+
previous = cs[i - 1];
|
|
3319
|
+
if (current === null || previous === null) continue;
|
|
3320
|
+
let d = current - previous;
|
|
3321
|
+
if (d < 0n) {
|
|
3322
|
+
if (o.onDecrease === "gap") continue;
|
|
3323
|
+
d += max + 1n;
|
|
3324
|
+
}
|
|
3325
|
+
const v = (Number(d) * o.factor) / (s.timestamps[i] - s.timestamps[i - 1]);
|
|
3326
|
+
check(finite(v), "Counter rate overflow.");
|
|
3327
|
+
if (o.maxRate === null || v <= o.maxRate) s.values[i] = v;
|
|
3328
|
+
}
|
|
3329
|
+
return s;
|
|
3330
|
+
}
|
|
3331
|
+
/** Aggregate elapsed-time buckets, preserving gaps and explicit coverage policy. */
|
|
3332
|
+
export function aggregate(
|
|
3333
|
+
timestamps: ArrayLike<Timestamp>,
|
|
3334
|
+
values: Values,
|
|
3335
|
+
options: AggregateOptions = {},
|
|
3336
|
+
): Samples {
|
|
3337
|
+
const o = merge<Required<AggregateOptions>>(
|
|
3338
|
+
{
|
|
3339
|
+
interval: 300,
|
|
3340
|
+
method: "mean",
|
|
3341
|
+
origin: 0,
|
|
3342
|
+
minCoverage: 0,
|
|
3343
|
+
expectedStep: null,
|
|
3344
|
+
maxBuckets: 1000000,
|
|
3345
|
+
},
|
|
3346
|
+
options,
|
|
3347
|
+
);
|
|
3348
|
+
number(o.interval, "Aggregation interval", 0.001);
|
|
3349
|
+
number(o.origin, "Aggregation origin");
|
|
3350
|
+
number(o.minCoverage, "Minimum coverage", 0, 1);
|
|
3351
|
+
if (o.expectedStep !== null)
|
|
3352
|
+
number(o.expectedStep, "Expected step", Number.MIN_VALUE);
|
|
3353
|
+
integer(o.maxBuckets, "Maximum buckets", 1, 1000000);
|
|
3354
|
+
check(
|
|
3355
|
+
["mean", "min", "max", "last", "sum"].includes(o.method),
|
|
3356
|
+
"Unknown aggregation method.",
|
|
3357
|
+
);
|
|
3358
|
+
const s = samples(timestamps, values);
|
|
3359
|
+
if (!s.timestamps.length) return s;
|
|
3360
|
+
const bucket = (t: number): number => Math.floor((t - o.origin) / o.interval),
|
|
3361
|
+
a = bucket(s.timestamps[0]),
|
|
3362
|
+
b = bucket(s.timestamps[s.timestamps.length - 1]);
|
|
3363
|
+
check(
|
|
3364
|
+
Number.isSafeInteger(a) &&
|
|
3365
|
+
Number.isSafeInteger(b) &&
|
|
3366
|
+
b - a + 1 <= o.maxBuckets,
|
|
3367
|
+
"Aggregation bucket limit or precision exceeded.",
|
|
3368
|
+
);
|
|
3369
|
+
const out: Samples = { timestamps: [], values: [] };
|
|
3370
|
+
let i = 0;
|
|
3371
|
+
for (let k = a; k <= b; k++) {
|
|
3372
|
+
const ts = o.origin + k * o.interval;
|
|
3373
|
+
epoch(ts);
|
|
3374
|
+
out.timestamps.push(ts);
|
|
3375
|
+
let total = 0,
|
|
3376
|
+
last = NaN,
|
|
3377
|
+
mn = Infinity,
|
|
3378
|
+
mx = -Infinity;
|
|
3379
|
+
const finiteValues = [];
|
|
3380
|
+
while (i < s.timestamps.length && bucket(s.timestamps[i]) === k) {
|
|
3381
|
+
const v = s.values[i++];
|
|
3382
|
+
total++;
|
|
3383
|
+
last = v;
|
|
3384
|
+
if (finite(v)) {
|
|
3385
|
+
finiteValues.push(v);
|
|
3386
|
+
mn = Math.min(mn, v);
|
|
3387
|
+
mx = Math.max(mx, v);
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
const denominator = Math.max(
|
|
3391
|
+
total,
|
|
3392
|
+
o.expectedStep === null ? 0 : o.interval / o.expectedStep,
|
|
3393
|
+
);
|
|
3394
|
+
if (
|
|
3395
|
+
!finiteValues.length ||
|
|
3396
|
+
finiteValues.length / denominator < o.minCoverage
|
|
3397
|
+
) {
|
|
3398
|
+
out.values.push(NaN);
|
|
3399
|
+
continue;
|
|
3400
|
+
}
|
|
3401
|
+
let value;
|
|
3402
|
+
if (o.method === "mean") value = stableMean(finiteValues)!;
|
|
3403
|
+
else if (o.method === "min") value = mn;
|
|
3404
|
+
else if (o.method === "max") value = mx;
|
|
3405
|
+
else if (o.method === "last") value = last;
|
|
3406
|
+
else {
|
|
3407
|
+
value = stableMean(finiteValues)! * finiteValues.length;
|
|
3408
|
+
check(finite(value), "Aggregation sum overflow.");
|
|
3409
|
+
}
|
|
3410
|
+
out.values.push(value);
|
|
3411
|
+
}
|
|
3412
|
+
return out;
|
|
3413
|
+
}
|
|
3414
|
+
/** Parse quoted CSV into validated series; timestamps must strictly increase. */
|
|
3415
|
+
export function parseCSV(input: string, options: CSVOptions = {}): Series[] {
|
|
3416
|
+
const o = merge<Required<CSVOptions>>(
|
|
3417
|
+
{
|
|
3418
|
+
timestampColumn: "timestamp",
|
|
3419
|
+
columns: [
|
|
3420
|
+
{
|
|
3421
|
+
column: "inbound",
|
|
3422
|
+
name: "Inbound",
|
|
3423
|
+
kind: "area",
|
|
3424
|
+
color: "#00cc00",
|
|
3425
|
+
outline: "#003000",
|
|
3426
|
+
},
|
|
3427
|
+
{
|
|
3428
|
+
column: "outbound",
|
|
3429
|
+
name: "Outbound",
|
|
3430
|
+
kind: "line",
|
|
3431
|
+
color: "#0000cc",
|
|
3432
|
+
},
|
|
3433
|
+
],
|
|
3434
|
+
maxRows: 1000000,
|
|
3435
|
+
maxBytes: 16777216,
|
|
3436
|
+
},
|
|
3437
|
+
options,
|
|
3438
|
+
);
|
|
3439
|
+
check(typeof input === "string", "CSV must be a string.");
|
|
3440
|
+
integer(o.maxRows, "Maximum CSV rows", 1, 2000000);
|
|
3441
|
+
integer(o.maxBytes, "Maximum CSV bytes", 1, 67108864);
|
|
3442
|
+
check(
|
|
3443
|
+
input.length <= o.maxBytes &&
|
|
3444
|
+
new TextEncoder().encode(input).length <= o.maxBytes,
|
|
3445
|
+
"CSV exceeds the configured byte limit.",
|
|
3446
|
+
);
|
|
3447
|
+
check(
|
|
3448
|
+
Array.isArray(o.columns) && o.columns.length > 0 && o.columns.length <= 128,
|
|
3449
|
+
"CSV needs at least one column mapping.",
|
|
3450
|
+
);
|
|
3451
|
+
if (input.charCodeAt(0) === 0xfeff) input = input.slice(1);
|
|
3452
|
+
const rows: string[][] = [];
|
|
3453
|
+
let row: string[] = [],
|
|
3454
|
+
cell = "",
|
|
3455
|
+
quoted = false,
|
|
3456
|
+
closed = false;
|
|
3457
|
+
function endCell(): void {
|
|
3458
|
+
row.push(cell);
|
|
3459
|
+
cell = "";
|
|
3460
|
+
closed = false;
|
|
3461
|
+
}
|
|
3462
|
+
function endRow(): void {
|
|
3463
|
+
endCell();
|
|
3464
|
+
if (row.some((v) => v !== "")) rows.push(row);
|
|
3465
|
+
row = [];
|
|
3466
|
+
check(rows.length <= o.maxRows + 1, "CSV row limit exceeded.");
|
|
3467
|
+
}
|
|
3468
|
+
for (let i = 0; i < input.length; i++) {
|
|
3469
|
+
const ch = input[i];
|
|
3470
|
+
if (quoted) {
|
|
3471
|
+
if (ch === '"') {
|
|
3472
|
+
if (input[i + 1] === '"') {
|
|
3473
|
+
cell += '"';
|
|
3474
|
+
i++;
|
|
3475
|
+
} else {
|
|
3476
|
+
quoted = false;
|
|
3477
|
+
closed = true;
|
|
3478
|
+
}
|
|
3479
|
+
} else cell += ch;
|
|
3480
|
+
continue;
|
|
3481
|
+
}
|
|
3482
|
+
if (closed) {
|
|
3483
|
+
check(
|
|
3484
|
+
ch === "," || ch === "\r" || ch === "\n",
|
|
3485
|
+
"Unexpected text after a closing CSV quote.",
|
|
3486
|
+
);
|
|
3487
|
+
}
|
|
3488
|
+
if (ch === '"') {
|
|
3489
|
+
check(
|
|
3490
|
+
cell === "" && !closed,
|
|
3491
|
+
"Unexpected quote in an unquoted CSV field.",
|
|
3492
|
+
);
|
|
3493
|
+
quoted = true;
|
|
3494
|
+
} else if (ch === ",") endCell();
|
|
3495
|
+
else if (ch === "\n" || ch === "\r") {
|
|
3496
|
+
if (ch === "\r" && input[i + 1] === "\n") i++;
|
|
3497
|
+
endRow();
|
|
3498
|
+
} else cell += ch;
|
|
3499
|
+
}
|
|
3500
|
+
check(!quoted, "Unterminated quoted CSV field.");
|
|
3501
|
+
if (cell !== "" || row.length || closed) endRow();
|
|
3502
|
+
check(rows.length >= 1, "CSV header is missing.");
|
|
3503
|
+
const header = rows.shift()!.map((v) => v.trim());
|
|
3504
|
+
check(
|
|
3505
|
+
header.every(Boolean) && new Set(header).size === header.length,
|
|
3506
|
+
"CSV headers must be nonempty and unique.",
|
|
3507
|
+
);
|
|
3508
|
+
const ti = header.indexOf(o.timestampColumn);
|
|
3509
|
+
check(ti >= 0, "Timestamp column not found: " + o.timestampColumn);
|
|
3510
|
+
const ids = o.columns.map((v) => {
|
|
3511
|
+
const i = header.indexOf(v.column);
|
|
3512
|
+
check(i >= 0, "Column not found: " + v.column);
|
|
3513
|
+
return i;
|
|
3514
|
+
});
|
|
3515
|
+
const ts: number[] = [],
|
|
3516
|
+
vv: number[][] = o.columns.map(() => []);
|
|
3517
|
+
for (let i = 0; i < rows.length; i++) {
|
|
3518
|
+
const r = rows[i];
|
|
3519
|
+
check(
|
|
3520
|
+
r.length === header.length,
|
|
3521
|
+
"CSV row " + (i + 2) + " has the wrong number of columns.",
|
|
3522
|
+
);
|
|
3523
|
+
try {
|
|
3524
|
+
ts.push(epoch(r[ti]));
|
|
3525
|
+
} catch (e) {
|
|
3526
|
+
throw new RangeError(
|
|
3527
|
+
"CSV row " +
|
|
3528
|
+
(i + 2) +
|
|
3529
|
+
": " +
|
|
3530
|
+
(e instanceof Error ? e.message : String(e)),
|
|
3531
|
+
);
|
|
3532
|
+
}
|
|
3533
|
+
ids.forEach((id, j) => {
|
|
3534
|
+
const v = r[id].trim();
|
|
3535
|
+
if (/^(?:nan|none|null)?$/i.test(v)) {
|
|
3536
|
+
vv[j].push(NaN);
|
|
3537
|
+
return;
|
|
3538
|
+
}
|
|
3539
|
+
check(
|
|
3540
|
+
/^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(v),
|
|
3541
|
+
"Invalid numeric CSV value at row " + (i + 2) + ".",
|
|
3542
|
+
);
|
|
3543
|
+
vv[j].push(number(Number(v), "CSV value"));
|
|
3544
|
+
});
|
|
3545
|
+
}
|
|
3546
|
+
return o.columns.map((v, j) => series(v.name || v.column, ts, vv[j], v));
|
|
3547
|
+
}
|
|
3548
|
+
/** Export the union of series timestamps, escaping formula-like text headers. */
|
|
3549
|
+
export function toCSV(
|
|
3550
|
+
list: readonly SeriesInput[],
|
|
3551
|
+
options: { escapeFormulas?: boolean } = {},
|
|
3552
|
+
): string {
|
|
3553
|
+
check(
|
|
3554
|
+
Array.isArray(list) && list.length <= 128,
|
|
3555
|
+
"Expected an array of series.",
|
|
3556
|
+
);
|
|
3557
|
+
const escaped = options.escapeFormulas !== false;
|
|
3558
|
+
const ss = list.map((s) => series(s.name, s.timestamps, s.values, s));
|
|
3559
|
+
let total = 0;
|
|
3560
|
+
for (const s of ss) total += s.timestamps.length;
|
|
3561
|
+
check(
|
|
3562
|
+
total <= LIMITS.samples,
|
|
3563
|
+
"CSV export exceeds the combined sample limit.",
|
|
3564
|
+
);
|
|
3565
|
+
const times = [...new Set(ss.flatMap((s) => s.timestamps))].sort(
|
|
3566
|
+
(a, b) => a - b,
|
|
3567
|
+
);
|
|
3568
|
+
check(
|
|
3569
|
+
new Set(ss.map((s) => s.name)).size === ss.length &&
|
|
3570
|
+
!ss.some((s) => s.name === "timestamp"),
|
|
3571
|
+
"CSV series names must be unique and not timestamp.",
|
|
3572
|
+
);
|
|
3573
|
+
const quote = (v: string): string => {
|
|
3574
|
+
v = String(v);
|
|
3575
|
+
if (escaped && /^[=+\-@\t\r]/.test(v)) v = "'" + v;
|
|
3576
|
+
return /[,"\r\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v;
|
|
3577
|
+
};
|
|
3578
|
+
const lines = [["timestamp", ...ss.map((s) => s.name)].map(quote).join(",")],
|
|
3579
|
+
indexes = ss.map(() => 0);
|
|
3580
|
+
for (const t of times) {
|
|
3581
|
+
const cells = [String(t)];
|
|
3582
|
+
ss.forEach((s, j) => {
|
|
3583
|
+
while (indexes[j] < s.timestamps.length && s.timestamps[indexes[j]] < t)
|
|
3584
|
+
indexes[j]++;
|
|
3585
|
+
const i = indexes[j];
|
|
3586
|
+
cells.push(
|
|
3587
|
+
i < s.timestamps.length && s.timestamps[i] === t && finite(s.values[i])
|
|
3588
|
+
? String(s.values[i])
|
|
3589
|
+
: "",
|
|
3590
|
+
);
|
|
3591
|
+
});
|
|
3592
|
+
lines.push(cells.join(","));
|
|
3593
|
+
}
|
|
3594
|
+
return lines.join("\r\n") + "\r\n";
|
|
3595
|
+
}
|
|
3596
|
+
/** Compare RGB pixels without aligning or resizing; alpha is ignored. */
|
|
3597
|
+
export function compareImages(
|
|
3598
|
+
reference: RGBAImage | RenderResult<object>,
|
|
3599
|
+
actual: RGBAImage | RenderResult<object>,
|
|
3600
|
+
options: CompareOptions = {},
|
|
3601
|
+
): PixelDifference {
|
|
3602
|
+
const a = reference instanceof RenderResult ? reference.image : reference,
|
|
3603
|
+
b = actual instanceof RenderResult ? actual.image : actual;
|
|
3604
|
+
validateImage(a);
|
|
3605
|
+
validateImage(b);
|
|
3606
|
+
check(
|
|
3607
|
+
a.width === b.width && a.height === b.height,
|
|
3608
|
+
"Image sizes must match; comparison never aligns or resizes.",
|
|
3609
|
+
);
|
|
3610
|
+
const tolerance = options.tolerance === undefined ? 0 : options.tolerance;
|
|
3611
|
+
integer(tolerance, "Tolerance", 0, 255);
|
|
3612
|
+
const box = options.box || [0, 0, a.width, a.height];
|
|
3613
|
+
check(
|
|
3614
|
+
Array.isArray(box) && box.length === 4,
|
|
3615
|
+
"Comparison box needs four coordinates.",
|
|
3616
|
+
);
|
|
3617
|
+
box.forEach((v) => integer(v, "Comparison coordinate", 0, 65536));
|
|
3618
|
+
const [x0, y0, x1, y1] = box;
|
|
3619
|
+
check(
|
|
3620
|
+
x1 > x0 && y1 > y0 && x1 <= a.width && y1 <= a.height,
|
|
3621
|
+
"Comparison box is out of bounds.",
|
|
3622
|
+
);
|
|
3623
|
+
let exact = 0,
|
|
3624
|
+
within = 0,
|
|
3625
|
+
sum = 0,
|
|
3626
|
+
square = 0,
|
|
3627
|
+
maxError = 0,
|
|
3628
|
+
loX = Infinity,
|
|
3629
|
+
loY = Infinity,
|
|
3630
|
+
hiX = -Infinity,
|
|
3631
|
+
hiY = -Infinity;
|
|
3632
|
+
for (let y = y0; y < y1; y++)
|
|
3633
|
+
for (let x = x0; x < x1; x++) {
|
|
3634
|
+
const i = (y * a.width + x) * 4;
|
|
3635
|
+
let err = 0;
|
|
3636
|
+
for (let k = 0; k < 3; k++) {
|
|
3637
|
+
const d = Math.abs(a.data[i + k] - b.data[i + k]);
|
|
3638
|
+
sum += d;
|
|
3639
|
+
square += d * d;
|
|
3640
|
+
err = Math.max(err, d);
|
|
3641
|
+
}
|
|
3642
|
+
maxError = Math.max(maxError, err);
|
|
3643
|
+
if (err === 0) exact++;
|
|
3644
|
+
else {
|
|
3645
|
+
loX = Math.min(loX, x - x0);
|
|
3646
|
+
loY = Math.min(loY, y - y0);
|
|
3647
|
+
hiX = Math.max(hiX, x - x0);
|
|
3648
|
+
hiY = Math.max(hiY, y - y0);
|
|
3649
|
+
}
|
|
3650
|
+
if (err <= tolerance) within++;
|
|
3651
|
+
}
|
|
3652
|
+
const count = (x1 - x0) * (y1 - y0);
|
|
3653
|
+
return {
|
|
3654
|
+
pixels: count,
|
|
3655
|
+
exactPixels: exact,
|
|
3656
|
+
exactRatio: exact / count,
|
|
3657
|
+
toleranceRatio: within / count,
|
|
3658
|
+
meanAbsoluteError: sum / (count * 3),
|
|
3659
|
+
rootMeanSquareError: Math.sqrt(square / (count * 3)),
|
|
3660
|
+
maxError,
|
|
3661
|
+
differenceBox: loX === Infinity ? null : [loX, loY, hiX + 1, hiY + 1],
|
|
3662
|
+
};
|
|
3663
|
+
}
|
|
3664
|
+
/** Visualize amplified absolute RGB differences in matching images. */
|
|
3665
|
+
export function differenceImage(
|
|
3666
|
+
reference: RGBAImage | RenderResult<object>,
|
|
3667
|
+
actual: RGBAImage | RenderResult<object>,
|
|
3668
|
+
amplify: number = 4,
|
|
3669
|
+
): RenderResult<{ imageSize: [number, number]; amplify: number }> {
|
|
3670
|
+
number(amplify, "Difference amplification", 0, 255);
|
|
3671
|
+
const a = reference instanceof RenderResult ? reference.image : reference,
|
|
3672
|
+
b = actual instanceof RenderResult ? actual.image : actual;
|
|
3673
|
+
compareImages(a, b);
|
|
3674
|
+
const out = new Surface(a.width, a.height);
|
|
3675
|
+
for (let i = 0; i < a.data.length; i += 4) {
|
|
3676
|
+
for (let k = 0; k < 3; k++)
|
|
3677
|
+
out.data[i + k] = Math.min(
|
|
3678
|
+
255,
|
|
3679
|
+
round(Math.abs(a.data[i + k] - b.data[i + k]) * amplify),
|
|
3680
|
+
);
|
|
3681
|
+
out.data[i + 3] = 255;
|
|
3682
|
+
}
|
|
3683
|
+
return new RenderResult(out, { imageSize: [out.width, out.height], amplify });
|
|
3684
|
+
}
|
|
3685
|
+
/** Copy Canvas2D pixels into independently owned RGBA storage. */
|
|
3686
|
+
export function readCanvas(canvas: Canvas): RGBAImage {
|
|
3687
|
+
check(
|
|
3688
|
+
canvas && typeof canvas.getContext === "function",
|
|
3689
|
+
"Expected a canvas.",
|
|
3690
|
+
);
|
|
3691
|
+
const c = canvas.getContext("2d") as Context2D | null;
|
|
3692
|
+
check(c, "Canvas 2D is unavailable.");
|
|
3693
|
+
const data = c.getImageData(0, 0, canvas.width, canvas.height);
|
|
3694
|
+
const out = new Surface(data.width, data.height);
|
|
3695
|
+
out.data.set(data.data);
|
|
3696
|
+
return out;
|
|
3697
|
+
}
|
|
3698
|
+
/** Check PNG dimensions and decode with the browser image decoder. */
|
|
3699
|
+
export async function decodeImage(blob: Blob): Promise<RGBAImage> {
|
|
3700
|
+
check(
|
|
3701
|
+
typeof Blob !== "undefined" && blob instanceof Blob,
|
|
3702
|
+
"decodeImage expects a Blob or File.",
|
|
3703
|
+
);
|
|
3704
|
+
check(blob.size <= 67108864, "Image file exceeds 64 MiB.");
|
|
3705
|
+
// Header dimensions are checked before a PNG is passed to the browser decoder.
|
|
3706
|
+
const head = new Uint8Array(await blob.slice(0, 24).arrayBuffer());
|
|
3707
|
+
check(
|
|
3708
|
+
head.length >= 24 &&
|
|
3709
|
+
[137, 80, 78, 71, 13, 10, 26, 10].every((v, i) => head[i] === v) &&
|
|
3710
|
+
head[12] === 73 &&
|
|
3711
|
+
head[13] === 72 &&
|
|
3712
|
+
head[14] === 68 &&
|
|
3713
|
+
head[15] === 82,
|
|
3714
|
+
"decodeImage accepts PNG files only.",
|
|
3715
|
+
);
|
|
3716
|
+
const headerView = new DataView(head.buffer),
|
|
3717
|
+
width = headerView.getUint32(16),
|
|
3718
|
+
height = headerView.getUint32(20);
|
|
3719
|
+
check(
|
|
3720
|
+
width > 0 && height > 0 && width * height <= LIMITS.pixels,
|
|
3721
|
+
"Decoded PNG exceeds the pixel limit.",
|
|
3722
|
+
);
|
|
3723
|
+
check(
|
|
3724
|
+
typeof createImageBitmap === "function",
|
|
3725
|
+
"This browser does not expose createImageBitmap.",
|
|
3726
|
+
);
|
|
3727
|
+
const bitmap = await createImageBitmap(blob);
|
|
3728
|
+
try {
|
|
3729
|
+
check(
|
|
3730
|
+
bitmap.width * bitmap.height <= LIMITS.pixels,
|
|
3731
|
+
"Decoded image exceeds the pixel limit.",
|
|
3732
|
+
);
|
|
3733
|
+
const canvas = createCanvas(bitmap.width, bitmap.height);
|
|
3734
|
+
const ctx = canvas.getContext("2d") as Context2D | null;
|
|
3735
|
+
check(ctx, "Canvas 2D is unavailable.");
|
|
3736
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
3737
|
+
return readCanvas(canvas);
|
|
3738
|
+
} finally {
|
|
3739
|
+
bitmap.close();
|
|
3740
|
+
}
|
|
3741
|
+
}
|
|
3742
|
+
// Browser controller. Cursor and tooltip live on an overlay, never in exported pixels.
|
|
3743
|
+
class BrowserController implements Controller {
|
|
3744
|
+
chart: Chart;
|
|
3745
|
+
readonly canvas: MountedCanvas;
|
|
3746
|
+
readonly options: MountOptions & { interactive: boolean };
|
|
3747
|
+
destroyed: boolean;
|
|
3748
|
+
result: RenderResult;
|
|
3749
|
+
private readonly handlers: [HTMLCanvasElement, string, EventListener][];
|
|
3750
|
+
private cursorTime: number | null;
|
|
3751
|
+
private readonly saved: {
|
|
3752
|
+
style: string | null;
|
|
3753
|
+
role: string | null;
|
|
3754
|
+
label: string | null;
|
|
3755
|
+
tab: string | null;
|
|
3756
|
+
};
|
|
3757
|
+
private readonly marker: Comment;
|
|
3758
|
+
private readonly wrap: HTMLSpanElement;
|
|
3759
|
+
private readonly overlay: HTMLCanvasElement;
|
|
3760
|
+
private readonly tip: HTMLDivElement;
|
|
3761
|
+
private readonly status: HTMLSpanElement;
|
|
3762
|
+
constructor(chart: Chart, canvas: MountedCanvas, options: MountOptions = {}) {
|
|
3763
|
+
check(
|
|
3764
|
+
typeof document !== "undefined" && canvas && canvas.ownerDocument,
|
|
3765
|
+
"mount requires an HTML canvas in a document.",
|
|
3766
|
+
);
|
|
3767
|
+
check(canvas.parentNode, "Attach the canvas to the document before mount.");
|
|
3768
|
+
check(
|
|
3769
|
+
!canvas.__bamtiGraphController,
|
|
3770
|
+
"A controller is already mounted on this canvas.",
|
|
3771
|
+
);
|
|
3772
|
+
this.chart = chart;
|
|
3773
|
+
this.canvas = canvas;
|
|
3774
|
+
this.options = { interactive: true, ...options };
|
|
3775
|
+
this.destroyed = false;
|
|
3776
|
+
this.handlers = [];
|
|
3777
|
+
this.cursorTime = null;
|
|
3778
|
+
this.result = chart.render();
|
|
3779
|
+
this.saved = {
|
|
3780
|
+
style: canvas.getAttribute("style"),
|
|
3781
|
+
role: canvas.getAttribute("role"),
|
|
3782
|
+
label: canvas.getAttribute("aria-label"),
|
|
3783
|
+
tab: canvas.getAttribute("tabindex"),
|
|
3784
|
+
};
|
|
3785
|
+
this.marker = document.createComment("canvas position");
|
|
3786
|
+
canvas.parentNode.insertBefore(this.marker, canvas);
|
|
3787
|
+
this.wrap = document.createElement("span");
|
|
3788
|
+
this.wrap.style.cssText =
|
|
3789
|
+
"position:relative;display:inline-block;vertical-align:top;line-height:0;max-width:none;";
|
|
3790
|
+
canvas.parentNode.insertBefore(this.wrap, canvas);
|
|
3791
|
+
this.wrap.appendChild(canvas);
|
|
3792
|
+
canvas.style.display = "block";
|
|
3793
|
+
canvas.style.maxWidth = "none";
|
|
3794
|
+
canvas.setAttribute("role", "img");
|
|
3795
|
+
this.overlay = document.createElement("canvas");
|
|
3796
|
+
this.overlay.setAttribute("aria-hidden", "true");
|
|
3797
|
+
this.overlay.style.cssText =
|
|
3798
|
+
"position:absolute;left:0;top:0;pointer-events:none;";
|
|
3799
|
+
this.wrap.appendChild(this.overlay);
|
|
3800
|
+
this.tip = document.createElement("div");
|
|
3801
|
+
this.tip.hidden = true;
|
|
3802
|
+
this.tip.setAttribute("role", "status");
|
|
3803
|
+
this.tip.style.cssText =
|
|
3804
|
+
"position:absolute;z-index:5;pointer-events:none;background:#111827;color:#fff;border:1px solid #4b5563;padding:9px 11px;border-radius:5px;font:11px/1.65 ui-monospace,monospace;white-space:pre;box-shadow:0 4px 18px #0003;text-align:left;";
|
|
3805
|
+
this.wrap.appendChild(this.tip);
|
|
3806
|
+
this.status = document.createElement("span");
|
|
3807
|
+
this.status.setAttribute("aria-live", "polite");
|
|
3808
|
+
this.status.style.cssText =
|
|
3809
|
+
"position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;";
|
|
3810
|
+
this.wrap.appendChild(this.status);
|
|
3811
|
+
this.apply(this.result);
|
|
3812
|
+
canvas.__bamtiGraphController = this;
|
|
3813
|
+
if (this.options.interactive) {
|
|
3814
|
+
canvas.tabIndex = 0;
|
|
3815
|
+
this.on(canvas, "pointermove", (e) => {
|
|
3816
|
+
const rect = canvas.getBoundingClientRect(),
|
|
3817
|
+
x = ((e.clientX - rect.left) * canvas.width) / rect.width,
|
|
3818
|
+
y = ((e.clientY - rect.top) * canvas.height) / rect.height,
|
|
3819
|
+
m = this.result.metadata,
|
|
3820
|
+
s = m.pixelScale,
|
|
3821
|
+
[l, t, r, b] = m.plotBox;
|
|
3822
|
+
if (x < l * s || x > r * s || y < t * s || y > b * s) {
|
|
3823
|
+
this.clear();
|
|
3824
|
+
return;
|
|
3825
|
+
}
|
|
3826
|
+
const time =
|
|
3827
|
+
m.timeRange[0] +
|
|
3828
|
+
((x / s - l) / (r - l)) * (m.timeRange[1] - m.timeRange[0]);
|
|
3829
|
+
this.show(time, false);
|
|
3830
|
+
});
|
|
3831
|
+
this.on(canvas, "pointerleave", () => this.clear());
|
|
3832
|
+
this.on(canvas, "blur", () => this.clear());
|
|
3833
|
+
this.on(canvas, "keydown", (e) => {
|
|
3834
|
+
if (
|
|
3835
|
+
!["ArrowLeft", "ArrowRight", "Home", "End", "Escape"].includes(e.key)
|
|
3836
|
+
)
|
|
3837
|
+
return;
|
|
3838
|
+
e.preventDefault();
|
|
3839
|
+
if (e.key === "Escape") {
|
|
3840
|
+
this.clear();
|
|
3841
|
+
return;
|
|
3842
|
+
}
|
|
3843
|
+
const [a, b] = this.result.metadata.timeRange,
|
|
3844
|
+
s = this.chart.config.series.find((s) => s.timestamps.length),
|
|
3845
|
+
visible = s
|
|
3846
|
+
? s.timestamps.slice(
|
|
3847
|
+
lowerBound(s.timestamps, a),
|
|
3848
|
+
upperBound(s.timestamps, b),
|
|
3849
|
+
)
|
|
3850
|
+
: [];
|
|
3851
|
+
let time;
|
|
3852
|
+
if (e.key === "Home") time = visible[0] === undefined ? a : visible[0];
|
|
3853
|
+
else if (e.key === "End")
|
|
3854
|
+
time = visible.length ? visible[visible.length - 1] : b;
|
|
3855
|
+
else if (visible.length) {
|
|
3856
|
+
let i =
|
|
3857
|
+
this.cursorTime === null
|
|
3858
|
+
? e.key === "ArrowRight"
|
|
3859
|
+
? -1
|
|
3860
|
+
: visible.length
|
|
3861
|
+
: lowerBound(visible, this.cursorTime);
|
|
3862
|
+
i = clamp(
|
|
3863
|
+
i + (e.key === "ArrowRight" ? 1 : -1) * (e.shiftKey ? 10 : 1),
|
|
3864
|
+
0,
|
|
3865
|
+
visible.length - 1,
|
|
3866
|
+
);
|
|
3867
|
+
time = visible[i];
|
|
3868
|
+
} else
|
|
3869
|
+
time = clamp(
|
|
3870
|
+
(this.cursorTime === null ? a : this.cursorTime) +
|
|
3871
|
+
((e.key === "ArrowRight" ? 1 : -1) * (b - a)) / 100,
|
|
3872
|
+
a,
|
|
3873
|
+
b,
|
|
3874
|
+
);
|
|
3875
|
+
this.show(time, true);
|
|
3876
|
+
});
|
|
3877
|
+
}
|
|
3878
|
+
}
|
|
3879
|
+
on<K extends keyof HTMLElementEventMap>(
|
|
3880
|
+
target: HTMLCanvasElement,
|
|
3881
|
+
type: K,
|
|
3882
|
+
fn: (event: HTMLElementEventMap[K]) => void,
|
|
3883
|
+
): void {
|
|
3884
|
+
target.addEventListener(type, fn);
|
|
3885
|
+
this.handlers.push([target, type, fn as EventListener]);
|
|
3886
|
+
}
|
|
3887
|
+
apply(r: RenderResult): void {
|
|
3888
|
+
this.result = r;
|
|
3889
|
+
r.draw(this.canvas);
|
|
3890
|
+
this.overlay.width = r.width;
|
|
3891
|
+
this.overlay.height = r.height;
|
|
3892
|
+
const m = r.metadata;
|
|
3893
|
+
this.canvas.setAttribute(
|
|
3894
|
+
"aria-label",
|
|
3895
|
+
this.options.ariaLabel ||
|
|
3896
|
+
[
|
|
3897
|
+
m.title || "Time-series chart",
|
|
3898
|
+
m.verticalLabel,
|
|
3899
|
+
...m.statistics.map(
|
|
3900
|
+
(s) =>
|
|
3901
|
+
s.name +
|
|
3902
|
+
": current " +
|
|
3903
|
+
(s.current === null
|
|
3904
|
+
? "missing"
|
|
3905
|
+
: formatValue(s.current, m.yUnit, 2)),
|
|
3906
|
+
),
|
|
3907
|
+
].join(". "),
|
|
3908
|
+
);
|
|
3909
|
+
this.wrap.style.width = r.width + "px";
|
|
3910
|
+
this.wrap.style.height = r.height + "px";
|
|
3911
|
+
this.clear();
|
|
3912
|
+
}
|
|
3913
|
+
show(time: number, announce: boolean): void {
|
|
3914
|
+
if (this.destroyed) return;
|
|
3915
|
+
const m = this.result.metadata,
|
|
3916
|
+
s = m.pixelScale,
|
|
3917
|
+
[left, top, right, bottom] = m.plotBox,
|
|
3918
|
+
ctx = this.overlay.getContext("2d")!,
|
|
3919
|
+
x =
|
|
3920
|
+
(left +
|
|
3921
|
+
((time - m.timeRange[0]) / (m.timeRange[1] - m.timeRange[0])) *
|
|
3922
|
+
(right - left)) *
|
|
3923
|
+
s;
|
|
3924
|
+
ctx.clearRect(0, 0, this.overlay.width, this.overlay.height);
|
|
3925
|
+
ctx.beginPath();
|
|
3926
|
+
ctx.strokeStyle = "#555";
|
|
3927
|
+
ctx.lineWidth = 1;
|
|
3928
|
+
ctx.setLineDash([2, 2]);
|
|
3929
|
+
ctx.moveTo(round(x) + 0.5, top * s);
|
|
3930
|
+
ctx.lineTo(round(x) + 0.5, bottom * s);
|
|
3931
|
+
ctx.stroke();
|
|
3932
|
+
const nearest = this.chart.nearest(time),
|
|
3933
|
+
rows = ["Nearest samples"];
|
|
3934
|
+
for (const p of nearest)
|
|
3935
|
+
rows.push(
|
|
3936
|
+
p.name +
|
|
3937
|
+
": " +
|
|
3938
|
+
formatValue(p.value, m.yUnit, 2, this.chart.config.missingLabel) +
|
|
3939
|
+
(p.time !== null
|
|
3940
|
+
? " " + formatTime(p.time, m.timezone, "%H:%M:%S")
|
|
3941
|
+
: " no observation"),
|
|
3942
|
+
);
|
|
3943
|
+
this.tip.textContent = rows.join("\n");
|
|
3944
|
+
this.tip.hidden = false;
|
|
3945
|
+
this.tip.style.left =
|
|
3946
|
+
Math.max(
|
|
3947
|
+
4,
|
|
3948
|
+
Math.min(this.result.width - this.tip.offsetWidth - 4, x + 12),
|
|
3949
|
+
) + "px";
|
|
3950
|
+
this.tip.style.top = top * s + 8 + "px";
|
|
3951
|
+
this.cursorTime = time;
|
|
3952
|
+
if (announce) this.status.textContent = rows.join(". ");
|
|
3953
|
+
if (typeof this.options.onHover === "function")
|
|
3954
|
+
this.options.onHover({ time, samples: nearest });
|
|
3955
|
+
}
|
|
3956
|
+
clear(): void {
|
|
3957
|
+
if (this.overlay)
|
|
3958
|
+
this.overlay
|
|
3959
|
+
.getContext("2d")!
|
|
3960
|
+
.clearRect(0, 0, this.overlay.width, this.overlay.height);
|
|
3961
|
+
if (this.tip) this.tip.hidden = true;
|
|
3962
|
+
this.cursorTime = null;
|
|
3963
|
+
}
|
|
3964
|
+
update(patch: ChartOptions | Chart): RenderResult {
|
|
3965
|
+
check(!this.destroyed, "Controller has been destroyed.");
|
|
3966
|
+
const next = patch instanceof Chart ? patch : this.chart.with(patch),
|
|
3967
|
+
result = next.render();
|
|
3968
|
+
this.chart = next;
|
|
3969
|
+
this.apply(result);
|
|
3970
|
+
return result;
|
|
3971
|
+
}
|
|
3972
|
+
destroy(): void {
|
|
3973
|
+
if (this.destroyed) return;
|
|
3974
|
+
this.destroyed = true;
|
|
3975
|
+
for (const [el, type, fn] of this.handlers)
|
|
3976
|
+
el.removeEventListener(type, fn);
|
|
3977
|
+
this.handlers.length = 0;
|
|
3978
|
+
if (this.marker.parentNode) {
|
|
3979
|
+
this.marker.parentNode.insertBefore(this.canvas, this.marker);
|
|
3980
|
+
this.marker.remove();
|
|
3981
|
+
} else this.wrap.removeChild(this.canvas);
|
|
3982
|
+
this.wrap.remove();
|
|
3983
|
+
for (const [key, attr] of [
|
|
3984
|
+
["style", "style"],
|
|
3985
|
+
["role", "role"],
|
|
3986
|
+
["label", "aria-label"],
|
|
3987
|
+
["tab", "tabindex"],
|
|
3988
|
+
] as const) {
|
|
3989
|
+
if (this.saved[key] === null) this.canvas.removeAttribute(attr);
|
|
3990
|
+
else this.canvas.setAttribute(attr, this.saved[key]);
|
|
3991
|
+
}
|
|
3992
|
+
delete this.canvas.__bamtiGraphController;
|
|
3993
|
+
}
|
|
3994
|
+
}
|
|
3995
|
+
|
|
3996
|
+
/** Return a detached copy of every chart default. */
|
|
3997
|
+
export function defaults(): ChartOptions {
|
|
3998
|
+
return clone(DEFAULTS);
|
|
3999
|
+
}
|
|
4000
|
+
/** Render a chart once; bitmap mode does not access browser APIs. */
|
|
4001
|
+
export function render(options: ChartOptions = {}): RenderResult {
|
|
4002
|
+
return new Chart(options).render();
|
|
4003
|
+
}
|
|
4004
|
+
/** Frozen default export, equivalent to the corresponding named exports. */
|
|
4005
|
+
export interface BamtiGraphAPI {
|
|
4006
|
+
readonly VERSION: typeof VERSION;
|
|
4007
|
+
readonly LIMITS: typeof LIMITS;
|
|
4008
|
+
readonly Chart: typeof Chart;
|
|
4009
|
+
readonly RenderResult: typeof RenderResult;
|
|
4010
|
+
readonly series: typeof series;
|
|
4011
|
+
readonly regularSeries: typeof regularSeries;
|
|
4012
|
+
readonly traffic: typeof traffic;
|
|
4013
|
+
readonly daily: typeof daily;
|
|
4014
|
+
readonly weekly: typeof weekly;
|
|
4015
|
+
readonly monthly: typeof monthly;
|
|
4016
|
+
readonly yearly: typeof yearly;
|
|
4017
|
+
readonly dashboard: typeof dashboard;
|
|
4018
|
+
readonly counterRate: typeof counterRate;
|
|
4019
|
+
readonly aggregate: typeof aggregate;
|
|
4020
|
+
readonly parseCSV: typeof parseCSV;
|
|
4021
|
+
readonly toCSV: typeof toCSV;
|
|
4022
|
+
readonly compareImages: typeof compareImages;
|
|
4023
|
+
readonly differenceImage: typeof differenceImage;
|
|
4024
|
+
readonly encodePNG: typeof encodePNG;
|
|
4025
|
+
readonly decodeImage: typeof decodeImage;
|
|
4026
|
+
readonly readCanvas: typeof readCanvas;
|
|
4027
|
+
readonly drawImageToCanvas: typeof drawImageToCanvas;
|
|
4028
|
+
readonly downloadBytes: typeof downloadBytes;
|
|
4029
|
+
readonly formatTime: typeof formatTime;
|
|
4030
|
+
readonly formatValue: typeof formatValue;
|
|
4031
|
+
readonly epoch: typeof epoch;
|
|
4032
|
+
readonly color: typeof color;
|
|
4033
|
+
readonly defaults: typeof defaults;
|
|
4034
|
+
readonly render: typeof render;
|
|
4035
|
+
}
|
|
4036
|
+
|
|
4037
|
+
/** The complete BamtiGraph API; importing this module creates no global. */
|
|
4038
|
+
const BamtiGraph: Readonly<BamtiGraphAPI> = Object.freeze({
|
|
4039
|
+
VERSION,
|
|
4040
|
+
LIMITS,
|
|
4041
|
+
Chart,
|
|
4042
|
+
RenderResult,
|
|
4043
|
+
series,
|
|
4044
|
+
regularSeries,
|
|
4045
|
+
traffic,
|
|
4046
|
+
daily,
|
|
4047
|
+
weekly,
|
|
4048
|
+
monthly,
|
|
4049
|
+
yearly,
|
|
4050
|
+
dashboard,
|
|
4051
|
+
counterRate,
|
|
4052
|
+
aggregate,
|
|
4053
|
+
parseCSV,
|
|
4054
|
+
toCSV,
|
|
4055
|
+
compareImages,
|
|
4056
|
+
differenceImage,
|
|
4057
|
+
encodePNG,
|
|
4058
|
+
decodeImage,
|
|
4059
|
+
readCanvas,
|
|
4060
|
+
drawImageToCanvas,
|
|
4061
|
+
downloadBytes,
|
|
4062
|
+
formatTime,
|
|
4063
|
+
formatValue,
|
|
4064
|
+
epoch,
|
|
4065
|
+
color,
|
|
4066
|
+
defaults,
|
|
4067
|
+
render,
|
|
4068
|
+
});
|
|
4069
|
+
export default BamtiGraph;
|