@mim/histui 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Histui
2
2
 
3
- Histui is a reusable, framework-agnostic interactive history timeline package. It can render PastStruct datasets or already-normalized records into a zoomable, pannable, responsive timeline with LOD, clustering, a zoom navigator, hover-linked connectors, axis placement controls, themes, Persian/English UI strings, and explode mode.
3
+ Histui is a reusable, framework-agnostic interactive history timeline package. It can render PastStruct datasets or already-normalized records into a zoomable, pannable, responsive timeline with LOD, clustering, a zoom navigator, hover-linked connectors, blueprint-style measurement indicators, axis placement controls, themes, Persian/English UI strings, and explode mode.
4
4
 
5
5
  ## Files
6
6
 
@@ -13,29 +13,34 @@ Histui is a reusable, framework-agnostic interactive history timeline package. I
13
13
 
14
14
  ## Basic Usage
15
15
 
16
- ```html
17
- <link rel="stylesheet" href="/path/to/histui/src/styles.css">
18
- <div id="timeline" style="height: 720px"></div>
19
- <script type="module">
20
- import { createHistuiTimeline } from "/path/to/histui/src/index.js";
21
-
22
- const histui = createHistuiTimeline({
23
- container: "#timeline",
24
- data: pastStructDataset,
25
- language: "en",
26
- themeId: "obsidian-lab",
27
- explodeEnabled: false,
28
- onSelect(record) {
29
- console.log("selected", record.id);
30
- },
31
- onViewportChange(viewport) {
32
- console.log(viewport);
33
- }
34
- });
16
+ Install the package from npm:
17
+
18
+ ```bash
19
+ npm install @mim/histui
20
+ ```
21
+
22
+ Import the JavaScript API and required stylesheet:
35
23
 
36
- histui.setExplodeEnabled(true);
37
- histui.setFilters({ minSignificance: 7 });
38
- </script>
24
+ ```js
25
+ import { createHistuiTimeline } from "@mim/histui";
26
+ import "@mim/histui/styles.css";
27
+
28
+ const histui = createHistuiTimeline({
29
+ container: "#timeline",
30
+ data: pastStructDataset,
31
+ language: "en",
32
+ themeId: "obsidian-lab",
33
+ explodeEnabled: false,
34
+ onSelect(record) {
35
+ console.log("selected", record.id);
36
+ },
37
+ onViewportChange(viewport) {
38
+ console.log(viewport);
39
+ }
40
+ });
41
+
42
+ histui.setExplodeEnabled(true);
43
+ histui.setFilters({ minSignificance: 7 });
39
44
  ```
40
45
 
41
46
  ## Local Development
@@ -56,7 +61,7 @@ Use a custom port when needed:
56
61
  PORT=5180 npm run dev
57
62
  ```
58
63
 
59
- Keep this server running while editing package files. For testing the package inside `histui-app-2`, keep the app pointed at the local file dependency (`"histui": "file:../histui"`), run the app dev server in that repo, and reinstall there only after changing package metadata or dependency wiring.
64
+ Keep this server running while editing package files. For testing the package inside `histui-app-2`, run `npm run histui:local` in the app repo to point `@mim/histui` at `../histui`, then run `npm run histui:published` when you want to switch the app back to the published package.
60
65
 
61
66
  ## Public API
62
67
 
