@macrostrat/column-views 3.6.1 → 3.8.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 (96) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/column-views.css +118 -89
  3. package/dist/column.cjs +1 -2
  4. package/dist/column.cjs.map +1 -1
  5. package/dist/column.js +1 -2
  6. package/dist/column.js.map +1 -1
  7. package/dist/column.module.sass.cjs +15 -15
  8. package/dist/column.module.sass.js +15 -15
  9. package/dist/correlation-chart/main.cjs +136 -34
  10. package/dist/correlation-chart/main.cjs.map +1 -1
  11. package/dist/correlation-chart/main.d.ts +33 -2
  12. package/dist/correlation-chart/main.js +118 -15
  13. package/dist/correlation-chart/main.js.map +1 -1
  14. package/dist/correlation-chart/main.module.sass.cjs +23 -20
  15. package/dist/correlation-chart/main.module.sass.cjs.map +1 -1
  16. package/dist/correlation-chart/main.module.sass.js +23 -20
  17. package/dist/correlation-chart/main.module.sass.js.map +1 -1
  18. package/dist/correlation-chart/prepare-data.cjs +0 -1
  19. package/dist/correlation-chart/prepare-data.cjs.map +1 -1
  20. package/dist/correlation-chart/prepare-data.js +0 -1
  21. package/dist/correlation-chart/prepare-data.js.map +1 -1
  22. package/dist/data-provider/unit-selection.cjs +4 -1
  23. package/dist/data-provider/unit-selection.cjs.map +1 -1
  24. package/dist/data-provider/unit-selection.d.ts +1 -1
  25. package/dist/data-provider/unit-selection.js +4 -1
  26. package/dist/data-provider/unit-selection.js.map +1 -1
  27. package/dist/index.cjs +14 -4
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.js +3 -2
  30. package/dist/maps/column-correlation/core.cjs +127 -6
  31. package/dist/maps/column-correlation/core.cjs.map +1 -1
  32. package/dist/maps/column-correlation/core.js +128 -7
  33. package/dist/maps/column-correlation/core.js.map +1 -1
  34. package/dist/maps/column-correlation/state.cjs +63 -2
  35. package/dist/maps/column-correlation/state.cjs.map +1 -1
  36. package/dist/maps/column-correlation/state.d.ts +31 -1
  37. package/dist/maps/column-correlation/state.js +63 -2
  38. package/dist/maps/column-correlation/state.js.map +1 -1
  39. package/dist/maps/layers/columns.cjs +5 -1
  40. package/dist/maps/layers/columns.cjs.map +1 -1
  41. package/dist/maps/layers/columns.js +5 -1
  42. package/dist/maps/layers/columns.js.map +1 -1
  43. package/dist/section.cjs +8 -2
  44. package/dist/section.cjs.map +1 -1
  45. package/dist/section.d.ts +2 -0
  46. package/dist/section.js +8 -2
  47. package/dist/section.js.map +1 -1
  48. package/dist/unit-details/age-range.cjs +2 -1
  49. package/dist/unit-details/age-range.cjs.map +1 -1
  50. package/dist/unit-details/age-range.js +2 -1
  51. package/dist/unit-details/age-range.js.map +1 -1
  52. package/dist/unit-details/index.d.ts +1 -1
  53. package/dist/unit-details/panel.cjs +2 -3
  54. package/dist/unit-details/panel.cjs.map +1 -1
  55. package/dist/unit-details/panel.js +1 -2
  56. package/dist/unit-details/panel.js.map +1 -1
  57. package/dist/unit-details/panel.module.sass.cjs +18 -22
  58. package/dist/unit-details/panel.module.sass.cjs.map +1 -1
  59. package/dist/unit-details/panel.module.sass.js +18 -22
  60. package/dist/unit-details/panel.module.sass.js.map +1 -1
  61. package/dist/unit-details/strat-names.cjs +1 -2
  62. package/dist/unit-details/strat-names.cjs.map +1 -1
  63. package/dist/unit-details/strat-names.js +1 -2
  64. package/dist/unit-details/strat-names.js.map +1 -1
  65. package/dist/units/boxes.cjs +2 -1
  66. package/dist/units/boxes.cjs.map +1 -1
  67. package/dist/units/boxes.js +2 -1
  68. package/dist/units/boxes.js.map +1 -1
  69. package/package.json +7 -7
  70. package/src/column.module.sass +6 -0
  71. package/src/correlation-chart/main.module.sass +46 -2
  72. package/src/correlation-chart/main.ts +180 -9
  73. package/src/correlation-chart/prepare-data.ts +0 -1
  74. package/src/data-provider/unit-selection.ts +13 -2
  75. package/src/maps/column-correlation/column-correlation-map.stories.ts +12 -0
  76. package/src/maps/column-correlation/core.ts +155 -9
  77. package/src/maps/column-correlation/state.ts +110 -2
  78. package/src/maps/layers/columns.ts +7 -1
  79. package/src/section.ts +9 -1
  80. package/src/unit-details/age-range.ts +3 -1
  81. package/src/unit-details/index.ts +7 -1
  82. package/src/unit-details/panel.module.sass +0 -8
  83. package/src/unit-details/panel.ts +6 -6
  84. package/src/unit-details/strat-names.ts +1 -1
  85. package/src/units/boxes.ts +4 -2
  86. package/dist/correlation-chart/stories/utils.d.ts +0 -6
  87. package/dist/unit-details/identifiers.cjs +0 -56
  88. package/dist/unit-details/identifiers.cjs.map +0 -1
  89. package/dist/unit-details/identifiers.d.ts +0 -31
  90. package/dist/unit-details/identifiers.js +0 -54
  91. package/dist/unit-details/identifiers.js.map +0 -1
  92. package/src/correlation-chart/stories/correlation-chart.stories.ts +0 -228
  93. package/src/correlation-chart/stories/namibia-correlation.stories.ts +0 -151
  94. package/src/correlation-chart/stories/stories.module.sass +0 -18
  95. package/src/correlation-chart/stories/utils.ts +0 -24
  96. package/src/unit-details/identifiers.ts +0 -67
