@mim/histui 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/src/index.d.ts ADDED
@@ -0,0 +1,137 @@
1
+ export type HistuiOrientation = "auto" | "horizontal" | "vertical";
2
+ export type HistuiAxisPlacement = "center" | "side-start" | "side-end";
3
+
4
+ export interface HistuiTheme {
5
+ id: string;
6
+ label?: Record<string, string> | string;
7
+ colors: Record<string, string>;
8
+ }
9
+
10
+ export interface HistuiConfig {
11
+ app?: {
12
+ name?: string;
13
+ defaultLanguage?: string;
14
+ languages?: string[];
15
+ defaultTheme?: string;
16
+ orientation?: HistuiOrientation;
17
+ axisPlacement?: {
18
+ horizontal?: HistuiAxisPlacement;
19
+ vertical?: HistuiAxisPlacement;
20
+ };
21
+ };
22
+ analytics?: {
23
+ googleAnalyticsMeasurementId?: string;
24
+ };
25
+ timeline?: Record<string, unknown>;
26
+ themes?: HistuiTheme[];
27
+ }
28
+
29
+ export interface HistuiFilters {
30
+ search?: string;
31
+ recordTypes?: string[] | Set<string>;
32
+ types?: string[] | Set<string>;
33
+ factuality?: string[] | Set<string>;
34
+ confidence?: string[] | Set<string>;
35
+ scopes?: string[] | Set<string>;
36
+ categories?: string[] | Set<string>;
37
+ countries?: string[] | Set<string>;
38
+ minSignificance?: number;
39
+ mediaOnly?: boolean;
40
+ uncertainOnly?: boolean;
41
+ fromYear?: number;
42
+ toYear?: number;
43
+ }
44
+
45
+ export interface HistuiViewport {
46
+ orientation: "horizontal" | "vertical";
47
+ placement: HistuiAxisPlacement;
48
+ span: number;
49
+ visible: number;
50
+ hidden: number;
51
+ total: number;
52
+ lod: unknown;
53
+ }
54
+
55
+ export interface HistuiTimelineOptions<RecordType = any> {
56
+ container: Element | string;
57
+ data?: unknown;
58
+ records?: RecordType[];
59
+ dataset?: unknown;
60
+ config?: HistuiConfig;
61
+ language?: string;
62
+ direction?: "ltr" | "rtl";
63
+ translator?: (key: string, values?: Record<string, unknown>) => string;
64
+ themeId?: string;
65
+ theme?: HistuiTheme;
66
+ title?: string;
67
+ description?: string;
68
+ controls?: boolean;
69
+ replace?: boolean;
70
+ selectInitial?: boolean;
71
+ selectedId?: string;
72
+ filters?: HistuiFilters;
73
+ orientation?: HistuiOrientation;
74
+ axisPlacement?: {
75
+ horizontal?: HistuiAxisPlacement;
76
+ vertical?: HistuiAxisPlacement;
77
+ };
78
+ lodEnabled?: boolean;
79
+ explodeEnabled?: boolean;
80
+ analytics?: {
81
+ measurementId?: string;
82
+ };
83
+ onSelect?: (record: RecordType, instance: HistuiTimeline<RecordType>) => void;
84
+ onViewportChange?: (viewport: HistuiViewport, instance: HistuiTimeline<RecordType>) => void;
85
+ onRecordsChange?: (records: RecordType[], instance: HistuiTimeline<RecordType>) => void;
86
+ onTrack?: (name: string, payload: Record<string, unknown>, instance: HistuiTimeline<RecordType>) => void;
87
+ }
88
+
89
+ export interface HistuiState<RecordType = any> {
90
+ dataset: unknown;
91
+ records: RecordType[];
92
+ filteredRecords: RecordType[];
93
+ facets: unknown;
94
+ filters: HistuiFilters;
95
+ selected: RecordType | null;
96
+ viewport: HistuiViewport | null;
97
+ language: string;
98
+ direction: string;
99
+ themeId: string;
100
+ orientation: HistuiOrientation;
101
+ axisPlacement: {
102
+ horizontal: HistuiAxisPlacement;
103
+ vertical: HistuiAxisPlacement;
104
+ };
105
+ lodEnabled: boolean;
106
+ explodeEnabled: boolean;
107
+ }
108
+
109
+ export class HistuiTimeline<RecordType = any> {
110
+ constructor(options: HistuiTimelineOptions<RecordType>);
111
+ setData(data: unknown, options?: { filters?: HistuiFilters; resetView?: boolean }): this;
112
+ setRecords(records: RecordType[], options?: { dataset?: unknown; filters?: HistuiFilters; resetView?: boolean }): this;
113
+ setFilters(filters: HistuiFilters, options?: { preserveView?: boolean }): this;
114
+ resetFilters(options?: { preserveView?: boolean }): this;
115
+ select(recordId: string, options?: { emit?: boolean }): this;
116
+ fit(options?: { animate?: boolean }): this;
117
+ zoomBy(factor: number): this;
118
+ setViewRange(start: number, end: number, options?: Record<string, unknown>): this;
119
+ setOrientation(orientation: HistuiOrientation): this;
120
+ setAxisPlacement(orientation: "horizontal" | "vertical", placement: HistuiAxisPlacement): this;
121
+ setLodEnabled(enabled: boolean): this;
122
+ setExplodeEnabled(enabled: boolean): this;
123
+ setLanguage(language: string, direction?: "ltr" | "rtl"): this;
124
+ setTheme(themeOrId: string | HistuiTheme): this;
125
+ applyTheme(theme: HistuiTheme): void;
126
+ getState(): HistuiState<RecordType>;
127
+ destroy(): void;
128
+ }
129
+
130
+ export function createHistuiTimeline<RecordType = any>(options: HistuiTimelineOptions<RecordType>): HistuiTimeline<RecordType>;
131
+ export function normalizeTimelineData(data: unknown, datasetConfig?: Record<string, unknown>): unknown;
132
+ export function normalizePastStruct(document: unknown, datasetConfig?: Record<string, unknown>): unknown;
133
+ export function createDefaultFilters(records: any[], facets?: unknown): HistuiFilters;
134
+ export function filterRecords<RecordType = any>(records: RecordType[], filters: HistuiFilters): RecordType[];
135
+ export function normalizeFilters(filters?: HistuiFilters, baseFilters?: HistuiFilters): HistuiFilters;
136
+ export const DEFAULT_HISTUI_CONFIG: HistuiConfig;
137
+
package/src/index.js ADDED
@@ -0,0 +1,457 @@
1
+ import { initializeAnalytics, trackAnalyticsEvent } from "./analytics.js";
2
+ import { DEFAULT_HISTUI_CONFIG } from "./default-config.js";
3
+ import { createDefaultFilters, filterRecords, normalizeFilters } from "./filters.js";
4
+ import { dirForLanguage, makeTranslator } from "./i18n.js";
5
+ import {
6
+ collectFacets,
7
+ escapeHtml,
8
+ normalizePastStruct,
9
+ normalizeRecord,
10
+ textOf
11
+ } from "./paststruct.js";
12
+ import { TimelineView } from "./timeline-view.js";
13
+ import { applyTheme, getTheme } from "./theme.js";
14
+
15
+ export { initializeAnalytics, trackAnalyticsEvent } from "./analytics.js";
16
+ export { DEFAULT_HISTUI_CONFIG } from "./default-config.js";
17
+ export { createDefaultFilters, filterRecords, getDatasetBounds, normalizeFilters } from "./filters.js";
18
+ export { dirForLanguage, makeTranslator, UI_STRINGS } from "./i18n.js";
19
+ export * from "./paststruct.js";
20
+ export { TimelineView } from "./timeline-view.js";
21
+ export { applyTheme, getTheme, localizedThemeLabel } from "./theme.js";
22
+
23
+ export function createHistuiTimeline(options) {
24
+ return new HistuiTimeline(options);
25
+ }
26
+
27
+ export class HistuiTimeline {
28
+ constructor(options = {}) {
29
+ if (!options.container) throw new Error("HistuiTimeline requires a container element or selector.");
30
+ this.container = resolveContainer(options.container);
31
+ this.options = {
32
+ controls: true,
33
+ replace: true,
34
+ selectInitial: true,
35
+ ...options
36
+ };
37
+ this.config = mergeConfig(DEFAULT_HISTUI_CONFIG, options.config || {});
38
+ this.language = options.language || this.config.app.defaultLanguage || "en";
39
+ this.direction = options.direction || dirForLanguage(this.language);
40
+ this.t = options.translator || makeTranslator(this.language);
41
+ this.themeId = options.themeId || this.config.app.defaultTheme;
42
+ this.orientation = options.orientation || this.config.app.orientation || "auto";
43
+ this.axisPlacement = {
44
+ horizontal: options.axisPlacement?.horizontal || this.config.app.axisPlacement?.horizontal || "center",
45
+ vertical: options.axisPlacement?.vertical || this.config.app.axisPlacement?.vertical || "side-start"
46
+ };
47
+ this.lodEnabled = options.lodEnabled ?? (this.config.timeline?.lod?.enabled !== false);
48
+ this.explodeEnabled = options.explodeEnabled ?? (this.config.timeline?.explode?.enabled === true);
49
+ this.dataset = null;
50
+ this.records = [];
51
+ this.filteredRecords = [];
52
+ this.facets = {};
53
+ this.filters = null;
54
+ this.selected = null;
55
+ this.viewport = null;
56
+
57
+ this.handleControlClick = (event) => this.onControlClick(event);
58
+ this.handleControlChange = (event) => this.onControlChange(event);
59
+
60
+ this.mount();
61
+ this.applyTheme(options.theme || getTheme(this.config, this.themeId));
62
+ this.initializeAnalytics();
63
+
64
+ if (options.data) this.setData(options.data, { filters: options.filters, resetView: true });
65
+ else if (options.records) this.setRecords(options.records, { dataset: options.dataset, filters: options.filters, resetView: true });
66
+ else this.timeline.setRecords([], { resetView: true });
67
+ if (options.selectedId) this.select(options.selectedId, { emit: false });
68
+ }
69
+
70
+ mount() {
71
+ if (this.options.replace) this.container.replaceChildren();
72
+ this.root = document.createElement("div");
73
+ this.root.className = "histui-timeline";
74
+ this.root.lang = this.language;
75
+ this.root.dir = this.direction;
76
+ this.root.innerHTML = `
77
+ <section class="histui-timeline-workbench">
78
+ <header class="histui-timeline-head" data-histui-head></header>
79
+ <div class="timeline-stage" data-histui-stage tabindex="0" aria-label="Interactive historical timeline">
80
+ <canvas class="histui-timeline-canvas" data-histui-canvas aria-hidden="true"></canvas>
81
+ <div class="timeline-cards" data-histui-cards></div>
82
+ <div class="stage-hint" data-histui-hint></div>
83
+ </div>
84
+ <div class="timeline-zoom-bar" data-histui-zoom-bar aria-label="Timeline overview and zoom controls"></div>
85
+ </section>
86
+ `;
87
+ this.container.append(this.root);
88
+ this.head = this.root.querySelector("[data-histui-head]");
89
+ this.stage = this.root.querySelector("[data-histui-stage]");
90
+ this.canvas = this.root.querySelector("[data-histui-canvas]");
91
+ this.cards = this.root.querySelector("[data-histui-cards]");
92
+ this.hint = this.root.querySelector("[data-histui-hint]");
93
+ this.zoomBar = this.root.querySelector("[data-histui-zoom-bar]");
94
+
95
+ this.root.addEventListener("click", this.handleControlClick);
96
+ this.root.addEventListener("change", this.handleControlChange);
97
+
98
+ this.timeline = new TimelineView({
99
+ stage: this.stage,
100
+ canvas: this.canvas,
101
+ cards: this.cards,
102
+ hint: this.hint,
103
+ zoomBar: this.zoomBar,
104
+ themeRoot: this.root,
105
+ config: this.config,
106
+ t: this.t,
107
+ language: this.language,
108
+ direction: this.direction,
109
+ onSelect: (record) => this.handleTimelineSelect(record),
110
+ onViewportChange: (viewport) => {
111
+ this.viewport = viewport;
112
+ this.renderControls();
113
+ this.options.onViewportChange?.(viewport, this);
114
+ }
115
+ });
116
+ this.timeline.setOrientationSetting(this.orientation);
117
+ this.timeline.setAxisPlacement("horizontal", this.axisPlacement.horizontal);
118
+ this.timeline.setAxisPlacement("vertical", this.axisPlacement.vertical);
119
+ this.timeline.setLodEnabled(this.lodEnabled);
120
+ this.timeline.setExplodeEnabled(this.explodeEnabled);
121
+ this.renderControls();
122
+ }
123
+
124
+ setData(data, { filters = null, resetView = true } = {}) {
125
+ const normalized = normalizeTimelineData(data, {
126
+ defaultLanguage: this.language,
127
+ languages: this.config.app.languages || [this.language]
128
+ });
129
+ this.dataset = normalized.dataset;
130
+ this.records = normalized.records;
131
+ this.facets = collectFacets(this.records, this.language, normalized.fallbackLanguage);
132
+ this.filters = normalizeFilters(filters || {}, createDefaultFilters(this.records, this.facets));
133
+ this.applyFilters({ preserveView: !resetView });
134
+ return this;
135
+ }
136
+
137
+ setRecords(records, { dataset = null, filters = null, resetView = true } = {}) {
138
+ const normalized = normalizeRecordsInput(records, this.language, dataset?.id || "");
139
+ this.dataset = dataset || {
140
+ id: "records",
141
+ title: { [this.language]: "Timeline" },
142
+ defaultLanguage: this.language
143
+ };
144
+ this.records = normalized;
145
+ this.facets = collectFacets(this.records, this.language, this.dataset.defaultLanguage || this.language);
146
+ this.filters = normalizeFilters(filters || {}, createDefaultFilters(this.records, this.facets));
147
+ this.applyFilters({ preserveView: !resetView });
148
+ return this;
149
+ }
150
+
151
+ setFilters(filters, { preserveView = true } = {}) {
152
+ this.filters = normalizeFilters(filters, this.filters || createDefaultFilters(this.records, this.facets));
153
+ this.applyFilters({ preserveView });
154
+ return this;
155
+ }
156
+
157
+ resetFilters({ preserveView = false } = {}) {
158
+ this.filters = createDefaultFilters(this.records, this.facets);
159
+ this.applyFilters({ preserveView });
160
+ return this;
161
+ }
162
+
163
+ applyFilters({ preserveView = true } = {}) {
164
+ this.filteredRecords = filterRecords(this.records, this.filters);
165
+ this.timeline.setRecords(this.filteredRecords, { resetView: !preserveView });
166
+ if (!this.selected || !this.filteredRecords.some((record) => record.id === this.selected.id)) {
167
+ this.selected = null;
168
+ if (this.options.selectInitial && this.filteredRecords.length) {
169
+ this.selected = this.filteredRecords.find((record) => record.__meta.importance >= 9) || this.filteredRecords[0];
170
+ }
171
+ }
172
+ if (this.selected) this.timeline.select(this.selected.id, false);
173
+ this.renderControls();
174
+ this.options.onRecordsChange?.(this.filteredRecords, this);
175
+ return this;
176
+ }
177
+
178
+ select(recordId, { emit = true } = {}) {
179
+ const record = this.records.find((entry) => entry.id === recordId) || null;
180
+ if (!record) return this;
181
+ this.selected = record;
182
+ this.timeline.select(record.id, false);
183
+ this.renderControls();
184
+ if (emit) this.emitSelect(record);
185
+ return this;
186
+ }
187
+
188
+ fit(options) {
189
+ this.timeline.fit(options);
190
+ return this;
191
+ }
192
+
193
+ zoomBy(factor) {
194
+ this.timeline.zoomBy(factor);
195
+ return this;
196
+ }
197
+
198
+ setViewRange(start, end, options) {
199
+ this.timeline.setViewRange(start, end, options);
200
+ return this;
201
+ }
202
+
203
+ setOrientation(orientation) {
204
+ this.orientation = orientation;
205
+ this.timeline.setOrientationSetting(orientation);
206
+ this.renderControls();
207
+ this.track("orientation_change", { orientation });
208
+ return this;
209
+ }
210
+
211
+ setAxisPlacement(orientation, placement) {
212
+ this.axisPlacement[orientation] = placement;
213
+ this.timeline.setAxisPlacement(orientation, placement);
214
+ this.renderControls();
215
+ this.track("timeline_setting_change", { setting: `axis-${orientation}`, value: placement });
216
+ return this;
217
+ }
218
+
219
+ setLodEnabled(enabled) {
220
+ this.lodEnabled = Boolean(enabled);
221
+ this.timeline.setLodEnabled(this.lodEnabled);
222
+ this.renderControls();
223
+ this.track("timeline_setting_change", { setting: "lod", value: this.lodEnabled });
224
+ return this;
225
+ }
226
+
227
+ setExplodeEnabled(enabled) {
228
+ this.explodeEnabled = Boolean(enabled);
229
+ this.timeline.setExplodeEnabled(this.explodeEnabled);
230
+ this.renderControls();
231
+ this.track("timeline_setting_change", { setting: "explode", value: this.explodeEnabled });
232
+ return this;
233
+ }
234
+
235
+ setLanguage(language, direction = dirForLanguage(language)) {
236
+ this.language = language;
237
+ this.direction = direction;
238
+ this.t = this.options.translator || makeTranslator(language);
239
+ this.root.lang = language;
240
+ this.root.dir = direction;
241
+ this.timeline.setTranslator(this.t);
242
+ this.timeline.setLanguage(language, direction);
243
+ this.facets = collectFacets(this.records, language, this.dataset?.defaultLanguage || "en");
244
+ this.renderControls();
245
+ return this;
246
+ }
247
+
248
+ setTheme(themeOrId) {
249
+ const theme = typeof themeOrId === "string" ? getTheme(this.config, themeOrId) : themeOrId;
250
+ this.themeId = theme?.id || this.themeId;
251
+ this.applyTheme(theme);
252
+ this.timeline.render();
253
+ return this;
254
+ }
255
+
256
+ applyTheme(theme) {
257
+ applyTheme(theme, this.root);
258
+ }
259
+
260
+ getState() {
261
+ return {
262
+ dataset: this.dataset,
263
+ records: this.records,
264
+ filteredRecords: this.filteredRecords,
265
+ facets: this.facets,
266
+ filters: this.filters,
267
+ selected: this.selected,
268
+ viewport: this.viewport,
269
+ language: this.language,
270
+ direction: this.direction,
271
+ themeId: this.themeId,
272
+ orientation: this.orientation,
273
+ axisPlacement: { ...this.axisPlacement },
274
+ lodEnabled: this.lodEnabled,
275
+ explodeEnabled: this.explodeEnabled
276
+ };
277
+ }
278
+
279
+ destroy() {
280
+ this.timeline.destroy();
281
+ this.root.removeEventListener("click", this.handleControlClick);
282
+ this.root.removeEventListener("change", this.handleControlChange);
283
+ this.root.remove();
284
+ }
285
+
286
+ handleTimelineSelect(record) {
287
+ this.selected = record || null;
288
+ this.renderControls();
289
+ if (record) this.emitSelect(record);
290
+ }
291
+
292
+ emitSelect(record) {
293
+ this.options.onSelect?.(record, this);
294
+ this.track("record_select", {
295
+ dataset_id: this.dataset?.id || "",
296
+ record_id: record.id,
297
+ record_type: record.recordType
298
+ });
299
+ }
300
+
301
+ onControlClick(event) {
302
+ const action = event.target.closest("[data-histui-action]")?.dataset.histuiAction;
303
+ if (!action) return;
304
+ if (action === "zoom-in") this.zoomBy(0.72);
305
+ if (action === "zoom-out") this.zoomBy(1.35);
306
+ if (action === "fit") this.fit();
307
+ this.track("timeline_action", { action });
308
+ }
309
+
310
+ onControlChange(event) {
311
+ const control = event.target.closest("[data-histui-control]");
312
+ if (!control) return;
313
+ const name = control.dataset.histuiControl;
314
+ if (name === "orientation") this.setOrientation(control.value);
315
+ if (name === "axis-horizontal") this.setAxisPlacement("horizontal", control.value);
316
+ if (name === "axis-vertical") this.setAxisPlacement("vertical", control.value);
317
+ if (name === "lod") this.setLodEnabled(control.checked);
318
+ if (name === "explode") this.setExplodeEnabled(control.checked);
319
+ }
320
+
321
+ renderControls() {
322
+ if (!this.head) return;
323
+ if (!this.options.controls) {
324
+ this.root.classList.add("has-hidden-controls");
325
+ this.head.hidden = true;
326
+ return;
327
+ }
328
+ this.root.classList.remove("has-hidden-controls");
329
+ this.head.hidden = false;
330
+ const fallback = this.dataset?.defaultLanguage || this.language;
331
+ const title = this.options.title || textOf(this.dataset?.title || this.dataset?.label, this.language, fallback) || "Histui";
332
+ const description = this.options.description || textOf(this.dataset?.description, this.language, fallback);
333
+ const viewport = this.viewport || {
334
+ total: this.filteredRecords.length,
335
+ visible: 0,
336
+ hidden: 0,
337
+ span: 0,
338
+ orientation: this.orientation
339
+ };
340
+ const spanYears = Math.max(1, Math.round(viewport.span || 0));
341
+
342
+ this.head.innerHTML = `
343
+ <div class="histui-timeline-title">
344
+ <p class="histui-eyebrow">${escapeHtml(this.t("currentView", { count: this.filteredRecords.length, total: this.records.length }))}</p>
345
+ <h2>${escapeHtml(title)}</h2>
346
+ ${description ? `<p>${escapeHtml(description)}</p>` : ""}
347
+ </div>
348
+ <div class="histui-timeline-actions">
349
+ <button class="histui-icon-button" type="button" data-histui-action="zoom-out" title="${escapeHtml(this.t("zoomOut"))}">-</button>
350
+ <button class="histui-icon-button" type="button" data-histui-action="zoom-in" title="${escapeHtml(this.t("zoomIn"))}">+</button>
351
+ <button class="histui-text-button" type="button" data-histui-action="fit">${escapeHtml(this.t("fit"))}</button>
352
+ <label class="histui-select-field">
353
+ <span>${escapeHtml(this.t("orientation"))}</span>
354
+ <select data-histui-control="orientation">${this.renderOrientationOptions()}</select>
355
+ </label>
356
+ <label class="histui-select-field">
357
+ <span>${escapeHtml(this.t("horizontal"))} ${escapeHtml(this.t("axis"))}</span>
358
+ <select data-histui-control="axis-horizontal">${this.renderAxisOptions(this.axisPlacement.horizontal)}</select>
359
+ </label>
360
+ <label class="histui-select-field">
361
+ <span>${escapeHtml(this.t("vertical"))} ${escapeHtml(this.t("axis"))}</span>
362
+ <select data-histui-control="axis-vertical">${this.renderAxisOptions(this.axisPlacement.vertical)}</select>
363
+ </label>
364
+ <label class="histui-toggle-pill">
365
+ <input type="checkbox" data-histui-control="lod"${this.lodEnabled ? " checked" : ""}>
366
+ <span>${escapeHtml(this.t("lod"))}</span>
367
+ </label>
368
+ <label class="histui-toggle-pill">
369
+ <input type="checkbox" data-histui-control="explode"${this.explodeEnabled ? " checked" : ""}>
370
+ <span>${escapeHtml(this.t("explode"))}</span>
371
+ </label>
372
+ <span class="histui-viewport-chip">${escapeHtml(this.t("zoomLevel", { span: spanYears }))}</span>
373
+ </div>
374
+ `;
375
+ }
376
+
377
+ renderOrientationOptions() {
378
+ return [
379
+ ["auto", this.t("auto")],
380
+ ["horizontal", this.t("horizontal")],
381
+ ["vertical", this.t("vertical")]
382
+ ].map(([value, label]) => {
383
+ return `<option value="${escapeHtml(value)}"${this.orientation === value ? " selected" : ""}>${escapeHtml(label)}</option>`;
384
+ }).join("");
385
+ }
386
+
387
+ renderAxisOptions(active) {
388
+ return [
389
+ ["center", this.t("middle")],
390
+ ["side-start", this.t("sideStart")],
391
+ ["side-end", this.t("sideEnd")]
392
+ ].map(([value, label]) => {
393
+ return `<option value="${escapeHtml(value)}"${active === value ? " selected" : ""}>${escapeHtml(label)}</option>`;
394
+ }).join("");
395
+ }
396
+
397
+ initializeAnalytics() {
398
+ const measurementId = this.options.analytics?.measurementId || this.config.analytics?.googleAnalyticsMeasurementId;
399
+ initializeAnalytics({ measurementId, appName: this.config.app?.name || "Histui" });
400
+ }
401
+
402
+ track(name, params = {}) {
403
+ const payload = {
404
+ app_name: this.config.app?.name || "Histui",
405
+ ...params
406
+ };
407
+ this.options.onTrack?.(name, payload, this);
408
+ trackAnalyticsEvent(name, payload);
409
+ }
410
+ }
411
+
412
+ export function normalizeTimelineData(data, datasetConfig = {}) {
413
+ if (Array.isArray(data)) {
414
+ const records = normalizeRecordsInput(data, datasetConfig.defaultLanguage || "en", datasetConfig.id || "");
415
+ return {
416
+ dataset: {
417
+ id: datasetConfig.id || "records",
418
+ title: datasetConfig.title || { [datasetConfig.defaultLanguage || "en"]: "Timeline" },
419
+ defaultLanguage: datasetConfig.defaultLanguage || "en",
420
+ languages: datasetConfig.languages || [datasetConfig.defaultLanguage || "en"]
421
+ },
422
+ records,
423
+ fallbackLanguage: datasetConfig.defaultLanguage || "en"
424
+ };
425
+ }
426
+ return normalizePastStruct(data, datasetConfig);
427
+ }
428
+
429
+ function normalizeRecordsInput(records, fallbackLanguage = "en", datasetId = "") {
430
+ return records
431
+ .map((record) => record.__meta ? record : normalizeRecord(record, fallbackLanguage, datasetId))
432
+ .sort((a, b) => a.__meta.start - b.__meta.start || a.__meta.importance - b.__meta.importance);
433
+ }
434
+
435
+ function resolveContainer(container) {
436
+ if (typeof container !== "string") {
437
+ if (!container?.append) throw new Error("Histui container must be an element or selector.");
438
+ return container;
439
+ }
440
+ const element = document.querySelector(container);
441
+ if (!element) throw new Error(`Histui container not found: ${container}`);
442
+ return element;
443
+ }
444
+
445
+ function mergeConfig(base, override) {
446
+ if (Array.isArray(base) || Array.isArray(override)) return override ?? base;
447
+ if (!isPlainObject(base) || !isPlainObject(override)) return override ?? base;
448
+ const merged = { ...base };
449
+ for (const [key, value] of Object.entries(override)) {
450
+ merged[key] = mergeConfig(base[key], value);
451
+ }
452
+ return merged;
453
+ }
454
+
455
+ function isPlainObject(value) {
456
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
457
+ }