@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
package/src/section.ts CHANGED
@@ -3,14 +3,23 @@ import {
3
3
  LabelTrackerProvider,
4
4
  SectionLabelsColumn,
5
5
  } from "./units";
6
- import { ReactNode, FunctionComponent, useMemo } from "react";
6
+ import {
7
+ type CSSProperties,
8
+ ReactNode,
9
+ FunctionComponent,
10
+ useMemo,
11
+ } from "react";
7
12
  import {
8
13
  Timescale,
9
14
  TimescaleOrientation,
10
- TimescaleClickHandler,
11
- useMacrostratIntervals,
15
+ useMacrostratTimescales,
16
+ type Interval,
12
17
  type IntervalStyleBuilder,
18
+ type TimescaleClickData,
13
19
  } from "@macrostrat/timescale";
20
+
21
+ /** Macrostrat's international timescale, the default for a column */
22
+ const INTERNATIONAL_TIMESCALE_ID = 11;
14
23
  import { ColumnAxisType, SVG } from "@macrostrat/column-components";
15
24
  import hyper from "@macrostrat/hyper";
16
25
  import styles from "./column.module.sass";
@@ -19,10 +28,6 @@ import {
19
28
  useMacrostratUnits,
20
29
  MacrostratColumnProvider,
21
30
  } from "./data-provider";
22
- import {
23
- useMacrostratBaseURL,
24
- useMacrostratData,
25
- } from "@macrostrat/data-provider";
26
31
  import { Duration } from "./unit-details";
27
32
  import { Value } from "@macrostrat/data-components";
28
33
  import type { ExtUnit, PackageScaleLayoutData } from "./prepare-units/types";
@@ -37,6 +42,8 @@ export interface SectionSharedProps {
37
42
  columnWidth?: number;
38
43
  children?: ReactNode;
39
44
  showLabelColumn?: boolean;
45
+ /** Suppress the label for a unit drawn thinner than this many pixels */
46
+ labelSuppressHeight?: number;
40
47
  axisType?: ColumnAxisType;
41
48
  className?: string;
42
49
  clipUnits?: boolean;
@@ -63,6 +70,7 @@ export function SectionsColumn(props: SectionSharedProps) {
63
70
  showLabelColumn = true,
64
71
  clipUnits = true,
65
72
  maxInternalColumns,
73
+ labelSuppressHeight,
66
74
  } = props;
67
75
 
68
76
  const units = useMacrostratUnits();
@@ -82,6 +90,7 @@ export function SectionsColumn(props: SectionSharedProps) {
82
90
  }),
83
91
  h.if(showLabelColumn)(SectionLabelsColumn, {
84
92
  width: width - columnWidth,
93
+ labelSuppressHeight,
85
94
  }),
86
95
  ]);
87
96
  }
@@ -98,7 +107,8 @@ function SectionUnitsColumn(props: SectionSharedProps) {
98
107
  unconformityLabels = true,
99
108
  } = props;
100
109
 
101
- const { sections, totalHeight } = useMacrostratColumnData();
110
+ const { sections, totalHeight, axisType: columnAxisType } =
111
+ useMacrostratColumnData();
102
112
 
103
113
  const scaleData: PackageScaleLayoutData[] = sections.map((section) => {
104
114
  return section.scaleInfo;
@@ -138,6 +148,9 @@ function SectionUnitsColumn(props: SectionSharedProps) {
138
148
  h.if(unconformityLabels)(UnconformityLabels, {
139
149
  width,
140
150
  sections: scaleData,
151
+ // A gap between sections is measured in whatever the axis is: metres
152
+ // down a core, Myr across a time column
153
+ axisType: axisType ?? columnAxisType,
141
154
  verbose: false,
142
155
  }),
143
156
  ]);
@@ -201,12 +214,77 @@ function SectionUnits(props: SectionProps) {
201
214
  );
202
215
  }
203
216
 
