ml-time-graph 1.0.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.
@@ -0,0 +1,141 @@
1
+ import { l as DataPoint, p as DrawCommand, ae as TimeScale, W as LinearScale } from '../scale-Cbr0KpPz.js';
2
+
3
+ /*!
4
+ * MLTimeGraph — Copyright (c) 2026 Michael Lechner
5
+ * MIT with Attribution: free use incl. commercial requires visible credit to
6
+ * "Michael Lechner". Commercial license (no attribution) on request. See LICENSE.
7
+ */
8
+ /** Configuration for zoom behavior (speed, min/max domain). */
9
+ interface ZoomConfig {
10
+ /** Enable or disable zoom interaction (default true). */
11
+ enabled?: boolean;
12
+ /** Zoom speed factor per wheel event (default 0.1). */
13
+ zoomSpeed?: number;
14
+ /** Minimum time span in ms before zoom stops (default 0). */
15
+ minDomainMs?: number;
16
+ /** Maximum time span in ms (default 365 days). */
17
+ maxDomainMs?: number;
18
+ }
19
+ /** Current zoom state including zoomed domain, scale factor, and offset. */
20
+ interface ZoomState {
21
+ domain: [number, number];
22
+ originalDomain: [number, number];
23
+ scale: number;
24
+ offset: number;
25
+ }
26
+ /** Handles mouse/trackpad zoom and pan, with clamping to original data domain. */
27
+ declare class Zoom {
28
+ private readonly _config;
29
+ private _state;
30
+ constructor(initialDomain: [number, number], config?: ZoomConfig);
31
+ get state(): ZoomState;
32
+ applyZoom(x: number, delta: number, rangeWidth: number): void;
33
+ applyPan(pixelDelta: number, rangeWidth: number): void;
34
+ reset(): void;
35
+ get enabled(): boolean;
36
+ }
37
+
38
+ /*!
39
+ * MLTimeGraph — Copyright (c) 2026 Michael Lechner
40
+ * MIT with Attribution: free use incl. commercial requires visible credit to
41
+ * "Michael Lechner". Commercial license (no attribution) on request. See LICENSE.
42
+ */
43
+
44
+ /** Configuration for minimap overview (thumbnail chart with viewport indicator). */
45
+ interface MinimapConfig {
46
+ /** Data points for overview */
47
+ data: DataPoint[];
48
+ /** Minimap dimensions */
49
+ width: number;
50
+ height: number;
51
+ /** Position */
52
+ x: number;
53
+ y: number;
54
+ /** Zoom state for brush indicator */
55
+ zoom?: Zoom;
56
+ /** Line color */
57
+ stroke?: string;
58
+ /** Background color */
59
+ bgColor?: string;
60
+ /** Brush (viewport) color */
61
+ brushColor?: string;
62
+ }
63
+ /** Renders a minimap (thumbnail overview) with an optional brush indicator showing the current viewport. */
64
+ declare class Minimap {
65
+ private readonly _data;
66
+ private readonly _width;
67
+ private readonly _height;
68
+ private readonly _x;
69
+ private readonly _y;
70
+ private readonly _zoom?;
71
+ private readonly _stroke;
72
+ private readonly _bgColor;
73
+ private readonly _brushColor;
74
+ constructor(config: MinimapConfig);
75
+ /** Render minimap as draw commands */
76
+ render(): DrawCommand[];
77
+ /** Convert minimap pixel X to time value (for click-to-navigate) */
78
+ navigateTo(pixelX: number): number;
79
+ }
80
+
81
+ /*!
82
+ * MLTimeGraph — Copyright (c) 2026 Michael Lechner
83
+ * MIT with Attribution: free use incl. commercial requires visible credit to
84
+ * "Michael Lechner". Commercial license (no attribution) on request. See LICENSE.
85
+ */
86
+
87
+ /** Configuration for tooltip rendering (crosshair, data snap, and value display). */
88
+ interface TooltipConfig {
89
+ /** X position in pixels */
90
+ mouseX: number;
91
+ /** Y position in pixels */
92
+ mouseY: number;
93
+ /** Time scale */
94
+ timeScale: TimeScale;
95
+ /** Value scale */
96
+ valueScale: LinearScale;
97
+ /** Data points */
98
+ data: DataPoint[];
99
+ /** X range of the chart */
100
+ xRange: [number, number];
101
+ /** Y range of the chart */
102
+ yRange: [number, number];
103
+ /** Maximum snap distance in pixels */
104
+ snapRadius?: number;
105
+ /** Tooltip background */
106
+ bgColor?: string;
107
+ /** Tooltip border */
108
+ borderColor?: string;
109
+ /** Label color */
110
+ textColor?: string;
111
+ }
112
+ /** Tooltip result containing visibility state, render commands, and snapped data point. */
113
+ interface TooltipResult {
114
+ /** Whether tooltip should be shown */
115
+ visible: boolean;
116
+ /** Commands to render the tooltip */
117
+ commands: DrawCommand[];
118
+ /** Closest data point (if any) */
119
+ dataPoint?: DataPoint;
120
+ /** Snapped pixel X position */
121
+ snapX?: number;
122
+ }
123
+ /** Computes tooltip visibility and renders crosshair + value box at the nearest data point. */
124
+ declare class Tooltip {
125
+ private readonly _mouseX;
126
+ private readonly _mouseY;
127
+ private readonly _timeScale;
128
+ private readonly _valueScale;
129
+ private readonly _data;
130
+ private readonly _xRange;
131
+ private readonly _yRange;
132
+ private readonly _snapRadius;
133
+ private readonly _bgColor;
134
+ private readonly _borderColor;
135
+ private readonly _textColor;
136
+ constructor(config: TooltipConfig);
137
+ /** Compute tooltip visibility and render commands */
138
+ compute(): TooltipResult;
139
+ }
140
+
141
+ export { Minimap, type MinimapConfig, Tooltip, type TooltipConfig, type TooltipResult, Zoom, type ZoomConfig, type ZoomState };
@@ -0,0 +1,438 @@
1
+ // src/core/scale.ts
2
+ var LinearScale = class {
3
+ #domain;
4
+ #range;
5
+ constructor(config) {
6
+ this.#domain = [...config.domain];
7
+ this.#range = [...config.range];
8
+ }
9
+ map(value) {
10
+ const v = Number(value);
11
+ const [d0, d1] = this.#domain;
12
+ const [r0, r1] = this.#range;
13
+ if (d1 === d0) return r0;
14
+ return r0 + (v - d0) / (d1 - d0) * (r1 - r0);
15
+ }
16
+ invert(pixel) {
17
+ const [d0, d1] = this.#domain;
18
+ const [r0, r1] = this.#range;
19
+ if (r1 === r0) return d0;
20
+ return d0 + (pixel - r0) / (r1 - r0) * (d1 - d0);
21
+ }
22
+ domain() {
23
+ return [...this.#domain];
24
+ }
25
+ range() {
26
+ return [...this.#range];
27
+ }
28
+ };
29
+ var TIME_INTERVALS = [
30
+ { label: "second", ms: 1e3 },
31
+ { label: "2_seconds", ms: 2e3 },
32
+ { label: "5_seconds", ms: 5e3 },
33
+ { label: "10_seconds", ms: 1e4 },
34
+ { label: "30_seconds", ms: 3e4 },
35
+ { label: "minute", ms: 6e4 },
36
+ { label: "5_minutes", ms: 3e5 },
37
+ { label: "15_minutes", ms: 9e5 },
38
+ { label: "30_minutes", ms: 18e5 },
39
+ { label: "hour", ms: 36e5 },
40
+ { label: "3_hours", ms: 108e5 },
41
+ { label: "6_hours", ms: 216e5 },
42
+ { label: "day", ms: 864e5 },
43
+ { label: "week", ms: 6048e5 },
44
+ { label: "month", ms: 2592e6 },
45
+ { label: "3_months", ms: 7776e6 },
46
+ { label: "6_months", ms: 15552e6 },
47
+ { label: "year", ms: 31536e6 },
48
+ { label: "2_years", ms: 63072e6 },
49
+ { label: "5_years", ms: 15768e7 }
50
+ ];
51
+ var TimeScale = class {
52
+ #linear;
53
+ #locale;
54
+ constructor(config) {
55
+ this.#linear = new LinearScale({
56
+ domain: config.domain,
57
+ range: config.range
58
+ });
59
+ this.#locale = config.locale || (typeof navigator !== "undefined" ? navigator.language : "en-US");
60
+ }
61
+ map(value) {
62
+ return this.#linear.map(Number(value));
63
+ }
64
+ invert(pixel) {
65
+ return this.#linear.invert(pixel);
66
+ }
67
+ domain() {
68
+ return this.#linear.domain();
69
+ }
70
+ range() {
71
+ return this.#linear.range();
72
+ }
73
+ get locale() {
74
+ return this.#locale;
75
+ }
76
+ /**
77
+ * Pick the "nicest" time interval that yields roughly `targetTicks` ticks
78
+ * across the visible range. Clamps to minTicks / maxTicks bounds.
79
+ */
80
+ tickInterval(targetTicks, minTicks = 3, maxTicks = 12) {
81
+ const [d0, d1] = this.#linear.domain();
82
+ const totalMs = d1 - d0;
83
+ if (totalMs <= 0) return { interval: TIME_INTERVALS[0].ms };
84
+ const ideal = totalMs / targetTicks;
85
+ let picked = TIME_INTERVALS[0].ms;
86
+ for (const t of TIME_INTERVALS) {
87
+ if (t.ms >= ideal) {
88
+ picked = t.ms;
89
+ break;
90
+ }
91
+ }
92
+ let candidate = picked;
93
+ let count = Math.round(totalMs / candidate);
94
+ while (count > maxTicks && candidate < TIME_INTERVALS[TIME_INTERVALS.length - 1].ms) {
95
+ const idx = TIME_INTERVALS.findIndex((t) => t.ms === candidate);
96
+ candidate = TIME_INTERVALS[Math.min(idx + 1, TIME_INTERVALS.length - 1)].ms;
97
+ count = Math.round(totalMs / candidate);
98
+ }
99
+ while (count < minTicks && candidate > TIME_INTERVALS[0].ms) {
100
+ const idx = TIME_INTERVALS.findIndex((t) => t.ms === candidate);
101
+ candidate = TIME_INTERVALS[Math.max(idx - 1, 0)].ms;
102
+ count = Math.round(totalMs / candidate);
103
+ }
104
+ return { interval: candidate };
105
+ }
106
+ /**
107
+ * Generate tick positions (timestamps) across the domain.
108
+ */
109
+ ticks(opts) {
110
+ const minT = opts?.minTicks ?? 5;
111
+ const maxT = opts?.maxTicks ?? 12;
112
+ const { interval } = this.tickInterval(
113
+ (minT + maxT) / 2,
114
+ minT,
115
+ maxT
116
+ );
117
+ const [d0, d1] = this.#linear.domain();
118
+ const result = [];
119
+ const start = Math.ceil(d0 / interval) * interval;
120
+ for (let t = start; t <= d1; t += interval) {
121
+ result.push(t);
122
+ }
123
+ return result;
124
+ }
125
+ /** Format a timestamp using Intl.DateTimeFormat */
126
+ format(timestamp, formatOpts) {
127
+ return new Intl.DateTimeFormat(this.#locale, formatOpts).format(
128
+ new Date(timestamp)
129
+ );
130
+ }
131
+ };
132
+
133
+ // src/interaction/zoom.ts
134
+ var Zoom = class {
135
+ _config;
136
+ _state;
137
+ constructor(initialDomain, config) {
138
+ this._config = {
139
+ enabled: config?.enabled ?? true,
140
+ zoomSpeed: config?.zoomSpeed ?? 0.1,
141
+ minDomainMs: config?.minDomainMs ?? 0,
142
+ maxDomainMs: config?.maxDomainMs ?? 365 * 24 * 60 * 60 * 1e3
143
+ };
144
+ this._state = {
145
+ domain: [...initialDomain],
146
+ originalDomain: [...initialDomain],
147
+ scale: 1,
148
+ offset: 0
149
+ };
150
+ }
151
+ get state() {
152
+ return { ...this._state, domain: [...this._state.domain] };
153
+ }
154
+ applyZoom(x, delta, rangeWidth) {
155
+ if (!this._config.enabled) return;
156
+ const factor = 1 + Math.sign(delta) * this._config.zoomSpeed;
157
+ if (factor <= 0) return;
158
+ const [d0, d1] = this._state.domain;
159
+ const span = d1 - d0;
160
+ const ratio = x / rangeWidth;
161
+ let newSpan = span / factor;
162
+ const minSpan = this._config.minDomainMs ?? 0;
163
+ const maxSpan = this._config.maxDomainMs ?? Infinity;
164
+ if (newSpan < minSpan) newSpan = minSpan;
165
+ if (newSpan > maxSpan) newSpan = maxSpan;
166
+ const focalPoint = d0 + span * ratio;
167
+ let newD0 = focalPoint - newSpan * ratio;
168
+ let newD1 = focalPoint + newSpan * (1 - ratio);
169
+ const [orig0, orig1] = this._state.originalDomain;
170
+ if (newD1 - newD0 > orig1 - orig0) {
171
+ newD0 = orig0;
172
+ newD1 = orig1;
173
+ newSpan = orig1 - orig0;
174
+ } else {
175
+ if (newD0 < orig0) {
176
+ newD1 = orig1 - (orig1 - orig0) * ((newD1 - newD0) / (orig1 - orig0));
177
+ newD0 = orig0;
178
+ }
179
+ if (newD1 > orig1) {
180
+ newD0 = orig0 + (orig1 - orig0) * ((newD0 - orig0) / (orig1 - orig0));
181
+ newD1 = orig1;
182
+ }
183
+ }
184
+ this._state.domain = [newD0, newD1];
185
+ this._state.scale = (d1 - d0) / newSpan;
186
+ }
187
+ applyPan(pixelDelta, rangeWidth) {
188
+ if (!this._config.enabled) return;
189
+ const [d0, d1] = this._state.domain;
190
+ const span = d1 - d0;
191
+ const [orig0, orig1] = this._state.originalDomain;
192
+ let shift = -(pixelDelta / rangeWidth) * span;
193
+ let newD0 = d0 + shift;
194
+ let newD1 = d1 + shift;
195
+ if (newD0 < orig0) {
196
+ shift -= newD0 - orig0;
197
+ }
198
+ if (newD1 > orig1) {
199
+ shift -= newD1 - orig1;
200
+ }
201
+ this._state.domain = [d0 + shift, d1 + shift];
202
+ }
203
+ reset() {
204
+ this._state.domain = [...this._state.originalDomain];
205
+ this._state.scale = 1;
206
+ this._state.offset = 0;
207
+ }
208
+ get enabled() {
209
+ return this._config.enabled ?? true;
210
+ }
211
+ };
212
+
213
+ // src/theme/defaults.ts
214
+ var theme = {
215
+ // ── Tooltip ──
216
+ /** Tooltip box background */
217
+ tooltipBg: "#fff",
218
+ /** Tooltip border */
219
+ tooltipBorder: "#cbd5e1",
220
+ /** Tooltip text color */
221
+ tooltipText: "#1e293b",
222
+ /** Tooltip snap radius in pixels */
223
+ tooltipSnapRadius: 20,
224
+ // ── Minimap ──
225
+ /** Minimap overview line stroke */
226
+ minimapStroke: "#94a3b8",
227
+ /** Minimap background */
228
+ minimapBg: "#f8f9fa",
229
+ /** Minimap brush (viewport) fill */
230
+ minimapBrush: "#3b82f644"};
231
+
232
+ // src/interaction/minimap.ts
233
+ var Minimap = class {
234
+ _data;
235
+ _width;
236
+ _height;
237
+ _x;
238
+ _y;
239
+ _zoom;
240
+ _stroke;
241
+ _bgColor;
242
+ _brushColor;
243
+ constructor(config) {
244
+ this._data = config.data;
245
+ this._width = config.width;
246
+ this._height = config.height;
247
+ this._x = config.x;
248
+ this._y = config.y;
249
+ this._zoom = config.zoom;
250
+ this._stroke = config.stroke ?? theme.minimapStroke;
251
+ this._bgColor = config.bgColor ?? theme.minimapBg;
252
+ this._brushColor = config.brushColor ?? theme.minimapBrush;
253
+ }
254
+ /** Render minimap as draw commands */
255
+ render() {
256
+ if (this._data.length === 0) return [];
257
+ const commands = [];
258
+ const times = this._data.map((d) => d.time);
259
+ const values = this._data.map((d) => d.value).filter((v) => v !== null);
260
+ const timeDomain = [Math.min(...times), Math.max(...times)];
261
+ const valueDomain = [Math.min(...values), Math.max(...values)];
262
+ const timeScale = new TimeScale({
263
+ domain: timeDomain,
264
+ range: [0, this._width]
265
+ });
266
+ const valueScale = new LinearScale({
267
+ domain: valueDomain,
268
+ range: [this._height, 0]
269
+ });
270
+ commands.push({
271
+ type: "rect",
272
+ x: this._x,
273
+ y: this._y,
274
+ w: this._width,
275
+ h: this._height,
276
+ fill: this._bgColor
277
+ });
278
+ const points = this._data.map((d) => ({
279
+ x: this._x + timeScale.map(d.time),
280
+ y: this._y + valueScale.map(d.value)
281
+ }));
282
+ commands.push({
283
+ type: "path",
284
+ points,
285
+ smoothing: false,
286
+ stroke: this._stroke,
287
+ strokeWidth: 1
288
+ });
289
+ if (this._zoom) {
290
+ const zoomState = this._zoom.state;
291
+ const brushX0 = this._x + timeScale.map(zoomState.domain[0]);
292
+ const brushX1 = this._x + timeScale.map(zoomState.domain[1]);
293
+ const brushW = Math.max(2, brushX1 - brushX0);
294
+ commands.push({
295
+ type: "rect",
296
+ x: brushX0,
297
+ y: this._y,
298
+ w: brushW,
299
+ h: this._height,
300
+ fill: this._brushColor
301
+ });
302
+ }
303
+ return commands;
304
+ }
305
+ /** Convert minimap pixel X to time value (for click-to-navigate) */
306
+ navigateTo(pixelX) {
307
+ const times = this._data.map((d) => d.time);
308
+ const timeDomain = [Math.min(...times), Math.max(...times)];
309
+ const timeScale = new TimeScale({ domain: timeDomain, range: [0, this._width] });
310
+ const localX = pixelX - this._x;
311
+ return timeScale.invert(localX);
312
+ }
313
+ };
314
+
315
+ // src/interaction/tooltip.ts
316
+ var Tooltip = class {
317
+ _mouseX;
318
+ _mouseY;
319
+ _timeScale;
320
+ _valueScale;
321
+ _data;
322
+ _xRange;
323
+ _yRange;
324
+ _snapRadius;
325
+ _bgColor;
326
+ _borderColor;
327
+ _textColor;
328
+ constructor(config) {
329
+ this._mouseX = config.mouseX;
330
+ this._mouseY = config.mouseY;
331
+ this._timeScale = config.timeScale;
332
+ this._valueScale = config.valueScale;
333
+ this._data = config.data;
334
+ this._xRange = config.xRange;
335
+ this._yRange = config.yRange;
336
+ this._snapRadius = config.snapRadius ?? theme.tooltipSnapRadius;
337
+ this._bgColor = config.bgColor ?? theme.tooltipBg;
338
+ this._borderColor = config.borderColor ?? theme.tooltipBorder;
339
+ this._textColor = config.textColor ?? theme.tooltipText;
340
+ }
341
+ /** Compute tooltip visibility and render commands */
342
+ compute() {
343
+ this._timeScale.invert(this._mouseX);
344
+ const sorted = [...this._data].filter((d) => d.value !== null).sort((a, b) => a.time - b.time);
345
+ let closest;
346
+ let closestDist = Infinity;
347
+ for (const dp of sorted) {
348
+ const px = this._timeScale.map(dp.time);
349
+ const dist = Math.abs(this._mouseX - px);
350
+ if (dist < closestDist) {
351
+ closestDist = dist;
352
+ closest = dp;
353
+ }
354
+ }
355
+ if (!closest || closestDist > this._snapRadius) {
356
+ return { visible: false, commands: [] };
357
+ }
358
+ const snapX = this._timeScale.map(closest.time);
359
+ const snapY = this._valueScale.map(closest.value);
360
+ const commands = [];
361
+ commands.push({
362
+ type: "line",
363
+ x1: snapX,
364
+ y1: this._yRange[0],
365
+ x2: snapX,
366
+ y2: this._yRange[1],
367
+ stroke: "#94a3b8",
368
+ strokeWidth: 1
369
+ });
370
+ commands.push({
371
+ type: "line",
372
+ x1: this._xRange[0],
373
+ y1: snapY,
374
+ x2: this._xRange[1],
375
+ y2: snapY,
376
+ stroke: "#94a3b8",
377
+ strokeWidth: 1
378
+ });
379
+ commands.push({
380
+ type: "circle",
381
+ cx: snapX,
382
+ cy: snapY,
383
+ r: 5,
384
+ fill: "#3b82f6"
385
+ });
386
+ const timeLabel = new Date(closest.time).toLocaleString();
387
+ const valueLabel = closest.value.toFixed(2);
388
+ const boxW = 120;
389
+ const boxH = 44;
390
+ let boxX = snapX + 12;
391
+ let boxY = snapY - boxH - 4;
392
+ if (boxX + boxW > this._xRange[1]) {
393
+ boxX = snapX - boxW - 12;
394
+ }
395
+ if (boxY < this._yRange[0]) {
396
+ boxY = snapY + 12;
397
+ }
398
+ commands.push({
399
+ type: "rect",
400
+ x: boxX,
401
+ y: boxY,
402
+ w: boxW,
403
+ h: boxH,
404
+ fill: this._bgColor,
405
+ stroke: this._borderColor,
406
+ strokeWidth: 1
407
+ });
408
+ commands.push({
409
+ type: "text",
410
+ content: timeLabel,
411
+ x: boxX + 8,
412
+ y: boxY + 16,
413
+ fontSize: 10,
414
+ fill: this._textColor
415
+ });
416
+ commands.push({
417
+ type: "text",
418
+ content: `Value: ${valueLabel}`,
419
+ x: boxX + 8,
420
+ y: boxY + 32,
421
+ fontSize: 11,
422
+ fill: "#3b82f6"
423
+ });
424
+ return {
425
+ visible: true,
426
+ commands,
427
+ dataPoint: closest,
428
+ snapX
429
+ };
430
+ }
431
+ };
432
+ /*!
433
+ * MLTimeGraph — Copyright (c) 2026 Michael Lechner
434
+ * MIT with Attribution: free use incl. commercial requires visible credit to
435
+ * "Michael Lechner". Commercial license (no attribution) on request. See LICENSE.
436
+ */
437
+
438
+ export { Minimap, Tooltip, Zoom };