@@ -6,8 +6,7 @@ import {
6
6
  useColumnRef,
7
7
  } from "../data-provider";
8
8
  import { UnitDetailsFeature, UnitSelectionPopover } from "../unit-details";
9
- import hyper from "@macrostrat/hyper";
10
- import styles from "./main.module.sass";
9
+ import h from "./main.module.sass";
11
10
  import { useMemo } from "react";
12
11
  import { useInDarkMode } from "@macrostrat/ui-components";
13
12
  import { CompositeTimescaleCore } from "../section";
@@ -31,12 +30,30 @@ import {
31
30
  import { ColoredUnitComponent } from "../units";
32
31
  import { UnitBoxes } from "../units/boxes";
33
32
  import { ColumnContainer } from "../column";
34
- import { ColumnData } from "../data-provider";
33
+ import type { ColumnData } from "@macrostrat/data-provider";
35
34
  import { BaseUnit } from "@macrostrat/api-types";
36
35
  import { ScaleContinuousNumeric } from "d3-scale";
37
36
  import { ExtUnit } from "../prepare-units/types";
37
+ import type { TimescaleClickHandler } from "@macrostrat/timescale";
38
+ import { useMacrostratColumnInfo } from "@macrostrat/data-provider";
38
39
 
39
- const h = hyper.styled(styles);
40
+ /** Standard props passed to a `columnHeaderComponent`. Everything a header
41
+ * needs is provided here, so the component can be a pure function of its props
42
+ * (no hooks required). Column-level hover/click are handled by the chart. */
43
+ export interface ColumnHeaderProps {
44
+ /** The column data (units + identifier) for this column */
45
+ column: ColumnData;
46
+ /** The column's ID (col_id) */
47
+ columnID: number;
48
+ /** The column's index (left-to-right) within the chart */
49
+ columnIndex: number;
50
+ /** The units within the column */
51
+ units: ExtUnit[];
52
+ /** The rendered width of the column, in pixels */
53
+ width: number;
54
+ /** The column's name, resolved from column metadata when available */
55
+ columnName?: string | null;
56
+ }
40
57
 
41
58
  export interface CorrelationChartProps extends CorrelationChartSettings {
42
59
  data: ColumnData[];
@@ -47,9 +64,25 @@ export interface CorrelationChartProps extends CorrelationChartSettings {
47
64
  selectedUnit?: number | null;
48
65
  showUnitPopover?: boolean;
49
66
  unitComponent?: any;
67
+ /** Arbitrary content (e.g. column title and/or ID) rendered above each column */
68
+ columnHeaderComponent?: React.ComponentType<ColumnHeaderProps>;
69
+ /** Content rendered above the timescale axis (top-left), beside the column
70
+ * headers — e.g. a zoom/filter indicator. */
71
+ axisTopContent?: React.ReactNode;
50
72
  onUnitSelected?: (unitID: number | null, unit: BaseUnit | null) => void;
73
+ /** Called when a timescale interval is clicked (e.g. to zoom the age range) */
74
+ onClickTimescaleInterval?: TimescaleClickHandler;
75
+ /** Called when the pointer enters/leaves a column (header or body). Passes
76
+ * the column ID, or null on leave. */
77
+ onColumnMouseOver?: (columnID: number | null) => void;
78
+ /** Called when a column header is clicked (e.g. to frame it on a map) */
79
+ onColumnClick?: (columnID: number) => void;
51
80
  }
52
81
 
82
+ /** Horizontal padding of the main chart SVG. Column headers are aligned to
83
+ * this so that they line up with the columns beneath them. */
84
+ const chartPaddingH = 4;
85
+
53
86
  function MainChartArea({ children }) {
54
87
  const columnRef = useColumnRef();
55
88
  return h("div.main-chart", { ref: columnRef }, children);
@@ -71,6 +104,11 @@ export function CorrelationChart({
71
104
  selectedUnit,
72
105
  onUnitSelected,
73
106
  unitComponent,
107
+ columnHeaderComponent,
108
+ axisTopContent,
109
+ onClickTimescaleInterval,
110
+ onColumnMouseOver,
111
+ onColumnClick,
74
112
  ...scaleProps
75
113
  }: CorrelationChartProps) {
76
114
  const defaultScaleProps = {
@@ -115,9 +153,9 @@ export function CorrelationChart({
115
153
  { selectedUnit, onUnitSelected, units },
116
154
  h(ChartArea, [
117
155
  h(TimescaleColumn, {
118
- key: "timescale",
119
156
  scaleInfo,
120
157
  unconformityLabels,
158
+ onClickInterval: onClickTimescaleInterval,
121
159
  }),
122
160
  h(MainChartArea, [
123
161
  h(
@@ -126,7 +164,7 @@ export function CorrelationChart({
126
164
  className,
127
165
  innerWidth: mainWidth,
128
166
  height: scaleInfo.totalHeight,
129
- paddingH: 4,
167
+ paddingH: chartPaddingH,
130
168
  },
131
169
  packages.map((pkg, i) => {
132
170
  const { offset, domain, pixelScale, scale, key } =
@@ -141,6 +179,7 @@ export function CorrelationChart({
141
179
  pixelScale,
142
180
  scale,
143
181
  unitComponent,
182
+ onColumnMouseOver,
144
183
  });
145
184
  }),
146
185
  ),
@@ -150,6 +189,17 @@ export function CorrelationChart({
150
189
  // Navigation only works within a column for now...
151
190
  h(UnitKeyboardNavigation, { columnData: data }),
152
191
  ]),
192
+ // Rendered last so the sticky header paints above the unit boxes
193
+ // (positioned siblings paint in document order at the same z-index)
194
+ h(ColumnHeaderRow, {
195
+ data,
196
+ columnWidth,
197
+ columnSpacing,
198
+ columnHeaderComponent,
199
+ axisTopContent,
200
+ onColumnMouseOver,
201
+ onColumnClick,
202
+ }),
153
203
  ]),
154
204
  ),
155
205
  );
@@ -164,6 +214,7 @@ function Package({
164
214
  domain,
165
215
  pixelScale,
166
216
  scale,
217
+ onColumnMouseOver,
167
218
  }) {
168
219
  return h("g.package", { transform: `translate(0 ${offset})` }, [
169
220
  // Disable the SVG overlay for now
@@ -172,6 +223,7 @@ function Package({
172
223
  columnData.map((data, i) => {
173
224
  return h(Column, {
174
225
  units: data.units,
226
+ columnID: data.columnID,
175
227
  unitComponent,
176
228
  width: columnWidth,
177
229
  key: i,
@@ -179,6 +231,7 @@ function Package({
179
231
  pixelScale,
180
232
  scale,
181
233
  offsetLeft: i * (columnWidth + columnSpacing),
234
+ onColumnMouseOver,
182
235
  });
183
236
  }),
184
237
  ]),
@@ -187,6 +240,7 @@ function Package({
187
240
 
188
241
  interface ColumnProps {
189
242
  units: ExtUnit[];
243
+ columnID?: number;
190
244
  unitComponent?: React.FunctionComponent<any>;
191
245
  unitComponentProps?: any;
192
246
  showLabels?: boolean;
@@ -198,17 +252,20 @@ interface ColumnProps {
198
252
  domain: [number, number];
199
253
  pixelScale: number;
200
254
  scale?: ScaleContinuousNumeric<number, number>;
255
+ onColumnMouseOver?: (columnID: number | null) => void;
201
256
  }
202
257
 
203
258
  function Column(props: ColumnProps) {
204
259
  const {
205
260
  units,
261
+ columnID,
206
262
  width = 150,
207
263
  offsetLeft,
208
264
  domain,
209
265
  pixelScale,
210
266
  scale,
211
267
  unitComponent = ColoredUnitComponent,
268
+ onColumnMouseOver,
212
269
  } = props;
213
270
 
214
271
  const columnWidth = width;
@@ -217,10 +274,19 @@ function Column(props: ColumnProps) {
217
274
  return null;
218
275
  }
219
276
 
277
+ const hoverHandlers =
278
+ onColumnMouseOver != null && columnID != null
279
+ ? {
280
+ onMouseEnter: () => onColumnMouseOver(columnID),
281
+ onMouseLeave: () => onColumnMouseOver(null),
282
+ }
283
+ : {};
284
+
220
285
  return h(
221
286
  "g.section",
222
287
  {
223
288
  transform: `translate(${offsetLeft} 0)`,
289
+ ...hoverHandlers,
224
290
  },
225
291
  h(
226
292
  ColumnProvider,
@@ -235,7 +301,6 @@ function Column(props: ColumnProps) {
235
301
  h(UnitBoxes, {
236
302
  unitComponent,
237
303
  unitComponentProps: {
238
- nColumns: 2,
239
304
  width: columnWidth,
240
305
  showLabel: true,
241
306
  },
@@ -322,16 +387,122 @@ function ChartArea({ children }) {
322
387
  );
323
388
  }
324
389
 
390
+ interface ColumnHeaderRowProps {
391
+ data: ColumnData[];
392
+ columnWidth: number;
393
+ columnSpacing: number;
394
+ columnHeaderComponent?: React.ComponentType<ColumnHeaderProps>;
395
+ axisTopContent?: React.ReactNode;
396
+ onColumnMouseOver?: (columnID: number | null) => void;
397
+ onColumnClick?: (columnID: number) => void;
398
+ }
399
+
400
+ function ColumnHeaderRow({
401
+ data,
402
+ columnWidth,
403
+ columnSpacing,
404
+ columnHeaderComponent,
405
+ axisTopContent,
406
+ onColumnMouseOver,
407
+ onColumnClick,
408
+ }: ColumnHeaderRowProps) {
409
+ /** The top grid row: arbitrary content above the timescale axis (top-left)
410
+ * and, when a header component is provided, per-column headers aligned with
411
+ * the columns. Collapses to nothing when neither is present. */
412
+ const Component = columnHeaderComponent;
413
+ if (Component == null && axisTopContent == null) {
414
+ return null;
415
+ }
416
+
417
+ return h([
418
+ h("div.column-header-spacer", axisTopContent),
419
+ h.if(Component != null)(
420
+ "div.column-header-row",
421
+ {
422
+ style: {
423
+ paddingLeft: chartPaddingH,
424
+ paddingRight: chartPaddingH,
425
+ gap: columnSpacing,
426
+ },
427
+ },
428
+ data.map((column, i) => {
429
+ return h(ColumnHeaderCell, {
430
+ key: column.columnID ?? i,
431
+ column,
432
+ columnIndex: i,
433
+ columnWidth,
434
+ Component: Component!,
435
+ onColumnMouseOver,
436
+ onColumnClick,
437
+ });
438
+ }),
439
+ ),
440
+ ]);
441
+ }
442
+
443
+ function ColumnHeaderCell({
444
+ column,
445
+ columnIndex,
446
+ columnWidth,
447
+ Component,
448
+ onColumnMouseOver,
449
+ onColumnClick,
450
+ }: {
451
+ column: ColumnData;
452
+ columnIndex: number;
453
+ columnWidth: number;
454
+ Component: React.ComponentType<ColumnHeaderProps>;
455
+ onColumnMouseOver?: (columnID: number | null) => void;
456
+ onColumnClick?: (columnID: number) => void;
457
+ }) {
458
+ /** Resolves the column name and wires column-level hover/click so the header
459
+ * component itself can stay a pure function of props. */
460
+ const columnID = column.columnID;
461
+ const info = useMacrostratColumnInfo(columnID);
462
+
463
+ return h(
464
+ "div.column-header-cell",
465
+ {
466
+ style: {
467
+ width: columnWidth,
468
+ minWidth: columnWidth,
469
+ maxWidth: columnWidth,
470
+ },
471
+ onMouseEnter: () => onColumnMouseOver?.(columnID),
472
+ onMouseLeave: () => onColumnMouseOver?.(null),
473
+ onClick: onColumnClick
474
+ ? (e: React.MouseEvent) => {
475
+ e.stopPropagation();
476
+ onColumnClick(columnID);
477
+ }
478
+ : undefined,
479
+ },
480
+ h(Component, {
481
+ column,
482
+ columnID,
483
+ columnIndex,
484
+ units: column.units as ExtUnit[],
485
+ width: columnWidth,
486
+ columnName: info?.col_name ?? null,
487
+ }),
488
+ );
489
+ }
490
+
325
491
  interface TimescaleColumnProps {
326
492
  scaleInfo: CompositeStratigraphicScaleInfo;
327
493
  showLabels?: boolean;
328
494
  unconformityLabels?: boolean;
495
+ onClickInterval?: TimescaleClickHandler;
329
496
  }
330
497
 
331
498
  function TimescaleColumn(props: TimescaleColumnProps) {
332
- const { scaleInfo, unconformityLabels = true } = props;
499
+ const { scaleInfo, unconformityLabels = true, onClickInterval } = props;
333
500
  return h("div.column-container.age-axis-container", [
334
501
  h(CompositeAgeAxisCore, { ...scaleInfo }),
335
- h(CompositeTimescaleCore, { ...scaleInfo, unconformityLabels }),
502
+ h(CompositeTimescaleCore, {
503
+ ...scaleInfo,
504
+ unconformityLabels,
505
+ onClickInterval,
506
+ }),
336
507
  ]);
337
508
  }
@@ -44,7 +44,6 @@ export function buildCorrelationChartData(
44
44
  columns: ColumnData[],
45
45
  settings: CorrelationChartSettings,
46
46
  ): CorrelationChartData {
47
- console.log(settings);
48
47
  const {
49
48
  mergeSections = MergeSectionsMode.OVERLAPPING,
50
49
  targetUnitHeight,
@@ -156,12 +156,23 @@ if (props.onClickedColumn) {
156
156
 
157
157
  export function useUnitSelectionTarget(
158
158
  unit: IUnit,
159
- ): [React.RefObject<HTMLElement>, boolean, (evt: Event) => void] {
159
+ ): [React.RefObject<HTMLElement>, boolean, (evt: Event) => void, boolean] {
160
160
  const ref = useRef<HTMLElement>(null);
161
161
  const [selectedUnit, selectUnit] = useUnitSelection();
162
162
  const selected = selectedUnit?.unit_id == unit.unit_id;
163
163
  const selectedUnitElement = scope.useAtomValue(selectedUnitElementAtom);
164
164
 
165
+ // "Linked" units share a stratigraphic name with the selected unit (e.g.
166
+ // the same formation correlated across columns), but are not the selection
167
+ // itself. They receive a subtler highlight.
168
+ const selectedStratName = (selectedUnit as any)?.strat_name_id;
169
+ const unitStratName = (unit as any)?.strat_name_id;
170
+ const linked =
171
+ !selected &&
172
+ selectedStratName != null &&
173
+ unitStratName != null &&
174
+ unitStratName === selectedStratName;
175
+
165
176
  const onClick = useCallback(
166
177
  (evt: Event) => {
167
178
  selectUnit?.(unit, ref.current);
@@ -185,7 +196,7 @@ export function useUnitSelectionTarget(
185
196
  });
186
197
  }, [selectedUnitElement]);
187
198
 
188
- return [ref, selected, onClick];
199
+ return [ref, selected, onClick, linked];
189
200
  }
190
201
 
191
202
  export function UnitKeyboardNavigation({
@@ -15,6 +15,7 @@ function ColumnCorrelationMapExt(
15
15
  ) {
16
16
  const {
17
17
  focusedLine,
18
+ manualColumns,
18
19
  columns,
19
20
  onSelectColumns,
20
21
  projectID,
@@ -26,6 +27,7 @@ function ColumnCorrelationMapExt(
26
27
  ColumnCorrelationProvider,
27
28
  {
28
29
  focusedLine,
30
+ manualColumns,
29
31
  columns,
30
32
  onSelectColumns,
31
33
  projectID,
@@ -63,6 +65,16 @@ export const withPreloadedCrossSection = {
63
65
  },
64
66
  };
65
67
 
68
+ // Manual selection: click columns to add/remove them (no line of section).
69
+ // Hovering highlights a column; the dashed line shows the selection order.
70
+ export const manualColumnSelection = {
71
+ args: {
72
+ style: { width: "800px", height: "600px" },
73
+ padding: 100,
74
+ manualColumns: [432, 490],
75
+ },
76
+ };
77
+
66
78
  export const nonStandardProject = {
67
79
  args: {
68
80
  style: { width: "800px", height: "600px" },
@@ -7,7 +7,7 @@ import {
7
7
  import { LngLatBounds, Style } from "mapbox-gl";
8
8
  import h from "@macrostrat/hyper";
9
9
  import { Feature, FeatureCollection } from "geojson";
10
- import { ReactNode, useMemo } from "react";
10
+ import { ReactNode, useMemo, useRef } from "react";
11
11
  import { setGeoJSON, buildGeoJSONSource } from "@macrostrat/mapbox-utils";
12
12
 
13
13
  import { useCorrelationMapStore } from "./state";
@@ -36,12 +36,48 @@ export function ColumnCorrelationMap(props: CorrelationMapProps) {
36
36
  h(ColumnsLayer, { color: columnColor }),
37
37
  h(SelectedColumnsLayer),
38
38
  h(MapClickHandler),
39
+ h(ColumnInteractionHandler),
40
+ h(HoveredColumnHighlight),
41
+ h(ColumnZoomer, { padding }),
39
42
  h(SectionLine, { padding }),
40
43
  children,
41
44
  ],
42
45
  );
43
46
  }
44
47
 
48
+ function ColumnZoomer({ padding }: { padding: number }) {
49
+ /** Frame a single column when `zoomColumn` is set (e.g. header click). */
50
+ const zoomColumn = useCorrelationMapStore((state) => state.zoomColumn);
51
+ const zoomNonce = useCorrelationMapStore((state) => state.zoomNonce);
52
+ const columns = useCorrelationMapStore((state) => state.columns);
53
+
54
+ const bounds = useMemo(() => {
55
+ if (zoomColumn == null || columns == null) return null;
56
+ const col = columns.find((c) => c.properties?.col_id === zoomColumn);
57
+ if (col?.geometry == null) return null;
58
+ const b = new LngLatBounds();
59
+ forEachCoordinate(col.geometry, (coord) => b.extend(coord as [number, number]));
60
+ return b.isEmpty() ? null : b;
61
+ // zoomNonce forces a fresh bounds object so repeat clicks re-frame
62
+ }, [zoomColumn, zoomNonce, columns]);
63
+
64
+ useMapEaseTo({ bounds, padding: padding * 2 });
65
+ return null;
66
+ }
67
+
68
+ function forEachCoordinate(geometry: any, fn: (coord: number[]) => void) {
69
+ const walk = (coords: any) => {
70
+ if (typeof coords[0] === "number") {
71
+ fn(coords);
72
+ } else {
73
+ for (const c of coords) walk(c);
74
+ }
75
+ };
76
+ if (geometry?.coordinates != null) walk(geometry.coordinates);
77
+ }
78
+
79
+ const columnLayers = ["columns-fill", "columns-points"];
80
+
45
81
  function MapClickHandler() {
46
82
  const onClickMap = useCorrelationMapStore((state) => state.onClickMap);
47
83
 
@@ -55,6 +91,102 @@ function MapClickHandler() {
55
91
  return null;
56
92
  }
57
93
 
94
+ function ColumnInteractionHandler() {
95
+ /** Per-column click (manual selection) and hover highlighting. */
96
+ const toggleColumn = useCorrelationMapStore((state) => state.toggleColumn);
97
+ const setHoveredColumn = useCorrelationMapStore(
98
+ (state) => state.setHoveredColumn,
99
+ );
100
+ const isManual = useCorrelationMapStore(
101
+ (state) => state.selectionMode === "manual",
102
+ );
103
+ const hoveredRef = useRef<number | string | null>(null);
104
+
105
+ // Click a column to toggle it in/out of the selection (manual mode only)
106
+ useMapStyleOperator(
107
+ (map) => {
108
+ if (!isManual) return;
109
+ const onClick = (e) => {
110
+ const colID = e.features?.[0]?.properties?.col_id;
111
+ if (colID != null) toggleColumn(colID);
112
+ };
113
+ map.on("click", columnLayers, onClick);
114
+ return () => map.off("click", columnLayers, onClick);
115
+ },
116
+ [isManual, toggleColumn],
117
+ );
118
+
119
+ // Hover highlighting (via the columns layer's `hover` feature-state)
120
+ useMapStyleOperator(
121
+ (map) => {
122
+ const clearHover = () => {
123
+ if (hoveredRef.current != null) {
124
+ map.setFeatureState(
125
+ { source: "columns", id: hoveredRef.current },
126
+ { hover: false },
127
+ );
128
+ hoveredRef.current = null;
129
+ }
130
+ };
131
+ const onMove = (e) => {
132
+ const f = e.features?.[0];
133
+ if (f == null) return;
134
+ if (hoveredRef.current !== f.id) {
135
+ clearHover();
136
+ map.setFeatureState({ source: "columns", id: f.id }, { hover: true });
137
+ hoveredRef.current = f.id;
138
+ }
139
+ setHoveredColumn(f.properties?.col_id ?? null);
140
+ map.getCanvas().style.cursor = "pointer";
141
+ };
142
+ const onLeave = () => {
143
+ clearHover();
144
+ setHoveredColumn(null);
145
+ map.getCanvas().style.cursor = "";
146
+ };
147
+ map.on("mousemove", columnLayers, onMove);
148
+ map.on("mouseleave", columnLayers, onLeave);
149
+ return () => {
150
+ map.off("mousemove", columnLayers, onMove);
151
+ map.off("mouseleave", columnLayers, onLeave);
152
+ clearHover();
153
+ };
154
+ },
155
+ [setHoveredColumn],
156
+ );
157
+
158
+ return null;
159
+ }
160
+
161
+ function HoveredColumnHighlight() {
162
+ /** Reflects the store's `hoveredColumn` on the map (e.g. when a column is
163
+ * hovered elsewhere in the UI) using the `highlighted` feature-state. */
164
+ const hoveredColumn = useCorrelationMapStore((state) => state.hoveredColumn);
165
+ const columns = useCorrelationMapStore((state) => state.columns);
166
+ const appliedRef = useRef<number | string | null>(null);
167
+
168
+ useMapStyleOperator(
169
+ (map) => {
170
+ if (appliedRef.current != null) {
171
+ map.setFeatureState(
172
+ { source: "columns", id: appliedRef.current },
173
+ { highlighted: false },
174
+ );
175
+ appliedRef.current = null;
176
+ }
177
+ if (hoveredColumn == null || columns == null) return;
178
+ const col = columns.find((c) => c.properties?.col_id === hoveredColumn);
179
+ const id = (col as any)?.id ?? col?.properties?.col_id;
180
+ if (id == null) return;
181
+ map.setFeatureState({ source: "columns", id }, { highlighted: true });
182
+ appliedRef.current = id;
183
+ },
184
+ [hoveredColumn, columns],
185
+ );
186
+
187
+ return null;
188
+ }
189
+
58
190
  function SelectedColumnsLayer() {
59
191
  useOverlayStyle(() => selectedColumnsStyle, []);
60
192
 
@@ -148,11 +280,17 @@ const lineOfSectionStyle: Style = {
148
280
  function SectionLine({ padding }: { padding: number }) {
149
281
  useOverlayStyle(() => lineOfSectionStyle, []);
150
282
  const focusedLine = useCorrelationMapStore((state) => state.focusedLine);
283
+ const focusedColumns = useCorrelationMapStore(
284
+ (state) => state.focusedColumns,
285
+ );
151
286
 
152
- // Setup focused line
287
+ // Setup focused line (only drawn in line-of-section mode)
153
288
  useMapStyleOperator(
154
289
  (map) => {
155
290
  if (focusedLine == null) {
291
+ // Clear any previously-drawn line (e.g. after switching to manual mode)
292
+ setGeoJSON(map, "crossSectionLine", emptyFeatureCollection);
293
+ setGeoJSON(map, "crossSectionEndpoints", emptyFeatureCollection);
156
294
  return;
157
295
  }
158
296
  const data: FeatureCollection = {
@@ -180,17 +318,25 @@ function SectionLine({ padding }: { padding: number }) {
180
318
  );
181
319
 
182
320
  const bounds = useMemo(() => {
183
- if (focusedLine == null || focusedLine?.coordinates.length < 2) {
184
- return null;
185
- }
186
- let bounds = new LngLatBounds();
187
- for (let coord of focusedLine.coordinates) {
188
- bounds.extend(coord);
321
+ // In line mode, frame the line; in manual mode, frame the selected columns
322
+ const coords: number[][] =
323
+ focusedLine != null && focusedLine.coordinates.length >= 2
324
+ ? focusedLine.coordinates
325
+ : focusedColumns.map((c) => c.properties.centroid.geometry.coordinates);
326
+ if (coords.length < 2) return null;
327
+ const bounds = new LngLatBounds();
328
+ for (const coord of coords) {
329
+ bounds.extend(coord as [number, number]);
189
330
  }
190
331
  return bounds;
191
- }, [focusedLine]);
332
+ }, [focusedLine, focusedColumns]);
192
333
 
193
334
  useMapEaseTo({ bounds, padding, trackResize: true });
194
335
 
195
336
  return null;
196
337
  }
338
+
339
+ const emptyFeatureCollection: FeatureCollection = {
340
+ type: "FeatureCollection",
341
+ features: [],
342
+ };