217
+ /** A per-interval style that also knows which timescale the interval was
218
+ * drawn from. The same interval often appears in several of the timescales
219
+ * beside each other, and they aren't interchangeable — only one of them is
220
+ * the one a selection was made in. */
221
+ export type ColumnIntervalStyleBuilder =
222
+ | CSSProperties
223
+ | ((interval: Interval, timescaleID: number) => CSSProperties)
224
+ | null;
225
+
226
+ /** A timescale click, reporting which timescale was clicked in */
227
+ export type ColumnTimescaleClickHandler = (
228
+ event: Event,
229
+ data: TimescaleClickData & { timescaleID: number },
230
+ ) => void;
231
+
232
+ /** A timescale to draw beside a column.
233
+ *
234
+ * The international timescale is one of these, not a special case: it is the
235
+ * one that happens to be nested several levels deep. Anything with the same
236
+ * shape can take its place or sit beside it — a regional set from the API, a
237
+ * zonation assembled locally, or a hierarchy that isn't a timescale at all,
238
+ * so long as its "intervals" carry ages.
239
+ */
240
+ export interface ColumnTimescale {
241
+ /** Integer identifier. Clicks and per-interval styles report it, so a
242
+ * consumer can tell one column's copy of an interval from another's. For a
243
+ * Macrostrat timescale it is the `timescale_id`. */
244
+ id: number;
245
+ /** Shown above the column, unless `label` says otherwise */
246
+ name?: string;
247
+ /** The intervals themselves, flat or already nested by `pid`. Left out,
248
+ * the timescale `id` is fetched from the Macrostrat API. */
249
+ intervals?: Interval[];
250
+ /** Levels to draw, and so how many slots wide the column is (default
251
+ * `[1, 1]`: one level, which is what a flat timescale has) */
252
+ levels?: [number, number];
253
+ /** `oid` of the interval the tree hangs from (default 0) */
254
+ rootInterval?: number;
255
+ /** Drawn above the column in place of the name — any node, so a consumer
256
+ * can put a control or a legend there instead of text */
257
+ label?: ReactNode;
258
+ }
259
+
260
+ export type ColumnTimescaleLike = number | ColumnTimescale;
261
+
204
262
  interface CompositeTimescaleProps {
263
+ /** Every timescale to draw, in order. A bare number is a Macrostrat
264
+ * timescale to fetch and draw as a single level.
265
+ *
266
+ * Given this, `levels`, `timescaleID` and `additionalTimescales` are
267
+ * ignored: it says what they say and more. */
268
+ timescales?: ColumnTimescaleLike[];
269
+ /** Levels of the ICS hierarchy to show. If a number, the finest level is
270
+ * `levels[0]`, the second-finest is `levels[1]`, etc. If an array, the
271
+ * first element is the starting level, the second is the ending level. */
205
272
  levels?: [number, number] | number;
206
273
  unconformityLabels?: boolean;
207
- onClickInterval?: TimescaleClickHandler;
274
+ onClickInterval?: ColumnTimescaleClickHandler;
208
275
  /** Per-interval style (e.g. to highlight the currently selected interval). */
209
- intervalStyle?: IntervalStyleBuilder;
276
+ intervalStyle?: ColumnIntervalStyleBuilder;
277
+ /** The timescale to draw. Defaults to the international one; any other
278
+ * Macrostrat timescale (a regional or project-specific set of intervals)
279
+ * can be drawn in its place. */
280
+ timescaleID?: number;
281
+ /** Further timescales drawn as extra level columns beside the main one,
282
+ * against the same scale. Shorthand for listing them in `timescales`. */
283
+ additionalTimescales?: number[];
284
+ /** Draw the names above the columns. They sit above the column proper, so
285
+ * leave top padding for them. */
286
+ showLabels?: boolean;
287
+ className?: string;
210
288
  }
211
289
 
