@event-timeline/core 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.
@@ -0,0 +1,439 @@
1
+ /**
2
+ * A continuous timestamp in milliseconds since the Unix epoch (UTC).
3
+ *
4
+ * "Date-level" granularity is a display concern, not storage: all calendar
5
+ * interpretation (day boundaries, tick labels, day-clustering) is done in UTC,
6
+ * but the stored value is always milliseconds (DESIGN.md §1).
7
+ */
8
+ type Time = number;
9
+ /** An entity drawn as a horizontal line (lane). */
10
+ interface TimelineElement<TData = unknown> {
11
+ id: string;
12
+ label: string;
13
+ /** URL, emoji, or preloaded image; drawn in the label area (Phase 2+). */
14
+ icon?: string | HTMLImageElement;
15
+ /** Per-element style overrides, merged over the theme (Phase 6). */
16
+ style?: Partial<ElementStyle>;
17
+ /** Ordering hint honoured only by the `explicit` layout strategy (§7). */
18
+ order?: number;
19
+ /** Opaque host payload, echoed back verbatim in callbacks. */
20
+ data?: TData;
21
+ }
22
+ /** A directed connector drawn as an arrow between two elements at a point in time. */
23
+ interface TimelineEvent<TData = unknown> {
24
+ id: string;
25
+ /** Element the arrow leaves (source node dot). */
26
+ sourceId: string;
27
+ /** Element the arrow enters (arrowhead). */
28
+ targetId: string;
29
+ /** The moment the event occurs. */
30
+ time: Time;
31
+ label?: string;
32
+ style?: Partial<EventStyle>;
33
+ data?: TData;
34
+ }
35
+ /** A clean dataset handed to the engine to render. */
36
+ interface TimelineData<TData = unknown> {
37
+ elements: TimelineElement<TData>[];
38
+ events: TimelineEvent<TData>[];
39
+ }
40
+ /**
41
+ * The complete set of stylable element properties (DESIGN.md §11). The same
42
+ * shape is reused at every level of the resolution chain (theme default →
43
+ * resolver → per-item `style`), so there is one definition of "what is
44
+ * stylable" rather than parallel shapes per layer (DRY). Sizes are CSS px.
45
+ */
46
+ interface ElementStyle {
47
+ lineColor: string;
48
+ lineWidth: number;
49
+ labelColor: string;
50
+ /** CSS font shorthand, e.g. "12px Inter, sans-serif". */
51
+ labelFont: string;
52
+ iconSize: number;
53
+ }
54
+ /** The complete set of stylable event properties (DESIGN.md §11). Sizes are CSS px. */
55
+ interface EventStyle {
56
+ arrowColor: string;
57
+ arrowWidth: number;
58
+ arrowheadSize: number;
59
+ labelColor: string;
60
+ labelFont: string;
61
+ /** Source node dot radius. */
62
+ nodeRadius: number;
63
+ }
64
+ /** Engine-wide theme: defaults plus structural metrics (DESIGN.md §11). */
65
+ interface Theme {
66
+ background: string;
67
+ gridlineColor: string;
68
+ headerTextColor: string;
69
+ headerFont: string;
70
+ /** Height of the (multi-row) time header in CSS px. */
71
+ headerHeight: number;
72
+ rowHeight: number;
73
+ rowGap: number;
74
+ labelPadding: number;
75
+ /** Overlay colour for the hovered primitive (DESIGN.md §9). */
76
+ hoverColor: string;
77
+ /** Overlay colour for selected primitives' outlines (DESIGN.md §9). */
78
+ selectionColor: string;
79
+ /** Colour of collapsed-event cluster markers and their counts (DESIGN.md §6). */
80
+ clusterColor: string;
81
+ /** Theme-level element defaults (base of the resolution chain). */
82
+ element: ElementStyle;
83
+ /** Theme-level event defaults (base of the resolution chain). */
84
+ event: EventStyle;
85
+ }
86
+
87
+ /** Why an item was dropped during ingest. */
88
+ type DiagnosticCode = 'invalid-time' | 'dangling-edge' | 'duplicate-id';
89
+ interface Diagnostic {
90
+ code: DiagnosticCode;
91
+ kind: 'element' | 'event';
92
+ /** Id of the offending item, when known. */
93
+ id?: string;
94
+ reason: string;
95
+ }
96
+ type OnDiagnostic = (d: Diagnostic) => void;
97
+
98
+ interface LayoutStrategy {
99
+ /**
100
+ * Pure function of the data: returns element ids in top-to-bottom order.
101
+ * No rendering, no side effects, no pixels — trivially unit-testable.
102
+ */
103
+ order(elements: readonly TimelineElement[], events: readonly TimelineEvent[]): string[];
104
+ }
105
+
106
+ /** Per-frame context handed to a strategy; carries the current zoom (§6). */
107
+ interface ClusterContext {
108
+ /** Pixel separation under which same-key events merge (also the column width). */
109
+ minSeparationPx: number;
110
+ /** Camera time→screen-x (the single coordinate source, §3). */
111
+ timeToScreenX(t: Time): number;
112
+ }
113
+ interface ClusterStrategy {
114
+ /**
115
+ * Stable grouping key for an event at the current zoom. Events sharing a key
116
+ * and within `minSeparationPx` collapse into one marker; pure, no side effects.
117
+ */
118
+ key(event: TimelineEvent, ctx: ClusterContext): string;
119
+ }
120
+ /**
121
+ * A run of one or more events that render as a unit. A single-event cluster is
122
+ * "un-clustered" — the renderer draws it as an ordinary arrow; a multi-event
123
+ * cluster draws a count marker (§6).
124
+ */
125
+ interface Cluster<TData = unknown> {
126
+ events: TimelineEvent<TData>[];
127
+ }
128
+
129
+ type TickLevel = 'year' | 'month' | 'day';
130
+ type TickFormatter = (date: Date, level: TickLevel) => string;
131
+
132
+ /**
133
+ * Overlay `partials` onto a complete `base`, later partials winning. A property
134
+ * set to `undefined` (or absent) in a partial **falls through** to the previous
135
+ * layer, matching the `?? theme.default` semantics each level previously used and
136
+ * keeping per-property independence (§11): a partial only overrides the keys it
137
+ * actually sets.
138
+ */
139
+ declare function overlayStyle<T extends object>(base: T, ...partials: Array<Partial<T> | undefined>): T;
140
+ /** Data-driven element style override (§11); merged between theme and `.style`. */
141
+ type ElementStyleResolver<TData = unknown> = (element: TimelineElement<TData>) => Partial<ElementStyle>;
142
+ /** Data-driven event style override (§11); merged between theme and `.style`. */
143
+ type EventStyleResolver<TData = unknown> = (event: TimelineEvent<TData>) => Partial<EventStyle>;
144
+
145
+ type HitResult<TData = unknown> = {
146
+ kind: 'element';
147
+ element: TimelineElement<TData>;
148
+ } | {
149
+ kind: 'event';
150
+ event: TimelineEvent<TData>;
151
+ } | {
152
+ kind: 'cluster';
153
+ events: TimelineEvent<TData>[];
154
+ count: number;
155
+ };
156
+
157
+ interface TimelineEventMap<TData = unknown> {
158
+ viewportChange: {
159
+ timeRange: [number, number];
160
+ scrollY: number;
161
+ pxPerMs: number;
162
+ visibleRowRange: [number, number];
163
+ };
164
+ hover: HitResult<TData> | null;
165
+ click: {
166
+ hit: HitResult<TData>;
167
+ modifiers: {
168
+ ctrlOrMeta: boolean;
169
+ shift: boolean;
170
+ };
171
+ };
172
+ selectionChange: {
173
+ ids: string[];
174
+ };
175
+ dataChange: {
176
+ added: number;
177
+ removed: number;
178
+ updated: number;
179
+ };
180
+ ready: void;
181
+ }
182
+
183
+ /** Event collapsing/aggregation config (DESIGN.md §6); all fields defaulted. */
184
+ interface ClusteringOptions {
185
+ /** Collapse dense events into count markers. Default `true`. */
186
+ enabled?: boolean;
187
+ /** Keying strategy. Default `bySourceTargetColumn`. */
188
+ strategy?: ClusterStrategy;
189
+ /** Pixel separation under which same-key events merge. Default 12. */
190
+ minSeparationPx?: number;
191
+ }
192
+ /** Level-of-detail config (DESIGN.md §6); all fields defaulted. */
193
+ interface LodOptions {
194
+ /** Zoom (CSS px per ms) at/above which per-event labels are drawn. */
195
+ eventLabelMinPxPerMs?: number;
196
+ }
197
+ /**
198
+ * Per-frame timing for one drawn layer (DESIGN.md §13). Emitted to the optional
199
+ * `onFrameStats` sink after each layer draw so hosts and the perf benchmark can
200
+ * assert the §13 budgets (≤16ms base, ≤4ms overlay at 50k) without the engine
201
+ * itself depending on `performance` or any logging implementation (DIP).
202
+ */
203
+ interface FrameStats {
204
+ layer: 'base' | 'overlay';
205
+ /** Wall-clock cost of the layer's draw, in milliseconds. */
206
+ durationMs: number;
207
+ }
208
+ /**
209
+ * Data-driven per-item style overrides (DESIGN.md §11). Each callback runs
210
+ * between the theme default and the item's own `.style`; most specific wins.
211
+ */
212
+ interface StyleResolvers<TData = unknown> {
213
+ element?: ElementStyleResolver<TData>;
214
+ event?: EventStyleResolver<TData>;
215
+ }
216
+ /**
217
+ * Label/tick text formatters (DESIGN.md §11). When omitted the engine echoes the
218
+ * raw label (and the built-in UTC tick formatter of §8).
219
+ */
220
+ interface Formatters<TData = unknown> {
221
+ tickLabel?: TickFormatter;
222
+ eventLabel?: (event: TimelineEvent<TData>) => string;
223
+ elementLabel?: (element: TimelineElement<TData>) => string;
224
+ }
225
+ interface TimelineEngineOptions<TData = unknown> {
226
+ theme?: Partial<Theme>;
227
+ onDiagnostic?: OnDiagnostic;
228
+ layout?: LayoutStrategy;
229
+ /**
230
+ * Trailing-edge delay (ms) for coalescing incremental re-layouts (DESIGN.md
231
+ * §7, §10). Applies only to the incremental seam ({@link scheduleLayoutRecompute});
232
+ * a full `setData` always recomputes synchronously. Default 120.
233
+ */
234
+ layoutDebounceMs?: number;
235
+ /** Allow `ctrl`/`⌘`+click to extend the selection (DESIGN.md §9). */
236
+ multiSelect?: boolean;
237
+ /**
238
+ * Re-layout policy for incremental mutations (`addEvents`/… §10). Default
239
+ * `'stable-append'`: existing rows stay put and new elements append, applied
240
+ * synchronously so updates appear at once with no jarring reshuffle (§7).
241
+ * `'full'` opts into re-running the configured {@link layout} strategy on the
242
+ * debounced {@link layoutDebounceMs} seam. `setData` always relayouts in full.
243
+ */
244
+ streamingLayout?: 'stable-append' | 'full';
245
+ clustering?: ClusteringOptions;
246
+ lod?: LodOptions;
247
+ /** Data-driven per-item style overrides (DESIGN.md §11). */
248
+ styleResolvers?: StyleResolvers<TData>;
249
+ /** Label/tick text formatters (DESIGN.md §11). */
250
+ formatters?: Formatters<TData>;
251
+ /**
252
+ * Optional per-frame timing sink (DESIGN.md §13). When supplied, each layer
253
+ * draw is bracketed with `performance.now()` and reported; when omitted the
254
+ * draw path stays allocation- and measurement-free (zero overhead).
255
+ */
256
+ onFrameStats?: (stats: FrameStats) => void;
257
+ }
258
+ declare class TimelineEngine<TData = unknown> {
259
+ private readonly theme;
260
+ private readonly metrics;
261
+ private readonly store;
262
+ private readonly camera;
263
+ private readonly renderer;
264
+ private readonly scheduler;
265
+ private readonly emitter;
266
+ private readonly pointer;
267
+ private readonly multiSelect;
268
+ private readonly layoutStrategy;
269
+ /** Incremental re-layout policy (DESIGN.md §7, §10). */
270
+ private readonly streamingLayout;
271
+ /**
272
+ * Coalesced incremental re-layout (DESIGN.md §7). Streaming mutations
273
+ * (`addEvents`/`removeEvents`, §10) call {@link scheduleLayoutRecompute} so a
274
+ * burst re-runs layout once; `setData` bypasses this and recomputes inline.
275
+ */
276
+ private readonly debouncedRecomputeLayout;
277
+ /** Resolved per-frame LOD/clustering config (DESIGN.md §6). */
278
+ private readonly lod;
279
+ private readonly clustering;
280
+ /**
281
+ * §11 style/label resolution, bound once from the theme + host resolvers. The
282
+ * builders receive these via `BuildContext`, so resolution lives in one place.
283
+ */
284
+ private readonly resolveElementStyle;
285
+ private readonly resolveEventStyle;
286
+ private readonly formatElementLabel;
287
+ private readonly formatEventLabel;
288
+ private readonly tickFormatter;
289
+ /** Optional per-frame timing sink (DESIGN.md §13); undefined disables timing. */
290
+ private readonly onFrameStats?;
291
+ private readonly iconCache;
292
+ /**
293
+ * Resolve an element icon for the current frame (§1, §11): a preloaded image
294
+ * is used directly when decoded, a URL is loaded/cached (skipped until ready),
295
+ * and any other string (emoji/glyph) is drawn as text by the builder.
296
+ */
297
+ private readonly resolveIcon;
298
+ private viewport;
299
+ private dpr;
300
+ private rowLayout;
301
+ private hasFitted;
302
+ private disposed;
303
+ /** Hit grid + targets rebuilt each base draw; reused during pure hover (§5). */
304
+ private hitGrid;
305
+ private frameTargets;
306
+ /** Latest cursor position pending a (frame-coalesced) hit-test, or null. */
307
+ private pendingPointer;
308
+ private hovered;
309
+ private readonly selectedIds;
310
+ constructor(baseCanvas: HTMLCanvasElement, overlayCanvas: HTMLCanvasElement, options?: TimelineEngineOptions<TData>);
311
+ setData(data: TimelineData<TData>): void;
312
+ /** Incrementally insert events without a full rebuild (DESIGN.md §10). */
313
+ addEvents(events: readonly TimelineEvent<TData>[]): void;
314
+ /** Remove events by id (DESIGN.md §10). */
315
+ removeEvents(ids: readonly string[]): void;
316
+ /** Append elements with stable-append layout (DESIGN.md §7, §10). */
317
+ addElements(elements: readonly TimelineElement<TData>[]): void;
318
+ /** Patch an element's label/icon/style/order in place (DESIGN.md §10). */
319
+ updateElement(id: string, patch: Partial<TimelineElement<TData>>): void;
320
+ /**
321
+ * Shared tail for the incremental mutations (§10): a no-op change emits and
322
+ * redraws nothing; otherwise re-layout (stable-append by default, §7), emit
323
+ * `dataChange`, and mark the base dirty. Streaming never refits — that would
324
+ * jerk the camera mid-stream.
325
+ */
326
+ private applyMutation;
327
+ resize(cssWidth: number, cssHeight: number, dpr: number): void;
328
+ fit(): void;
329
+ panToTime(t: Time): void;
330
+ /** Frame a time range across the viewport width (DESIGN.md §12). */
331
+ zoomToRange(start: Time, end: Time): void;
332
+ /**
333
+ * Replace the selection imperatively (DESIGN.md §9, §12). Ids are the
334
+ * `kind:id` composites produced by hit-testing (see {@link hitId}), so an
335
+ * element and an event sharing a raw id stay distinct. A no-op replacement
336
+ * emits nothing and skips the overlay redraw.
337
+ */
338
+ select(ids: string[]): void;
339
+ getViewport(): TimelineEventMap<TData>['viewportChange'];
340
+ on<K extends keyof TimelineEventMap<TData>>(key: K, fn: (payload: TimelineEventMap<TData>[K]) => void): () => void;
341
+ off<K extends keyof TimelineEventMap<TData>>(key: K, fn: (payload: TimelineEventMap<TData>[K]) => void): void;
342
+ dispose(): void;
343
+ private now;
344
+ private zoomBounds;
345
+ /**
346
+ * Edge space reserved when fitting (§3): a left gutter so the leftmost line's
347
+ * start marker (icon + label, §1) clears the viewport edge instead of being
348
+ * clipped. Derived from the theme so host themes scale it. With no elements
349
+ * there are no markers, so nothing is reserved (the empty-state window stays
350
+ * centred on `now`, §3.1).
351
+ */
352
+ private fitInset;
353
+ /**
354
+ * Recompute row order + positions (DESIGN.md §7). `'full'` runs the configured
355
+ * strategy (used by `setData` and the opt-in streaming relayout); `'stable-append'`
356
+ * keeps existing rows and appends new elements (the streaming default, §10).
357
+ */
358
+ private recomputeLayout;
359
+ /**
360
+ * Apply layout after an *incremental* mutation (DESIGN.md §7, §10). By default
361
+ * (`streamingLayout: 'stable-append'`) this runs synchronously so new rows and
362
+ * extents appear at once with no reshuffle; under `'full'` a burst is coalesced
363
+ * into one trailing strategy recompute via the debounced seam. Full `setData`
364
+ * bypasses this and recomputes synchronously so `fit`/scroll-clamp see
365
+ * `contentHeight` immediately.
366
+ */
367
+ private scheduleLayoutRecompute;
368
+ /**
369
+ * Frame the view before the first explicit fit. With data, fit to it once and
370
+ * latch (`hasFitted`). Without data, frame the default window on `now` (§3.1)
371
+ * but stay un-latched, so real data still auto-fits when it arrives.
372
+ */
373
+ private maybeFit;
374
+ /** Mark the base layer dirty and emit the new viewport (any camera change). */
375
+ private onCameraChange;
376
+ /**
377
+ * The semantic-intent sink for the pointer controller (§9). The controller
378
+ * does no math; every camera mutation and hit-test is applied here so the
379
+ * Camera stays the single source of coordinate truth (§3).
380
+ */
381
+ private pointerHost;
382
+ /** Resolve a click against the current hit grid: emit click + update selection. */
383
+ private onClick;
384
+ /** Zoom to a clicked cluster's member time span, expanding it (§6). */
385
+ private expandCluster;
386
+ private emitViewportChange;
387
+ private drawFrame;
388
+ /**
389
+ * Run one layer's draw, reporting its wall-clock cost to `onFrameStats` when a
390
+ * sink is set (DESIGN.md §13). With no sink the draw runs directly — no
391
+ * `performance.now()` calls, no allocation — so timing is truly opt-in.
392
+ */
393
+ private timed;
394
+ private drawBase;
395
+ /**
396
+ * Overlay pass (DESIGN.md §4, §9): resolve the pending hover against the hit
397
+ * grid (emitting `hover` only on change), then redraw the hovered and selected
398
+ * primitives in the highlight/selection colours. It reuses this frame's
399
+ * `frameTargets` geometry — no recompute — and never touches the base layer,
400
+ * so hover/selection stay independent of the expensive base redraw.
401
+ */
402
+ private drawOverlay;
403
+ /**
404
+ * Overlay colour for a target, or null when it should not highlight. A target
405
+ * highlights when it is itself hovered/selected, or — for an event/cluster —
406
+ * when one of its endpoints is a hovered/selected element (§9). Hover wins the
407
+ * colour over selection.
408
+ */
409
+ private highlightColorFor;
410
+ /** Run the coalesced hit-test for the latest cursor position and emit on change. */
411
+ private resolveHover;
412
+ /** Redraw one target's geometry in `color`, slightly thickened, on the overlay. */
413
+ private drawHighlight;
414
+ }
415
+
416
+ declare const byFirstEvent: LayoutStrategy;
417
+
418
+ declare const barycenter: LayoutStrategy;
419
+
420
+ declare const explicit: LayoutStrategy;
421
+
422
+ /** Collapse every event sharing a pixel column, regardless of endpoints. */
423
+ declare const byPixelColumn: ClusterStrategy;
424
+ /** Collapse events between the same source and target (proximity-gated, §6). */
425
+ declare const byElementPair: ClusterStrategy;
426
+ /** Collapse events sharing a UTC calendar day (proximity-gated, §6). */
427
+ declare const byDay: ClusterStrategy;
428
+ /**
429
+ * Default: `(sourceId, targetId, pixelColumn)` — same-direction events in the
430
+ * same column collapse, so a dense fan-out between two lines reads as one marker.
431
+ */
432
+ declare const bySourceTargetColumn: ClusterStrategy;
433
+
434
+ declare const defaultTheme: Theme;
435
+
436
+ /** Current package version placeholder; real releases are driven by Changesets. */
437
+ declare const VERSION = "0.0.0";
438
+
439
+ export { type Cluster, type ClusterContext, type ClusterStrategy, type ClusteringOptions, type Diagnostic, type DiagnosticCode, type ElementStyle, type ElementStyleResolver, type EventStyle, type EventStyleResolver, type Formatters, type FrameStats, type HitResult, type LayoutStrategy, type LodOptions, type OnDiagnostic, type StyleResolvers, type Theme, type TickFormatter, type TickLevel, type Time, type TimelineData, type TimelineElement, TimelineEngine, type TimelineEngineOptions, type TimelineEvent, type TimelineEventMap, VERSION, barycenter, byDay, byElementPair, byFirstEvent, byPixelColumn, bySourceTargetColumn, defaultTheme, explicit, overlayStyle };