@macrostrat/column-views 3.11.0 → 3.12.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.
Files changed (130) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/dist/age-model/data.cjs +57 -0
  3. package/dist/age-model/data.cjs.map +1 -0
  4. package/dist/age-model/data.d.ts +29 -0
  5. package/dist/age-model/data.js +57 -0
  6. package/dist/age-model/data.js.map +1 -0
  7. package/dist/age-model/details.cjs +261 -0
  8. package/dist/age-model/details.cjs.map +1 -0
  9. package/dist/age-model/details.d.ts +39 -0
  10. package/dist/age-model/details.js +258 -0
  11. package/dist/age-model/details.js.map +1 -0
  12. package/dist/age-model/details.module.sass.cjs +42 -0
  13. package/dist/age-model/details.module.sass.cjs.map +1 -0
  14. package/dist/age-model/details.module.sass.js +40 -0
  15. package/dist/age-model/details.module.sass.js.map +1 -0
  16. package/dist/age-model/index.d.ts +5 -0
  17. package/dist/age-model/legacy.cjs +28 -0
  18. package/dist/age-model/legacy.cjs.map +1 -0
  19. package/dist/age-model/legacy.d.ts +6 -0
  20. package/dist/age-model/legacy.js +26 -0
  21. package/dist/age-model/legacy.js.map +1 -0
  22. package/dist/age-model/surfaces.cjs +355 -0
  23. package/dist/age-model/surfaces.cjs.map +1 -0
  24. package/dist/age-model/surfaces.d.ts +68 -0
  25. package/dist/age-model/surfaces.js +352 -0
  26. package/dist/age-model/surfaces.js.map +1 -0
  27. package/dist/age-model/surfaces.module.sass.cjs +45 -0
  28. package/dist/age-model/surfaces.module.sass.cjs.map +1 -0
  29. package/dist/age-model/surfaces.module.sass.js +43 -0
  30. package/dist/age-model/surfaces.module.sass.js.map +1 -0
  31. package/dist/age-model/types.cjs +229 -0
  32. package/dist/age-model/types.cjs.map +1 -0
  33. package/dist/age-model/types.d.ts +112 -0
  34. package/dist/age-model/types.js +229 -0
  35. package/dist/age-model/types.js.map +1 -0
  36. package/dist/column-views.css +389 -57
  37. package/dist/column.cjs +20 -13
  38. package/dist/column.cjs.map +1 -1
  39. package/dist/column.d.ts +21 -6
  40. package/dist/column.js +21 -14
  41. package/dist/column.js.map +1 -1
  42. package/dist/column.module.sass.cjs +19 -15
  43. package/dist/column.module.sass.cjs.map +1 -1
  44. package/dist/column.module.sass.js +19 -15
  45. package/dist/column.module.sass.js.map +1 -1
  46. package/dist/data-provider/store.cjs +34 -1
  47. package/dist/data-provider/store.cjs.map +1 -1
  48. package/dist/data-provider/store.d.ts +5 -0
  49. package/dist/data-provider/store.js +35 -2
  50. package/dist/data-provider/store.js.map +1 -1
  51. package/dist/index.cjs +54 -3
  52. package/dist/index.cjs.map +1 -1
  53. package/dist/index.d.ts +3 -1
  54. package/dist/index.js +54 -3
  55. package/dist/index.js.map +1 -1
  56. package/dist/notes.cjs +11 -2
  57. package/dist/notes.cjs.map +1 -1
  58. package/dist/notes.d.ts +11 -1
  59. package/dist/notes.js +11 -2
  60. package/dist/notes.js.map +1 -1
  61. package/dist/prepare-units/composite-scale.cjs +18 -16
  62. package/dist/prepare-units/composite-scale.cjs.map +1 -1
  63. package/dist/prepare-units/composite-scale.js +18 -16
  64. package/dist/prepare-units/composite-scale.js.map +1 -1
  65. package/dist/prepare-units/density.cjs +83 -0
  66. package/dist/prepare-units/density.cjs.map +1 -0
  67. package/dist/prepare-units/density.d.ts +92 -0
  68. package/dist/prepare-units/density.js +83 -0
  69. package/dist/prepare-units/density.js.map +1 -0
  70. package/dist/prepare-units/index.cjs +15 -7
  71. package/dist/prepare-units/index.cjs.map +1 -1
  72. package/dist/prepare-units/index.d.ts +1 -0
  73. package/dist/prepare-units/index.js +15 -7
  74. package/dist/prepare-units/index.js.map +1 -1
  75. package/dist/prepare-units/types.cjs.map +1 -1
  76. package/dist/prepare-units/types.d.ts +34 -13
  77. package/dist/prepare-units/types.js.map +1 -1
  78. package/dist/section.cjs +108 -24
  79. package/dist/section.cjs.map +1 -1
  80. package/dist/section.d.ts +66 -6
  81. package/dist/section.js +109 -25
  82. package/dist/section.js.map +1 -1
  83. package/dist/timescale-zoom.cjs +191 -0
  84. package/dist/timescale-zoom.cjs.map +1 -0
  85. package/dist/timescale-zoom.d.ts +74 -0
  86. package/dist/timescale-zoom.js +191 -0
  87. package/dist/timescale-zoom.js.map +1 -0
  88. package/dist/unit-details/age-range.cjs +1 -0
  89. package/dist/unit-details/age-range.cjs.map +1 -1
  90. package/dist/unit-details/age-range.d.ts +5 -1
  91. package/dist/unit-details/age-range.js +1 -0
  92. package/dist/unit-details/age-range.js.map +1 -1
  93. package/dist/units/composite.cjs +4 -1
  94. package/dist/units/composite.cjs.map +1 -1
  95. package/dist/units/composite.d.ts +2 -0
  96. package/dist/units/composite.js +4 -1
  97. package/dist/units/composite.js.map +1 -1
  98. package/package.json +7 -7
  99. package/src/age-model/data.ts +98 -0
  100. package/src/age-model/details.module.sass +108 -0
  101. package/src/age-model/details.ts +333 -0
  102. package/src/age-model/index.ts +5 -0
  103. package/src/age-model/legacy.ts +30 -0
  104. package/src/age-model/surfaces.module.sass +168 -0
  105. package/src/age-model/surfaces.ts +568 -0
  106. package/src/age-model/types.ts +392 -0
  107. package/src/column.module.sass +59 -8
  108. package/src/column.ts +49 -23
  109. package/src/data-provider/store.ts +50 -1
  110. package/src/index.ts +5 -1
  111. package/src/notes.ts +20 -2
  112. package/src/prepare-units/composite-scale.ts +34 -31
  113. package/src/prepare-units/density.ts +197 -0
  114. package/src/prepare-units/index.ts +25 -6
  115. package/src/prepare-units/types.ts +34 -13
  116. package/src/section.ts +219 -33
  117. package/src/timescale-zoom.ts +341 -0
  118. package/src/unit-details/age-range.ts +3 -3
  119. package/src/units/composite.ts +11 -0
  120. package/dist/age-model-overlay.cjs +0 -50
  121. package/dist/age-model-overlay.cjs.map +0 -1
  122. package/dist/age-model-overlay.d.ts +0 -24
  123. package/dist/age-model-overlay.js +0 -48
  124. package/dist/age-model-overlay.js.map +0 -1
  125. package/dist/age-model-overlay.module.sass.cjs +0 -12
  126. package/dist/age-model-overlay.module.sass.cjs.map +0 -1
  127. package/dist/age-model-overlay.module.sass.js +0 -11
  128. package/dist/age-model-overlay.module.sass.js.map +0 -1
  129. package/src/age-model-overlay.module.sass +0 -11
  130. package/src/age-model-overlay.ts +0 -96