212
290
  export function CompositeTimescale(props: CompositeTimescaleProps) {
@@ -223,53 +301,85 @@ export function CompositeTimescale(props: CompositeTimescaleProps) {
223
301
 
224
302
  type CompositeTimescaleCoreProps = CompositeTimescaleProps & {
225
303
  packages: PackageScaleLayoutData[];
226
- onClickInterval?: TimescaleClickHandler;
227
304
  };
228
305
 
306
+ /** What is drawn above a timescale: its name, reading up the page, or
307
+ * whatever node the consumer gave instead. */
308
+ function TimescaleLabel({ timescale }: { timescale: ColumnTimescale }) {
309
+ const { label, name } = timescale;
310
+ const style = levelCountStyle(levelCount(timescale));
311
+
312
+ let content: ReactNode = label;
313
+ if (label == null && name != null) {
314
+ content = h("span.timescale-label-text", name);
315
+ }
316
+ if (content == null) return h("div.timescale-label", { style });
317
+
318
+ return h("div.timescale-label", { style }, content);
319
+ }
320
+
321
+ /** How wide a timescale draws: one slot per level it shows. */
322
+ function levelCountStyle(levelCount: number): CSSProperties {
323
+ return { "--timescale-level-count": levelCount } as CSSProperties;
324
+ }
325
+
229
326
  export function CompositeTimescaleCore(props: CompositeTimescaleCoreProps) {
230
327
  const {
231
- levels = 3,
232
328
  packages,
233
329
  unconformityLabels = false,
234
330
  onClickInterval,
235
331
  intervalStyle,
332
+ showLabels = false,
333
+ className,
236
334
  } = props;
237
335
 
238
- // Use intervals from Macrostrat API
239
- const baseURL = useMacrostratBaseURL();
240
- const intervals = useMacrostratIntervals({ baseURL });
241
-
242
- let _levels: [number, number];
243
- if (typeof levels === "number") {
244
- // If levels is a number, use the most common starting level
245
- _levels = [2, Math.max(2 + Math.min(levels, 5) - 1, 1)];
246
- } else {
247
- _levels = levels;
336
+ const timescales = useColumnTimescales(props);
337
+
338
+ /** Clicks and styles carry the timescale they came from, so a consumer can
339
+ * tell one column's copy of an interval from another's. */
340
+ function forTimescale(id: number) {
341
+ let onClick = null;
342
+ if (onClickInterval != null) {
343
+ onClick = (event: Event, data: TimescaleClickData) => {
344
+ onClickInterval(event, { ...data, timescaleID: id });
345
+ };
346
+ }
347
+ let style = intervalStyle as IntervalStyleBuilder;
348
+ if (typeof intervalStyle === "function") {
349
+ style = (interval: Interval) => intervalStyle(interval, id);
350
+ }
351
+ return { onClick, intervalStyle: style };
248
352
  }
249
353
 
250
- const nCols = _levels[1] - _levels[0] + 1;
251
-
252
- return h("div.timescale-column", [
354
+ return h("div.timescale-column", { className }, [
355
+ h.if(showLabels)(
356
+ "div.timescale-labels",
357
+ timescales.map((timescale) =>
358
+ h(TimescaleLabel, { key: timescale.id, timescale }),
359
+ ),
360
+ ),
253
361
  h(
254
362
  "div.timescales",
255
363
  packages.map((group) => {
256
364
  const { pixelHeight, paddingTop, key, scale } = group;
257
365
  return h(
258
- "div.timescale-container",
259
- { style: { paddingTop, "--timescale-level-count": nCols }, key },
260
- [
366
+ "div.section-timescales",
367
+ { style: { paddingTop }, key },
368
+ timescales.map((timescale) =>
261
369
  h(Timescale, {
370
+ key: timescale.id,
262
371
  orientation: TimescaleOrientation.VERTICAL,
263
372
  length: pixelHeight,
264
- levels: _levels,
373
+ levels: timescale.levels,
374
+ rootInterval: timescale.rootInterval,
265
375
  absoluteAgeScale: true,
266
376
  showAgeAxis: false,
267
377
  scale,
268
- intervals,
269
- onClick: onClickInterval,
270
- intervalStyle,
378
+ intervals: timescale.intervals,
379
+ style: levelCountStyle(levelCount(timescale)),
380
+ ...forTimescale(timescale.id),
271
381
  }),
272
- ],
382
+ ),
273
383
  );
274
384
  }),
275
385
  ),
@@ -282,6 +392,82 @@ export function CompositeTimescaleCore(props: CompositeTimescaleCoreProps) {
282
392
  ]);
283
393
  }
284
394
 
395
+ /** The timescales to draw, with their intervals.
396
+ *
397
+ * Every timescale a column draws is fetched in one place: a column is split
398
+ * into sections, and fetching per section would ask for the same timescale
399
+ * once per section per timescale.
400
+ */
401
+ function useColumnTimescales(props: CompositeTimescaleProps): ColumnTimescale[] {
402
+ const { timescales, levels = 3, timescaleID, additionalTimescales } = props;
403
+
404
+ const specs = useMemo(
405
+ () => timescaleSpecs({ timescales, levels, timescaleID, additionalTimescales }),
406
+ [
407
+ timescales,
408
+ levels,
409
+ timescaleID,
410
+ additionalTimescales?.join(","),
411
+ ],
412
+ );
413
+
414
+ // Only the ones that didn't come with their own intervals need fetching
415
+ const fetchIDs = useMemo(
416
+ () => specs.filter((d) => d.intervals == null).map((d) => d.id),
417
+ [specs],
418
+ );
419
+ const intervalSets = useMacrostratTimescales(fetchIDs);
420
+
421
+ return useMemo(
422
+ () =>
423
+ specs.map((spec) => ({
424
+ ...spec,
425
+ intervals: spec.intervals ?? intervalSets.get(spec.id) ?? [],
426
+ })),
427
+ [specs, intervalSets],
428
+ );
429
+ }
430
+
431
+ /** The `timescales` list, or one built from the older props that name the
432
+ * leveled timescale and the flat ones beside it. */
433
+ function timescaleSpecs(props: CompositeTimescaleProps): ColumnTimescale[] {
434
+ const { timescales, levels = 3, timescaleID, additionalTimescales = [] } = props;
435
+
436
+ if (timescales != null) {
437
+ return timescales.map(normalizeTimescale);
438
+ }
439
+
440
+ return [
441
+ {
442
+ id: timescaleID ?? INTERNATIONAL_TIMESCALE_ID,
443
+ levels: levelRange(levels),
444
+ },
445
+ ...additionalTimescales.map(normalizeTimescale),
446
+ ];
447
+ }
448
+
449
+ function normalizeTimescale(timescale: ColumnTimescaleLike): ColumnTimescale {
450
+ if (typeof timescale === "number") {
451
+ return { id: timescale, levels: FLAT_TIMESCALE_LEVELS };
452
+ }
453
+ return { levels: FLAT_TIMESCALE_LEVELS, ...timescale };
454
+ }
455
+
456
+ /** Regional and project timescales come back flat: one level of intervals. */
457
+ const FLAT_TIMESCALE_LEVELS: [number, number] = [1, 1];
458
+
459
+ function levelRange(levels: [number, number] | number): [number, number] {
460
+ if (typeof levels !== "number") return levels;
461
+ // A count, rather than a range: start at the level most columns start at
462
+ return [2, Math.max(2 + Math.min(levels, 5) - 1, 1)];
463
+ }
464
+
465
+ /** How many slots wide a timescale draws: one per level it shows. */
466
+ function levelCount(timescale: ColumnTimescale): number {
467
+ const [min, max] = timescale.levels ?? FLAT_TIMESCALE_LEVELS;
468
+ return Math.max(max - min + 1, 1);
469
+ }
470
+
285
471
  export function UnconformityLabels(props: {
286
472
  width: string | number;
287
473
  sections: PackageScaleLayoutData[];
@@ -0,0 +1,341 @@
1
+ /** Click-to-zoom navigation over geologic time.
2
+ *
3
+ * `useTimescaleZoom` turns clicks on a column's timescale into an animated
4
+ * age window: clicking an interval zooms to it, clicking the interval you are
5
+ * already in zooms back out a level, and the timescale shows a sliding window
6
+ * of levels that follows the selection, so finer intervals come into reach as
7
+ * you drill. The result spreads straight onto a `Column`.
8
+ */
9
+ import { type CSSProperties, useCallback, useMemo, useState } from "react";
10
+ import type {
11
+ Interval,
12
+ IntervalStyleBuilder,
13
+ TimescaleClickData,
14
+ TimescaleClickHandler,
15
+ } from "@macrostrat/timescale";
16
+ import { type AgeWindow, useAnimatedAgeWindow } from "./animated-age-window";
17
+
18
+ /** Coarsest level worth showing; level 0 is "all of geologic time" */
19
+ const MIN_TIMESCALE_LEVEL = 1;
20
+ /** Deepest level in the timescale (age/stage) */
21
+ const MAX_TIMESCALE_LEVEL = 5;
22
+ /** How many levels are shown at once */
23
+ const LEVEL_WINDOW = 3;
24
+ /** The level anchored on before an interval is picked. 3 (period) puts the
25
+ * starting window at era–epoch, the levels a `Column` shows by default. */
26
+ const DEFAULT_LEVEL = 3;
27
+
28
+ const SELECTED_INTERVAL_STYLE: CSSProperties = { fontWeight: "bold" };
29
+
30
+ export interface UseTimescaleZoomOptions {
31
+ /** The full data extent — the window zooming returns to. `null` until the
32
+ * data (and hence the extent) is known. */
33
+ fullExtent: AgeWindow | null;
34
+ /** When false the hook reports no window and no column props, so a view can
35
+ * offer zooming as an option without changing hook order (default `true`). */
36
+ enabled?: boolean;
37
+ /** How many timescale levels to show at once (default 3) */
38
+ levelWindow?: number;
39
+ /** The level to anchor the window on before an interval is picked
40
+ * (default 3, period) */
41
+ defaultLevel?: number;
42
+ minLevel?: number;
43
+ maxLevel?: number;
44
+ /** Animation duration in ms */
45
+ duration?: number;
46
+ /** Style for the selected interval — the one whose click zooms out
47
+ * (default bold) */
48
+ selectedIntervalStyle?: CSSProperties;
49
+ }
50
+
51
+ /** Timescale and age-window props, ready to spread onto a `Column`. */
52
+ export interface TimescaleZoomColumnProps {
53
+ showTimescale?: boolean;
54
+ timescaleLevels?: [number, number];
55
+ timescaleIntervalStyle?: IntervalStyleBuilder;
56
+ onClickTimescaleInterval?: TimescaleClickHandler;
57
+ t_age?: number;
58
+ b_age?: number;
59
+ isTransitioning?: boolean;
60
+ }
61
+
62
+ export interface TimescaleZoom {
63
+ enabled: boolean;
64
+ /** The rendered age window — `null` when disabled or before the extent is
65
+ * known */
66
+ window: AgeWindow | null;
67
+ /** The drill path: coarse to fine, the last entry being the selection */
68
+ intervals: Interval[];
69
+ selectedInterval: Interval | null;
70
+ /** The timescale the selection was made in, when the click said so */
71
+ selectedTimescaleID: number | null;
72
+ /** Every interval in the selection: the one drilled to, plus any added by
73
+ * shift-clicking. The window spans all of them. */
74
+ selectedIntervals: Interval[];
75
+ /** The levels the timescale should show, following the selection */
76
+ timescaleLevels: [number, number];
77
+ isFullExtent: boolean;
78
+ isAnimating: boolean;
79
+ /** Return to the full extent, clearing the drill path */
80
+ reset(): void;
81
+ zoomToInterval(interval: Interval): void;
82
+ onClickTimescaleInterval: TimescaleClickHandler;
83
+ timescaleIntervalStyle: (interval: Interval) => CSSProperties;
84
+ columnProps: TimescaleZoomColumnProps;
85
+ }
86
+
87
+ export function useTimescaleZoom(
88
+ options: UseTimescaleZoomOptions,
89
+ ): TimescaleZoom {
90
+ const {
91
+ fullExtent,
92
+ enabled = true,
93
+ levelWindow = LEVEL_WINDOW,
94
+ defaultLevel = DEFAULT_LEVEL,
95
+ minLevel = MIN_TIMESCALE_LEVEL,
96
+ maxLevel = MAX_TIMESCALE_LEVEL,
97
+ duration,
98
+ selectedIntervalStyle = SELECTED_INTERVAL_STYLE,
99
+ } = options;
100
+
101
+ const anim = useAnimatedAgeWindow({ fullExtent, duration });
102
+
103
+ // The intervals drilled through; the last one is the current selection
104
+ const [intervals, setIntervals] = useState<Interval[]>([]);
105
+ // Intervals shift-clicked into the selection alongside it
106
+ const [addedIntervals, setAddedIntervals] = useState<Interval[]>([]);
107
+ // Which timescale the selection was made in. The same interval is drawn in
108
+ // every timescale that contains it, and those copies are not the same
109
+ // selection: clicking one in another column moves the selection there.
110
+ const [selectedTimescaleID, setSelectedTimescaleID] = useState<number | null>(
111
+ null,
112
+ );
113
+ const selectedInterval = intervals[intervals.length - 1] ?? null;
114
+
115
+ const selectedIntervals = useMemo(() => {
116
+ if (selectedInterval == null) return addedIntervals;
117
+ return [selectedInterval, ...addedIntervals];
118
+ }, [selectedInterval, addedIntervals]);
119
+
120
+ const reset = useCallback(() => {
121
+ setIntervals([]);
122
+ setAddedIntervals([]);
123
+ setSelectedTimescaleID(null);
124
+ anim.reset();
125
+ }, [anim.reset]);
126
+
127
+ /** Take the interval clicked into (or out of) the selection, and span the
128
+ * result — how you widen a window to the interval next door. */
129
+ const extendToInterval = useCallback(
130
+ (interval: Interval) => {
131
+ let added = addedIntervals.filter((d) => d.oid !== interval.oid);
132
+ const wasSelected = added.length !== addedIntervals.length;
133
+ if (!wasSelected && interval.oid !== selectedInterval?.oid) {
134
+ added = [...addedIntervals, interval];
135
+ }
136
+ setAddedIntervals(added);
137
+
138
+ let selection = added;
139
+ if (selectedInterval != null) {
140
+ selection = [selectedInterval, ...added];
141
+ }
142
+ if (selection.length === 0) {
143
+ anim.reset();
144
+ return;
145
+ }
146
+ anim.zoomToWindow(intervalsWindow(selection));
147
+ },
148
+ [addedIntervals, selectedInterval, anim.zoomToWindow, anim.reset],
149
+ );
150
+
151
+ const zoomToInterval = useCallback(
152
+ (interval: Interval, timescaleID: number | null = null) => {
153
+ // Keep only the coarser intervals that actually contain this one, so
154
+ // stepping sideways into a different parent (the last stage of the
155
+ // Cambrian → the first of the Ordovician) doesn't strand the old one
156
+ const containing = intervals.filter(
157
+ (d) =>
158
+ d.lvl < interval.lvl &&
159
+ d.eag >= interval.eag &&
160
+ d.lag <= interval.lag,
161
+ );
162
+ setIntervals([...containing, interval]);
163
+ setAddedIntervals([]);
164
+ setSelectedTimescaleID(timescaleID);
165
+ anim.zoomToInterval(interval);
166
+ },
167
+ [intervals, anim.zoomToInterval],
168
+ );
169
+
170
+ const onClickTimescaleInterval = useCallback<TimescaleClickHandler>(
171
+ (event: Event, data: TimescaleClickData & { timescaleID?: number }) => {
172
+ const interval = data?.interval;
173
+ if (interval == null || interval.lvl == null) return;
174
+
175
+ const timescaleID = data.timescaleID ?? null;
176
+ // A click in a different timescale is a different selection, even on the
177
+ // same interval: it moves to that column rather than zooming back out
178
+ const sameTimescale =
179
+ timescaleID == null ||
180
+ selectedTimescaleID == null ||
181
+ timescaleID === selectedTimescaleID;
182
+
183
+ // Shift-click widens the window instead of moving it, so a selection can
184
+ // grow into the interval next door
185
+ if ((event as MouseEvent)?.shiftKey) {
186
+ extendToInterval(interval);
187
+ return;
188
+ }
189
+
190
+ // Clicking the interval you are in is the way back out: pop a level, or
191
+ // return to the full extent past the root
192
+ if (interval.oid === selectedInterval?.oid && addedIntervals.length === 0) {
193
+ const next = intervals.slice(0, -1);
194
+ setIntervals(next);
195
+ setSelectedTimescaleID(null);
196
+ const parent = next[next.length - 1] ?? null;
197
+ if (parent == null) {
198
+ anim.reset();
199
+ } else {
200
+ anim.zoomToInterval(parent);
201
+ }
202
+ return;
203
+ }
204
+
205
+ // Every other click navigates *to* the interval clicked, whatever its
206
+ // rank: a finer one drills in, a neighbor moves along the timescale, a
207
+ // coarser one zooms out to it
208
+ zoomToInterval(interval, timescaleID);
209
+ },
210
+ [
211
+ intervals,
212
+ selectedInterval,
213
+ selectedTimescaleID,
214
+ addedIntervals,
215
+ extendToInterval,
216
+ zoomToInterval,
217
+ anim.reset,
218
+ ],
219
+ );
220
+
221
+ const timescaleIntervalStyle = useCallback(
222
+ (interval: Interval): CSSProperties => {
223
+ if (selectedIntervals.some((d) => d.oid === interval.oid)) {
224
+ return selectedIntervalStyle;
225
+ }
226
+ return {};
227
+ },
228
+ [selectedIntervals, selectedIntervalStyle],
229
+ );
230
+
231
+ const levels = useMemo(() => {
232
+ let level = defaultLevel;
233
+ for (const interval of selectedIntervals) {
234
+ if (interval.lvl != null) level = Math.max(level, interval.lvl);
235
+ }
236
+ return levelsForSelected(level, { levelWindow, minLevel, maxLevel });
237
+ }, [selectedIntervals, defaultLevel, levelWindow, minLevel, maxLevel]);
238
+
239
+ if (!enabled) {
240
+ return {
241
+ ...disabledZoom,
242
+ selectedTimescaleID: null,
243
+ selectedIntervals: [],
244
+ timescaleLevels: levels,
245
+ reset,
246
+ zoomToInterval,
247
+ onClickTimescaleInterval,
248
+ timescaleIntervalStyle,
249
+ };
250
+ }
251
+
252
+ const window = anim.window ?? fullExtent;
253
+
254
+ return {
255
+ enabled: true,
256
+ window,
257
+ intervals,
258
+ selectedInterval,
259
+ selectedTimescaleID,
260
+ selectedIntervals,
261
+ timescaleLevels: levels,
262
+ isFullExtent: anim.isFullExtent,
263
+ isAnimating: anim.isAnimating,
264
+ reset,
265
+ zoomToInterval,
266
+ onClickTimescaleInterval,
267
+ timescaleIntervalStyle,
268
+ columnProps: {
269
+ showTimescale: true,
270
+ timescaleLevels: levels,
271
+ timescaleIntervalStyle,
272
+ onClickTimescaleInterval,
273
+ t_age: window?.t_age,
274
+ b_age: window?.b_age,
275
+ isTransitioning: anim.isAnimating,
276
+ },
277
+ };
278
+ }
279
+
280
+ /** What the hook reports when zooming is switched off: no window, and no
281
+ * props to change how the column renders. */
282
+ const disabledZoom: Pick<
283
+ TimescaleZoom,
284
+ | "enabled"
285
+ | "window"
286
+ | "intervals"
287
+ | "selectedInterval"
288
+ | "isFullExtent"
289
+ | "isAnimating"
290
+ | "columnProps"
291
+ > = {
292
+ enabled: false,
293
+ window: null,
294
+ intervals: [],
295
+ selectedInterval: null,
296
+ isFullExtent: true,
297
+ isAnimating: false,
298
+ columnProps: {},
299
+ };
300
+
301
+ interface LevelWindowOptions {
302
+ levelWindow: number;
303
+ minLevel: number;
304
+ maxLevel: number;
305
+ }
306
+
307
+ /** A fixed window of timescale levels that slides with the selected level —
308
+ * one coarser for context, the rest finer to drill into. Clamped so the
309
+ * useless level 0 is never shown. */
310
+ export function levelsForSelected(
311
+ selectedLevel: number,
312
+ options: LevelWindowOptions,
313
+ ): [number, number] {
314
+ const { levelWindow, minLevel, maxLevel } = options;
315
+ const span = Math.max(levelWindow - 1, 0);
316
+ const lo = Math.min(
317
+ Math.max(selectedLevel - 1, minLevel),
318
+ Math.max(maxLevel - span, minLevel),
319
+ );
320
+ return [lo, lo + span];
321
+ }
322
+
323
+ /** The window spanning a set of intervals. */
324
+ function intervalsWindow(intervals: Interval[]): AgeWindow {
325
+ return {
326
+ t_age: Math.min(...intervals.map((d) => d.lag)),
327
+ b_age: Math.max(...intervals.map((d) => d.eag)),
328
+ };
329
+ }
330
+
331
+ /** The age extent of a set of units, in the shape the age-window props take.
332
+ * `null` for an empty or absent set. */
333
+ export function unitsAgeExtent(
334
+ units: { t_age: number; b_age: number }[] | null | undefined,
335
+ ): AgeWindow | null {
336
+ if (units == null || units.length === 0) return null;
337
+ return {
338
+ t_age: Math.min(...units.map((d) => d.t_age)),
339
+ b_age: Math.max(...units.map((d) => d.b_age)),
340
+ };
341
+ }