@@ -69,7 +74,7 @@ import {
69
74
  createDefaultFilters,
70
75
  filterRecords,
71
76
  DEFAULT_HISTUI_CONFIG
72
- } from "histui";
77
+ } from "@mim/histui";
73
78
  ```
74
79
 
75
80
  ### `createHistuiTimeline(options)`
@@ -92,6 +97,7 @@ Common options:
92
97
  - `axisPlacement`: `{ horizontal, vertical }`, each `"center"`, `"side-start"`, or `"side-end"`.
93
98
  - `lodEnabled`: boolean.
94
99
  - `explodeEnabled`: boolean.
100
+ - `measurement`: optional override for `config.timeline.measurement`.
95
101
  - `analytics.measurementId`: optional Google Analytics measurement id.
96
102
  - `onSelect(record, instance)`: event callback.
97
103
  - `onViewportChange(viewport, instance)`: event callback.
@@ -112,6 +118,8 @@ Common options:
112
118
  - `setAxisPlacement(orientation, placement)`
113
119
  - `setLodEnabled(enabled)`
114
120
  - `setExplodeEnabled(enabled)`
121
+ - `setMeasurementOptions(options)`
122
+ - `setMeasurementEnabled(enabled)`
115
123
  - `setLanguage(language, direction)`
116
124
  - `setTheme(themeOrId)`
117
125
  - `getState()`
@@ -146,6 +154,11 @@ createHistuiTimeline({
146
154
  data,
147
155
  config: {
148
156
  timeline: {
157
+ measurement: {
158
+ enabled: true,
159
+ transient: true,
160
+ fadeOutMs: 1200
161
+ },
149
162
  explode: {
150
163
  maxVisible: 42,
151
164
  layers: 8,
@@ -156,6 +169,8 @@ createHistuiTimeline({
156
169
  });
157
170
  ```
158
171
 
172
+ `timeline.measurement.enabled` draws a dimension-style line across the currently visible timeline span and labels it with the visible year count. Set `timeline.measurement.transient` to `true` to show it only after the viewport changes; it fades out after `fadeOutMs` milliseconds, defaulting to `1200`.
173
+
159
174
  ## Check
160
175
 