@@ -0,0 +1,568 @@
1
+ /** The surfaces view: age-model calibration surfaces drawn on a column.
2
+ *
3
+ * `ColumnSurfaces` is dropped into a `Column` as a child. It draws one line
4
+ * per surface across the units (`ColumnSurfaceLines`) and a notes column of
5
+ * labels beside them (`ColumnSurfaceLabels`), styled by `boundary_status` and
6
+ * `boundary_type`, with hover and selection.
7
+ */
8
+ import hyper from "@macrostrat/hyper";
9
+ import classNames from "classnames";
10
+ import {
11
+ type CSSProperties,
12
+ type RefObject,
13
+ useCallback,
14
+ useEffect,
15
+ useLayoutEffect,
16
+ useMemo,
17
+ useRef,
18
+ useState,
19
+ } from "react";
20
+ import type { NoteData } from "@macrostrat/column-components";
21
+ import {
22
+ type IntervalShort,
23
+ IntervalTag,
24
+ TagSize,
25
+ } from "@macrostrat/data-components";
26
+ import { useMacrostratDefs } from "@macrostrat/data-provider";
27
+ import {
28
+ useClaimLabelColumn,
29
+ useCompositeScale,
30
+ useMacrostratColumnData,
31
+ } from "../data-provider";
32
+ import { ColumnNotes } from "../notes";
33
+ import { AgeLabel, Proportion } from "../unit-details/age-range";
34
+ import columnStyles from "../column.module.sass";
35
+ import styles from "./surfaces.module.sass";
36
+ import {
37
+ type ColumnSurface,
38
+ type SurfaceStatus,
39
+ formatProportion,
40
+ surfaceLabel,
41
+ surfacePosition,
42
+ surfaceStatusLabels,
43
+ surfaceToken,
44
+ TIE_POINT_STATUSES,
45
+ } from "./types";
46
+ import { type UseColumnSurfacesOptions, useColumnSurfaces } from "./data";
47
+
48
+ const h = hyper.styled(styles);
49
+
50
+ /** How far the surface lines extend: across the units column only, or the
51
+ * full width of the column including its axes. */
52
+ export type SurfaceLinesExtent = "units" | "column";
53
+
54
+ export type SurfaceSelectionHandler = (surface: ColumnSurface | null) => void;
55
+
56
+ export interface SurfaceSelectionProps {
57
+ /** The selected surface's id. Pass `null` for a controlled, empty
58
+ * selection; leave undefined for the component to manage selection. */
59
+ selectedSurface?: ColumnSurface["id"] | null;
60
+ onSelectSurface?: SurfaceSelectionHandler;
61
+ }
62
+
63
+ export interface ColumnSurfacesProps
64
+ extends UseColumnSurfacesOptions, SurfaceSelectionProps {
65
+ showLines?: boolean;
66
+ showLabels?: boolean;
67
+ extent?: SurfaceLinesExtent;
68
+ /** Width of the labels column, connector included (default 200) */
69
+ labelWidth?: number;
70
+ labelPaddingLeft?: number;
71
+ /** Only show surfaces with these statuses */
72
+ statuses?: SurfaceStatus[] | null;
73
+ /** Which surfaces get a label. Defaults to the tie points — the statuses
74
+ * at which the age model was constrained — so interpolated (`modeled`),
75
+ * unspecified and unit-derived surfaces are drawn as lines only. Pass
76
+ * `null` to label every surface shown. */
77
+ labelStatuses?: SurfaceStatus[] | null;
78
+ /** Labels closer than this (in pixels) are thinned out, since the column
79
+ * has finite room; the selected surface's label is always kept. */
80
+ minLabelSpacing?: number;
81
+ className?: string;
82
+ }
83
+
84
+ export function ColumnSurfaces(props: ColumnSurfacesProps) {
85
+ const {
86
+ showLines = true,
87
+ showLabels = true,
88
+ extent = "units",
89
+ labelWidth,
90
+ labelPaddingLeft,
91
+ statuses,
92
+ labelStatuses = TIE_POINT_STATUSES,
93
+ minLabelSpacing,
94
+ selectedSurface,
95
+ onSelectSurface,
96
+ className,
97
+ ...dataOptions
98
+ } = props;
99
+
100
+ const { surfaces: allSurfaces } = useColumnSurfaces(dataOptions);
101
+ const surfaces = useMemo(
102
+ () => filterByStatus(allSurfaces, statuses),
103
+ [allSurfaces, statuses],
104
+ );
105
+
106
+ const [selectedID, select] = useSurfaceSelection(
107
+ selectedSurface,
108
+ onSelectSurface,
109
+ );
110
+
111
+ return h([
112
+ h.if(showLines)(ColumnSurfaceLines, {
113
+ surfaces,
114
+ extent,
115
+ selectedSurface: selectedID,
116
+ onSelectSurface: select,
117
+ className,
118
+ }),
119
+ h.if(showLabels)(ColumnSurfaceLabels, {
120
+ surfaces,
121
+ labelStatuses,
122
+ minLabelSpacing,
123
+ width: labelWidth,
124
+ paddingLeft: labelPaddingLeft,
125
+ selectedSurface: selectedID,
126
+ onSelectSurface: select,
127
+ className,
128
+ }),
129
+ ]);
130
+ }
131
+
132
+ export interface ColumnSurfaceLinesProps extends SurfaceSelectionProps {
133
+ surfaces: ColumnSurface[];
134
+ extent?: SurfaceLinesExtent;
135
+ className?: string;
136
+ }
137
+
138
+ /** One horizontal line per surface, overlaid on the column at the surface's
139
+ * age (or measured position, for height and depth columns). */
140
+ export function ColumnSurfaceLines(props: ColumnSurfaceLinesProps) {
141
+ const {
142
+ surfaces,
143
+ extent = "units",
144
+ selectedSurface,
145
+ onSelectSurface,
146
+ className,
147
+ } = props;
148
+ const scale = useCompositeScale();
149
+ const { axisType, totalHeight } = useMacrostratColumnData();
150
+ const intervalMap = useSurfaceIntervalDefs(surfaces);
151
+
152
+ const ref = useRef<HTMLDivElement>(null);
153
+ const unitsExtent = useUnitsColumnExtent(ref, extent === "units");
154
+
155
+ let style: CSSProperties = { height: totalHeight };
156
+ if (unitsExtent != null) {
157
+ style = { ...style, left: unitsExtent.left, width: unitsExtent.width };
158
+ }
159
+ const measuring = extent === "units" && unitsExtent == null;
160
+
161
+ return h(
162
+ "div.column-surface-lines",
163
+ { ref, style, className: classNames(className, { measuring }) },
164
+ surfaces.map((surface) => {
165
+ const position = surfacePosition(surface, axisType);
166
+ if (position == null) return null;
167
+ const y = scale(position);
168
+ if (y == null) return null;
169
+ return h(SurfaceLine, {
170
+ key: surface.id,
171
+ surface,
172
+ y,
173
+ color: surfaceIntervalColor(surface, intervalMap),
174
+ selected: surface.id === selectedSurface,
175
+ onSelect: onSelectSurface,
176
+ });
177
+ }),
178
+ );
179
+ }
180
+
181
+ function SurfaceLine({
182
+ surface,
183
+ y,
184
+ color,
185
+ selected,
186
+ onSelect,
187
+ }: {
188
+ surface: ColumnSurface;
189
+ y: number;
190
+ /** The interval color, when known; otherwise the status color is used */
191
+ color?: string | null;
192
+ selected: boolean;
193
+ onSelect?: SurfaceSelectionHandler;
194
+ }) {
195
+ const onClick = useCallback(
196
+ (evt: MouseEvent) => {
197
+ // Don't let the column deselect its unit
198
+ evt.stopPropagation();
199
+ if (selected) {
200
+ onSelect?.(null);
201
+ } else {
202
+ onSelect?.(surface);
203
+ }
204
+ },
205
+ [surface, selected, onSelect],
206
+ );
207
+
208
+ let style: CSSProperties = { top: y };
209
+ if (color != null) {
210
+ style = { ...style, "--surface-color": color } as CSSProperties;
211
+ }
212
+
213
+ return h(
214
+ "div.surface-line",
215
+ {
216
+ style,
217
+ className: classNames(surfaceClasses(surface), {
218
+ selected,
219
+ interactive: onSelect != null,
220
+ }),
221
+ title: surfaceLabel(surface),
222
+ onClick,
223
+ },
224
+ h("div.surface-line-inner"),
225
+ );
226
+ }
227
+
228
+ export interface ColumnSurfaceLabelsProps extends SurfaceSelectionProps {
229
+ surfaces: ColumnSurface[];
230
+ /** Which surfaces get a label (see `ColumnSurfacesProps.labelStatuses`) */
231
+ labelStatuses?: SurfaceStatus[] | null;
232
+ /** Minimum pixel spacing between labels (see `ColumnSurfacesProps`) */
233
+ minLabelSpacing?: number;
234
+ /** Total width, connector included */
235
+ width?: number;
236
+ paddingLeft?: number;
237
+ className?: string;
238
+ }
239
+
240
+ interface SurfaceNote extends NoteData {
241
+ /** The interval color, which the note's connector picks up */
242
+ color?: string;
243
+ surface: ColumnSurface;
244
+ /** The calibration interval, with color and rank from the definitions */
245
+ interval: IntervalShort | null;
246
+ selected: boolean;
247
+ }
248
+
249
+ /** A notes column of surface labels, laid out beside the units with the
250
+ * collision avoidance of `NotesColumn`. It takes the label column over from
251
+ * the unit labels while mounted: labels are one or the other. */
252
+ export function ColumnSurfaceLabels(props: ColumnSurfaceLabelsProps) {
253
+ const {
254
+ surfaces,
255
+ labelStatuses = TIE_POINT_STATUSES,
256
+ minLabelSpacing = 22,
257
+ width = 200,
258
+ paddingLeft = 24,
259
+ selectedSurface,
260
+ onSelectSurface,
261
+ className,
262
+ } = props;
263
+ const { axisType } = useMacrostratColumnData();
264
+ const scale = useCompositeScale();
265
+ const intervalMap = useSurfaceIntervalDefs(surfaces);
266
+ useClaimLabelColumn();
267
+
268
+ const labeled = useMemo(() => {
269
+ const byStatus = filterByStatus(surfaces, labelStatuses);
270
+ return thinByPixelSpacing(
271
+ byStatus,
272
+ (s) => scale(surfacePosition(s, axisType) ?? NaN),
273
+ minLabelSpacing,
274
+ selectedSurface,
275
+ );
276
+ }, [
277
+ surfaces,
278
+ labelStatuses,
279
+ minLabelSpacing,
280
+ selectedSurface,
281
+ scale,
282
+ axisType,
283
+ ]);
284
+
285
+ const notes: SurfaceNote[] = useMemo(() => {
286
+ const _notes: SurfaceNote[] = [];
287
+ for (const surface of labeled) {
288
+ const position = surfacePosition(surface, axisType);
289
+ if (position == null) continue;
290
+ _notes.push({
291
+ id: surface.id,
292
+ height: position,
293
+ note: surfaceLabel(surface),
294
+ // The note's color reaches its connector, so a label's leader line
295
+ // matches the surface line it points at
296
+ color: surfaceIntervalColor(surface, intervalMap) ?? undefined,
297
+ surface,
298
+ interval: calibrationInterval(surface, intervalMap),
299
+ selected: surface.id === selectedSurface,
300
+ });
301
+ }
302
+ return _notes;
303
+ }, [labeled, axisType, selectedSurface, intervalMap]);
304
+
305
+ const onClickNote = useCallback(
306
+ (note: NoteData) => {
307
+ const { surface, selected } = note as SurfaceNote;
308
+ if (selected) {
309
+ onSelectSurface?.(null);
310
+ } else {
311
+ onSelectSurface?.(surface);
312
+ }
313
+ },
314
+ [onSelectSurface],
315
+ );
316
+
317
+ return h(
318
+ "div.column-surface-labels",
319
+ { className },
320
+ h(ColumnNotes, {
321
+ notes,
322
+ width,
323
+ paddingLeft,
324
+ noteComponent: SurfaceNoteLabel,
325
+ onClickNote,
326
+ forceOptions: LABEL_FORCE_OPTIONS,
327
+ // The leader runs from the surface line in the column to the tag body,
328
+ // continuing a little past both ends rather than stopping in the gap —
329
+ // so it needs no endpoint marker of its own
330
+ connectorOverhang: LABEL_CONNECTOR_OVERHANG,
331
+ showPointMarker: false,
332
+ }),
333
+ );
334
+ }
335
+
336
+ /** Breathing room between stacked labels, in pixels */
337
+ const LABEL_FORCE_OPTIONS = { nodeSpacing: 2 };
338
+
339
+ /** How far the label's leader line continues past each end, in pixels: left
340
+ * into the column to meet the surface line, right under the tag body. */
341
+ const LABEL_CONNECTOR_OVERHANG: [number, number] = [8, 6];
342
+
343
+ /** A surface's label: its calibration interval as the standard interval tag
344
+ * (with the position within the interval as the tag's detail), and the
345
+ * modeled age beneath. Surfaces without a calibration show their status. */
346
+ function SurfaceNoteLabel({ note }: { note: SurfaceNote }) {
347
+ const { surface, interval, selected } = note;
348
+
349
+ let primary: any;
350
+ if (interval != null) {
351
+ primary = h(IntervalTag, {
352
+ interval,
353
+ size: TagSize.Small,
354
+ prefix: h(Proportion, { value: surface.proportion }),
355
+ className: "surface-interval-tag",
356
+ });
357
+ } else {
358
+ primary = h(
359
+ "span.surface-label-status",
360
+ `${surfaceStatusLabels[surface.status]} surface`,
361
+ );
362
+ }
363
+
364
+ return h(
365
+ "div.surface-label",
366
+ { className: classNames(surfaceClasses(surface), { selected }) },
367
+ h("div.surface-label-primary", primary),
368
+ );
369
+ }
370
+
371
+ /** The interval definitions for the surfaces' calibration intervals. The age
372
+ * model carries interval ids and names but no colors, so they come from the
373
+ * definitions store (which holds them all, and fetches once). */
374
+ function useSurfaceIntervalDefs(
375
+ surfaces: ColumnSurface[],
376
+ ): Map<number, any> | null {
377
+ const intervalIDs = useMemo(() => {
378
+ const ids = new Set<number>();
379
+ for (const surface of surfaces) {
380
+ if (surface.calibration != null) ids.add(surface.calibration.id);
381
+ }
382
+ return Array.from(ids);
383
+ }, [surfaces]);
384
+ return useMacrostratDefs("intervals", intervalIDs, null);
385
+ }
386
+
387
+ /** The color a surface is drawn in: its calibration interval's, so the line
388
+ * and its label's leader read as the interval the surface is tied to. `null`
389
+ * for an uncalibrated surface, or before the definitions load — the status
390
+ * color stands in. */
391
+ function surfaceIntervalColor(
392
+ surface: ColumnSurface,
393
+ intervalMap: Map<number, any> | null,
394
+ ): string | null {
395
+ const id = surface.calibration?.id;
396
+ if (id == null) return null;
397
+ return intervalMap?.get(id)?.color ?? null;
398
+ }
399
+
400
+ /** The calibration interval in the shape the interval tag takes, colored
401
+ * from the definitions when they have loaded. */
402
+ function calibrationInterval(
403
+ surface: ColumnSurface,
404
+ intervalMap: Map<number, any> | null,
405
+ ): IntervalShort | null {
406
+ const { calibration } = surface;
407
+ if (calibration == null) return null;
408
+ const def = intervalMap?.get(calibration.id);
409
+ return {
410
+ id: calibration.id,
411
+ name: calibration.name,
412
+ b_age: calibration.b_age,
413
+ t_age: calibration.t_age,
414
+ color: def?.color,
415
+ rank: def?.rank,
416
+ };
417
+ }
418
+
419
+ /** Class names shared by the lines, labels, legend and tags, so a status or
420
+ * type reads the same everywhere. */
421
+ export function surfaceClasses(surface: {
422
+ status?: SurfaceStatus | null;
423
+ type?: string | null;
424
+ }): string {
425
+ return classNames(
426
+ `status-${surfaceToken(surface.status)}`,
427
+ `type-${surfaceToken(surface.type)}`,
428
+ );
429
+ }
430
+
431
+ /** Walk the surfaces down the column and drop any whose label would sit
432
+ * within `minSpacing` pixels of the last one kept. The selected surface is
433
+ * always kept (and displaces a neighbor if need be). */
434
+ function thinByPixelSpacing(
435
+ surfaces: ColumnSurface[],
436
+ pixelPosition: (s: ColumnSurface) => number | null,
437
+ minSpacing: number,
438
+ selectedSurface: ColumnSurface["id"] | null | undefined,
439
+ ): ColumnSurface[] {
440
+ if (minSpacing <= 0) return surfaces;
441
+ const positioned = surfaces
442
+ .map((surface) => ({ surface, y: pixelPosition(surface) }))
443
+ .filter((d) => d.y != null && !Number.isNaN(d.y))
444
+ .sort((a, b) => a.y - b.y);
445
+
446
+ const kept: { surface: ColumnSurface; y: number }[] = [];
447
+ for (const item of positioned) {
448
+ const selected = item.surface.id === selectedSurface;
449
+ const last = kept[kept.length - 1];
450
+ if (last == null || item.y - last.y >= minSpacing) {
451
+ kept.push(item);
452
+ } else if (selected) {
453
+ // The selection wins its slot
454
+ kept[kept.length - 1] = item;
455
+ }
456
+ }
457
+ return kept.map((d) => d.surface);
458
+ }
459
+
460
+ function filterByStatus(
461
+ surfaces: ColumnSurface[],
462
+ statuses: SurfaceStatus[] | null | undefined,
463
+ ): ColumnSurface[] {
464
+ if (statuses == null) return surfaces;
465
+ const allowed = new Set(statuses);
466
+ return surfaces.filter((d) => allowed.has(d.status));
467
+ }
468
+
469
+ /** Controlled-or-uncontrolled selection: when `selectedID` is passed (even as
470
+ * `null`) the consumer owns it; otherwise it is kept internally. */
471
+ function useSurfaceSelection(
472
+ selectedID: ColumnSurface["id"] | null | undefined,
473
+ onSelect: SurfaceSelectionHandler | undefined,
474
+ ): [ColumnSurface["id"] | null, SurfaceSelectionHandler] {
475
+ const [internal, setInternal] = useState<ColumnSurface["id"] | null>(null);
476
+ const isControlled = selectedID !== undefined;
477
+
478
+ const select: SurfaceSelectionHandler = useCallback(
479
+ (surface) => {
480
+ setInternal(surface?.id ?? null);
481
+ onSelect?.(surface);
482
+ },
483
+ [onSelect],
484
+ );
485
+
486
+ let current = internal;
487
+ if (isControlled) {
488
+ current = selectedID;
489
+ }
490
+ return [current, select];
491
+ }
492
+
493
+ interface HorizontalExtent {
494
+ left: number;
495
+ width: number;
496
+ }
497
+
498
+ /** `useLayoutEffect` in the browser, `useEffect` on the server — where it
499
+ * would warn and has nothing to measure anyway. */
500
+ const useMeasureEffect =
501
+ typeof window === "undefined" ? useEffect : useLayoutEffect;
502
+
503
+ /** Measure where the units column sits within the `Column`, so an overlay
504
+ * can cover exactly the units rather than the axes. `null` while unmeasured,
505
+ * when disabled, or when the units column can't be found. */
506
+ function useUnitsColumnExtent(
507
+ ref: RefObject<HTMLDivElement | null>,
508
+ enabled: boolean,
509
+ ): HorizontalExtent | null {
510
+ const [extent, setExtent] = useState<HorizontalExtent | null>(null);
511
+
512
+ const measure = useCallback(() => {
513
+ if (!enabled) return;
514
+ const parent = ref.current?.parentElement;
515
+ const target = parent?.querySelector<HTMLElement>(
516
+ `.${unitsContainerClass()}`,
517
+ );
518
+ if (parent == null || target == null) return;
519
+
520
+ const p = parent.getBoundingClientRect();
521
+ const t = target.getBoundingClientRect();
522
+ const next = { left: t.left - p.left, width: t.width };
523
+ setExtent((prev) => {
524
+ if (prev?.left === next.left && prev?.width === next.width) {
525
+ return prev;
526
+ }
527
+ return next;
528
+ });
529
+ }, [enabled]);
530
+
531
+ // Re-measure on every render as a backstop: cheap, and it catches layout
532
+ // changes that no observer reports.
533
+ useMeasureEffect(measure);
534
+
535
+ useMeasureEffect(() => {
536
+ if (!enabled) {
537
+ setExtent(null);
538
+ return;
539
+ }
540
+ const parent = ref.current?.parentElement;
541
+ if (parent == null) return;
542
+ if (typeof ResizeObserver === "undefined") return;
543
+
544
+ const observer = new ResizeObserver(measure);
545
+ // The units column *moves* when a sibling changes width — the timescale
546
+ // widening once its intervals load, or its level count changing — and
547
+ // that is not a resize of the units column or of the column itself, so
548
+ // the siblings have to be watched too.
549
+ observer.observe(parent);
550
+ for (const child of Array.from(parent.children)) {
551
+ observer.observe(child);
552
+ }
553
+ return () => observer.disconnect();
554
+ }, [enabled, measure]);
555
+
556
+ return extent;
557
+ }
558
+
559
+ /** The (module-scoped) class of the units container rendered by
560
+ * `SectionsColumn`. */
561
+ function unitsContainerClass(): string {
562
+ const s = columnStyles as any;
563
+ return (
564
+ s["section-units-container"] ??
565
+ s.sectionUnitsContainer ??
566
+ "section-units-container"
567
+ );
568
+ }