161
176
  ```bash
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.0",
6
+ "version": "0.2.1",
7
7
  "description": "Reusable Histui interactive timeline package for PastStruct and normalized historical records.",
8
8
  "type": "module",
9
9
  "main": "./src/index.js",
@@ -29,6 +29,11 @@ export const DEFAULT_HISTUI_CONFIG = {
29
29
  trackInsetPx: 18,
30
30
  minSelectionPixels: 10
31
31
  },
32
+ measurement: {
33
+ enabled: false,
34
+ transient: false,
35
+ fadeOutMs: 1200
36
+ },
32
37
  lod: {
33
38
  enabled: true,
34
39
  thresholds: [
@@ -117,4 +122,3 @@ export const DEFAULT_HISTUI_CONFIG = {
117
122
  }
118
123
  ]
119
124
  };
120
-
package/src/index.d.ts CHANGED
@@ -7,6 +7,24 @@ export interface HistuiTheme {
7
7
  colors: Record<string, string>;
8
8
  }
9
9
 
10
+ export interface HistuiMeasurementConfig {
11
+ enabled?: boolean;
12
+ transient?: boolean;
13
+ showOnChangeOnly?: boolean;
14
+ visibleOnChangeOnly?: boolean;
15
+ fadeOutMs?: number;
16
+ hideAfterMs?: number;
17
+ offsetPx?: number;
18
+ }
19
+
20
+ export interface HistuiTimelineConfig {
21
+ minZoomSpanYears?: number;
22
+ maxZoomMultiplier?: number;
23
+ defaultPaddingRatio?: number;
24
+ measurement?: HistuiMeasurementConfig;
25
+ [key: string]: unknown;
26
+ }
27
+
10
28
  export interface HistuiConfig {
11
29
  app?: {
12
30
  name?: string;
@@ -22,7 +40,7 @@ export interface HistuiConfig {
22
40
  analytics?: {
23
41
  googleAnalyticsMeasurementId?: string;
24
42
  };
25
- timeline?: Record<string, unknown>;
43
+ timeline?: HistuiTimelineConfig;
26
44
  themes?: HistuiTheme[];
27
45
  }
28
46
 
@@ -77,6 +95,7 @@ export interface HistuiTimelineOptions<RecordType = any> {
77
95
  };
78
96
  lodEnabled?: boolean;
79
97
  explodeEnabled?: boolean;
98
+ measurement?: HistuiMeasurementConfig;
80
99
  analytics?: {
81
100
  measurementId?: string;
82
101
  };
@@ -104,6 +123,7 @@ export interface HistuiState<RecordType = any> {
104
123
  };
105
124
  lodEnabled: boolean;
106
125
  explodeEnabled: boolean;
126
+ measurement: HistuiMeasurementConfig;
107
127
  }
108
128
 
109
129
  export class HistuiTimeline<RecordType = any> {
@@ -120,6 +140,8 @@ export class HistuiTimeline<RecordType = any> {
120
140
  setAxisPlacement(orientation: "horizontal" | "vertical", placement: HistuiAxisPlacement): this;
121
141
  setLodEnabled(enabled: boolean): this;
122
142
  setExplodeEnabled(enabled: boolean): this;
143
+ setMeasurementOptions(options: HistuiMeasurementConfig): this;
144
+ setMeasurementEnabled(enabled: boolean): this;
123
145
  setLanguage(language: string, direction?: "ltr" | "rtl"): this;
124
146
  setTheme(themeOrId: string | HistuiTheme): this;
125
147
  applyTheme(theme: HistuiTheme): void;
@@ -134,4 +156,3 @@ export function createDefaultFilters(records: any[], facets?: unknown): HistuiFi
134
156
  export function filterRecords<RecordType = any>(records: RecordType[], filters: HistuiFilters): RecordType[];
135
157
  export function normalizeFilters(filters?: HistuiFilters, baseFilters?: HistuiFilters): HistuiFilters;
136
158
  export const DEFAULT_HISTUI_CONFIG: HistuiConfig;
137
-
package/src/index.js CHANGED
@@ -35,6 +35,10 @@ export class HistuiTimeline {
35
35
  ...options
36
36
  };
37
37
  this.config = mergeConfig(DEFAULT_HISTUI_CONFIG, options.config || {});
38
+ if (options.measurement) {
39
+ this.config.timeline = this.config.timeline || {};
40
+ this.config.timeline.measurement = mergeConfig(this.config.timeline.measurement || {}, options.measurement);
41
+ }
38
42
  this.language = options.language || this.config.app.defaultLanguage || "en";
39
43
  this.direction = options.direction || dirForLanguage(this.language);
40
44
  this.t = options.translator || makeTranslator(this.language);
@@ -232,6 +236,18 @@ export class HistuiTimeline {
232
236
  return this;
233
237
  }
234
238
 
239
+ setMeasurementOptions(options = {}) {
240
+ this.config.timeline = this.config.timeline || {};
241
+ this.config.timeline.measurement = mergeConfig(this.config.timeline.measurement || {}, options);
242
+ this.timeline.setMeasurementOptions(this.config.timeline.measurement);
243
+ this.track("timeline_setting_change", { setting: "measurement", value: { ...this.config.timeline.measurement } });
244
+ return this;
245
+ }
246
+
247
+ setMeasurementEnabled(enabled) {
248
+ return this.setMeasurementOptions({ enabled: Boolean(enabled) });
249
+ }
250
+
235
251
  setLanguage(language, direction = dirForLanguage(language)) {
236
252
  this.language = language;
237
253
  this.direction = direction;
@@ -272,7 +288,8 @@ export class HistuiTimeline {
272
288
  orientation: this.orientation,
273
289
  axisPlacement: { ...this.axisPlacement },
274
290
  lodEnabled: this.lodEnabled,
275
- explodeEnabled: this.explodeEnabled
291
+ explodeEnabled: this.explodeEnabled,
292
+ measurement: { ...(this.config.timeline?.measurement || {}) }
276
293
  };
277
294
  }
278
295
 
package/src/styles.css CHANGED
@@ -167,6 +167,160 @@
167
167
  pointer-events: none;
168
168
  }
169
169
 
170
+ .histui-measurement-line {
171
+ position: absolute;
172
+ z-index: 240;
173
+ opacity: 0;
174
+ pointer-events: none;
175
+ transform: translateY(-4px);
176
+ transition:
177
+ opacity 240ms ease,
178
+ transform 260ms cubic-bezier(0.16, 0.84, 0.28, 1);
179
+ }
180
+
181
+ .histui-measurement-line[hidden] {
182
+ display: none;
183
+ }
184
+
185
+ .histui-measurement-line.is-visible {
186
+ opacity: 1;
187
+ transform: translateY(0);
188
+ }
189
+
190
+ .histui-measurement-line[data-orientation="vertical"] {
191
+ transform: translateX(-4px);
192
+ }
193
+
194
+ .histui-measurement-line[data-orientation="vertical"].is-visible {
195
+ transform: translateX(0);
196
+ }
197
+
198
+ .histui-measurement-rule {
199
+ position: absolute;
200
+ inset: 0;
201
+ color: var(--accent2);
202
+ }
203
+
204
+ .histui-measurement-rule::before,
205
+ .histui-measurement-rule::after {
206
+ position: absolute;
207
+ background: color-mix(in srgb, var(--accent2) 72%, var(--line));
208
+ content: "";
209
+ }
210
+
211
+ .histui-measurement-line[data-orientation="horizontal"] .histui-measurement-rule {
212
+ top: 50%;
213
+ right: 0;
214
+ left: 0;
215
+ height: 0;
216
+ border-top: 1px dashed color-mix(in srgb, var(--accent2) 72%, var(--line));
217
+ box-shadow: 0 0 18px color-mix(in srgb, var(--accent2) 12%, transparent);
218
+ }
219
+
220
+ .histui-measurement-line[data-orientation="horizontal"] .histui-measurement-rule::before,
221
+ .histui-measurement-line[data-orientation="horizontal"] .histui-measurement-rule::after {
222
+ top: -7px;
223
+ width: 1px;
224
+ height: 14px;
225
+ }
226
+
227
+ .histui-measurement-line[data-orientation="horizontal"] .histui-measurement-rule::before {
228
+ left: 0;
229
+ }
230
+
231
+ .histui-measurement-line[data-orientation="horizontal"] .histui-measurement-rule::after {
232
+ right: 0;
233
+ }
234
+
235
+ .histui-measurement-line[data-orientation="vertical"] .histui-measurement-rule {
236
+ top: 0;
237
+ bottom: 0;
238
+ left: 50%;
239
+ width: 0;
240
+ border-left: 1px dashed color-mix(in srgb, var(--accent2) 72%, var(--line));
241
+ box-shadow: 0 0 18px color-mix(in srgb, var(--accent2) 12%, transparent);
242
+ }
243
+
244
+ .histui-measurement-line[data-orientation="vertical"] .histui-measurement-rule::before,
245
+ .histui-measurement-line[data-orientation="vertical"] .histui-measurement-rule::after {
246
+ left: -7px;
247
+ width: 14px;
248
+ height: 1px;
249
+ }
250
+
251
+ .histui-measurement-line[data-orientation="vertical"] .histui-measurement-rule::before {
252
+ top: 0;
253
+ }
254
+
255
+ .histui-measurement-line[data-orientation="vertical"] .histui-measurement-rule::after {
256
+ bottom: 0;
257
+ }
258
+
259
+ .histui-measurement-arrow {
260
+ position: absolute;
261
+ width: 0;
262
+ height: 0;
263
+ filter: drop-shadow(0 0 8px color-mix(in srgb, var(--accent2) 22%, transparent));
264
+ }
265
+
266
+ .histui-measurement-line[data-orientation="horizontal"] .histui-measurement-arrow {
267
+ top: 50%;
268
+ border-top: 5px solid transparent;
269
+ border-bottom: 5px solid transparent;
270
+ transform: translateY(-50%);
271
+ }
272
+
273
+ .histui-measurement-line[data-orientation="horizontal"] .histui-measurement-arrow-start {
274
+ left: 0;
275
+ border-left: 9px solid color-mix(in srgb, var(--accent2) 88%, var(--text));
276
+ }
277
+
278
+ .histui-measurement-line[data-orientation="horizontal"] .histui-measurement-arrow-end {
279
+ right: 0;
280
+ border-right: 9px solid color-mix(in srgb, var(--accent2) 88%, var(--text));
281
+ }
282
+
283
+ .histui-measurement-line[data-orientation="vertical"] .histui-measurement-arrow {
284
+ left: 50%;
285
+ border-right: 5px solid transparent;
286
+ border-left: 5px solid transparent;
287
+ transform: translateX(-50%);
288
+ }
289
+
290
+ .histui-measurement-line[data-orientation="vertical"] .histui-measurement-arrow-start {
291
+ top: 0;
292
+ border-top: 9px solid color-mix(in srgb, var(--accent2) 88%, var(--text));
293
+ }
294
+
295
+ .histui-measurement-line[data-orientation="vertical"] .histui-measurement-arrow-end {
296
+ bottom: 0;
297
+ border-bottom: 9px solid color-mix(in srgb, var(--accent2) 88%, var(--text));
298
+ }
299
+
300
+ .histui-measurement-label {
301
+ position: absolute;
302
+ top: 50%;
303
+ left: 50%;
304
+ max-width: min(220px, calc(100vw - 72px));
305
+ padding: 5px 9px;
306
+ overflow: hidden;
307
+ border: 1px solid color-mix(in srgb, var(--accent2) 62%, var(--line));
308
+ border-radius: 999px;
309
+ background:
310
+ linear-gradient(180deg, color-mix(in srgb, var(--surface-raised) 92%, var(--accent2) 8%), color-mix(in srgb, var(--panel) 92%, transparent));
311
+ box-shadow:
312
+ 0 10px 22px color-mix(in srgb, var(--shadow) 62%, transparent),
313
+ inset 0 0 0 1px color-mix(in srgb, var(--text) 8%, transparent);
314
+ color: var(--accent2);
315
+ font-family: var(--mono-font);
316
+ font-size: 11px;
317
+ line-height: 1;
318
+ text-align: center;
319
+ text-overflow: ellipsis;
320
+ transform: translate(-50%, -50%);
321
+ white-space: nowrap;
322
+ }
323
+
170
324
  .event-card {
171
325
  position: absolute;
172
326
  left: var(--x);
@@ -64,6 +64,9 @@ export class TimelineView {
64
64
  this.viewportAnimation = null;
65
65
  this.motionTimer = 0;
66
66
  this.explodeAnimationTimer = 0;
67
+ this.measurementFadeTimer = 0;
68
+ this.lastMeasurementKey = "";
69
+ this.suppressMeasurementChange = false;
67
70
  this.lastFrame = 0;
68
71
  this.lastMetrics = null;
69
72
  this.lastItems = { all: [], display: [], hidden: [] };
@@ -73,6 +76,7 @@ export class TimelineView {
73
76
  this.clusterTooltip.className = "cluster-tooltip";
74
77
  this.clusterTooltip.hidden = true;
75
78
  this.stage.append(this.clusterTooltip);
79
+ this.setupMeasurementLine();
76
80
  this.stage.classList.toggle("is-explode-mode", this.explodeEnabled);
77
81
  this.setupZoomBar();
78
82
 
@@ -177,6 +181,22 @@ export class TimelineView {
177
181
  this.zoomBar.addEventListener("keydown", (event) => this.handleZoomKeydown(event));
178
182
  }
179
183
 
184
+ setupMeasurementLine() {
185
+ this.measurementLine = document.createElement("div");
186
+ this.measurementLine.className = "histui-measurement-line";
187
+ this.measurementLine.hidden = true;
188
+ this.measurementLine.setAttribute("aria-hidden", "true");
189
+ this.measurementLine.innerHTML = `
190
+ <span class="histui-measurement-rule" aria-hidden="true">
191
+ <span class="histui-measurement-arrow histui-measurement-arrow-start"></span>
192
+ <span class="histui-measurement-arrow histui-measurement-arrow-end"></span>
193
+ </span>
194
+ <span class="histui-measurement-label"></span>
195
+ `;
196
+ this.measurementLabel = this.measurementLine.querySelector(".histui-measurement-label");
197
+ this.stage.append(this.measurementLine);
198
+ }
199
+
180
200
  setTranslator(t) {
181
201
  this.t = t;
182
202
  if (this.zoomBar) {
@@ -231,7 +251,19 @@ export class TimelineView {
231
251
  this.render();
232
252
  }
233
253
 
254
+ setMeasurementOptions(options = {}) {
255
+ this.config.timeline = this.config.timeline || {};
256
+ this.config.timeline.measurement = {
257
+ ...(this.config.timeline.measurement || {}),
258
+ ...options
259
+ };
260
+ this.lastMeasurementKey = "";
261
+ if (!this.getMeasurementConfig().enabled) this.hideMeasurementLine({ immediate: true });
262
+ this.render();
263
+ }
264
+
234
265
  setRecords(records, { resetView = false } = {}) {
266
+ this.suppressMeasurementChange = true;
235
267
  this.records = records;
236
268
  this.idMap = new Map(records.map((record) => [record.id, record]));
237
269
  this.hoveredClusterId = null;
@@ -860,6 +892,7 @@ export class TimelineView {
860
892
  this.drawClusters(metrics, colors, items.hidden);
861
893
  this.renderClusterTooltip(metrics);
862
894
  this.renderZoomBar(colors);
895
+ this.renderMeasurementLine(metrics);
863
896
  if (renderCards) this.renderCards(metrics, items.display);
864
897
  else this.updateCardHighlightClasses();
865
898
  this.renderHint(metrics, items);
@@ -1486,6 +1519,112 @@ export class TimelineView {
1486
1519
  this.clusterTooltip.hidden = false;
1487
1520
  }
1488
1521
 
1522
+ renderMeasurementLine(metrics) {
1523
+ if (!this.measurementLine || !this.measurementLabel) return;
1524
+ const measurement = this.getMeasurementConfig();
1525
+ this.stage.classList.toggle("has-measurement-line", measurement.enabled);
1526
+
1527
+ if (!measurement.enabled || metrics.axisLength < 80) {
1528
+ this.hideMeasurementLine({ immediate: true });
1529
+ return;
1530
+ }
1531
+
1532
+ const span = Math.max(1, Math.round(this.view.end - this.view.start));
1533
+ this.measurementLabel.textContent = this.t("zoomLevel", { span });
1534
+ this.measurementLine.dataset.orientation = metrics.orientation;
1535
+ this.measurementLine.hidden = false;
1536
+
1537
+ if (metrics.orientation === "horizontal") {
1538
+ const y = this.measurementCoordinate(metrics, measurement);
1539
+ this.measurementLine.style.left = `${metrics.axisStart}px`;
1540
+ this.measurementLine.style.top = `${y - 16}px`;
1541
+ this.measurementLine.style.width = `${metrics.axisLength}px`;
1542
+ this.measurementLine.style.height = "32px";
1543
+ } else {
1544
+ const x = this.measurementCoordinate(metrics, measurement);
1545
+ this.measurementLine.style.left = `${x - 16}px`;
1546
+ this.measurementLine.style.top = `${metrics.axisStart}px`;
1547
+ this.measurementLine.style.width = "32px";
1548
+ this.measurementLine.style.height = `${metrics.axisLength}px`;
1549
+ }
1550
+
1551
+ if (!measurement.transient) {
1552
+ this.showMeasurementLine({ persistent: true });
1553
+ return;
1554
+ }
1555
+
1556
+ const key = [
1557
+ metrics.orientation,
1558
+ Math.round(this.view.start * 1000) / 1000,
1559
+ Math.round(this.view.end * 1000) / 1000,
1560
+ metrics.axisLength
1561
+ ].join(":");
1562
+
1563
+ if (!this.lastMeasurementKey || this.suppressMeasurementChange) {
1564
+ this.lastMeasurementKey = key;
1565
+ this.suppressMeasurementChange = false;
1566
+ this.hideMeasurementLine();
1567
+ return;
1568
+ }
1569
+
1570
+ if (key !== this.lastMeasurementKey) {
1571
+ this.lastMeasurementKey = key;
1572
+ this.showMeasurementLine({ fadeOutMs: measurement.fadeOutMs });
1573
+ }
1574
+ }
1575
+
1576
+ getMeasurementConfig() {
1577
+ const measurement = this.config.timeline?.measurement || {};
1578
+ const fadeOutMs = Number(measurement.fadeOutMs ?? measurement.hideAfterMs ?? 1200);
1579
+ return {
1580
+ enabled: measurement.enabled === true,
1581
+ transient: measurement.transient === true ||
1582
+ measurement.showOnChangeOnly === true ||
1583
+ measurement.visibleOnChangeOnly === true,
1584
+ fadeOutMs: Number.isFinite(fadeOutMs) ? Math.max(0, fadeOutMs) : 1200,
1585
+ offsetPx: Number.isFinite(Number(measurement.offsetPx)) ? Number(measurement.offsetPx) : null
1586
+ };
1587
+ }
1588
+
1589
+ measurementCoordinate(metrics, measurement) {
1590
+ const offset = clamp(measurement.offsetPx ?? 32, 20, 110);
1591
+ if (metrics.orientation === "horizontal") {
1592
+ if (metrics.placement === "side-start") return clamp(metrics.axisCoordinate + offset, 18, metrics.height - 18);
1593
+ if (metrics.placement === "side-end") return clamp(metrics.axisCoordinate - offset, 18, metrics.height - 18);
1594
+ return clamp(offset, 18, metrics.height - 18);
1595
+ }
1596
+
1597
+ if (metrics.placement === "center") {
1598
+ return clamp(this.direction === "rtl" ? metrics.width - offset : offset, 18, metrics.width - 18);
1599
+ }
1600
+
1601
+ const side = metrics.axisCoordinate < metrics.width / 2 ? 1 : -1;
1602
+ return clamp(metrics.axisCoordinate + side * offset, 18, metrics.width - 18);
1603
+ }
1604
+
1605
+ showMeasurementLine({ persistent = false, fadeOutMs = 1200 } = {}) {
1606
+ if (!this.measurementLine) return;
1607
+ const wasHidden = this.measurementLine.hidden;
1608
+ this.measurementLine.hidden = false;
1609
+ if (wasHidden) void this.measurementLine.offsetWidth;
1610
+ this.measurementLine.classList.add("is-visible");
1611
+ if (this.measurementFadeTimer) window.clearTimeout(this.measurementFadeTimer);
1612
+ this.measurementFadeTimer = 0;
1613
+ if (persistent) return;
1614
+ this.measurementFadeTimer = window.setTimeout(() => {
1615
+ this.measurementFadeTimer = 0;
1616
+ this.hideMeasurementLine();
1617
+ }, fadeOutMs);
1618
+ }
1619
+
1620
+ hideMeasurementLine({ immediate = false } = {}) {
1621
+ if (!this.measurementLine) return;
1622
+ if (this.measurementFadeTimer) window.clearTimeout(this.measurementFadeTimer);
1623
+ this.measurementFadeTimer = 0;
1624
+ this.measurementLine.classList.remove("is-visible");
1625
+ if (immediate) this.measurementLine.hidden = true;
1626
+ }
1627
+
1489
1628
  renderCards(metrics, displayItems) {
1490
1629
  const axis = metrics.axisCoordinate;
1491
1630
  const mode = this.getLod(this.view.end - this.view.start).labelMode;
@@ -1835,11 +1974,14 @@ export class TimelineView {
1835
1974
  if (this.viewportAnimationFrame) cancelAnimationFrame(this.viewportAnimationFrame);
1836
1975
  if (this.motionTimer) window.clearTimeout(this.motionTimer);
1837
1976
  if (this.explodeAnimationTimer) window.clearTimeout(this.explodeAnimationTimer);
1977
+ if (this.measurementFadeTimer) window.clearTimeout(this.measurementFadeTimer);
1838
1978
  this.clusterTooltip?.remove();
1979
+ this.measurementLine?.remove();
1839
1980
  this.animationFrame = 0;
1840
1981
  this.viewportAnimationFrame = 0;
1841
1982
  this.motionTimer = 0;
1842
1983
  this.explodeAnimationTimer = 0;
1984
+ this.measurementFadeTimer = 0;
1843
1985
  }
1844
1986
  }
1845
1987