@macrostrat/column-views 3.6.1 → 3.9.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 (138) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/dist/animated-age-window.cjs +51 -0
  3. package/dist/animated-age-window.cjs.map +1 -0
  4. package/dist/animated-age-window.d.ts +51 -0
  5. package/dist/animated-age-window.js +51 -0
  6. package/dist/animated-age-window.js.map +1 -0
  7. package/dist/column-views.css +118 -89
  8. package/dist/column.cjs +12 -3
  9. package/dist/column.cjs.map +1 -1
  10. package/dist/column.d.ts +11 -0
  11. package/dist/column.js +12 -3
  12. package/dist/column.js.map +1 -1
  13. package/dist/column.module.sass.cjs +15 -15
  14. package/dist/column.module.sass.js +15 -15
  15. package/dist/correlation-chart/main.cjs +156 -42
  16. package/dist/correlation-chart/main.cjs.map +1 -1
  17. package/dist/correlation-chart/main.d.ts +40 -2
  18. package/dist/correlation-chart/main.js +138 -23
  19. package/dist/correlation-chart/main.js.map +1 -1
  20. package/dist/correlation-chart/main.module.sass.cjs +23 -20
  21. package/dist/correlation-chart/main.module.sass.cjs.map +1 -1
  22. package/dist/correlation-chart/main.module.sass.js +23 -20
  23. package/dist/correlation-chart/main.module.sass.js.map +1 -1
  24. package/dist/correlation-chart/prepare-data.cjs +0 -1
  25. package/dist/correlation-chart/prepare-data.cjs.map +1 -1
  26. package/dist/correlation-chart/prepare-data.js +0 -1
  27. package/dist/correlation-chart/prepare-data.js.map +1 -1
  28. package/dist/data-provider/store.cjs +17 -4
  29. package/dist/data-provider/store.cjs.map +1 -1
  30. package/dist/data-provider/store.d.ts +6 -1
  31. package/dist/data-provider/store.js +17 -4
  32. package/dist/data-provider/store.js.map +1 -1
  33. package/dist/data-provider/unit-selection.cjs +4 -1
  34. package/dist/data-provider/unit-selection.cjs.map +1 -1
  35. package/dist/data-provider/unit-selection.d.ts +1 -1
  36. package/dist/data-provider/unit-selection.js +4 -1
  37. package/dist/data-provider/unit-selection.js.map +1 -1
  38. package/dist/index.cjs +16 -4
  39. package/dist/index.cjs.map +1 -1
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js +5 -2
  42. package/dist/index.js.map +1 -1
  43. package/dist/maps/column-correlation/core.cjs +127 -6
  44. package/dist/maps/column-correlation/core.cjs.map +1 -1
  45. package/dist/maps/column-correlation/core.js +128 -7
  46. package/dist/maps/column-correlation/core.js.map +1 -1
  47. package/dist/maps/column-correlation/state.cjs +63 -2
  48. package/dist/maps/column-correlation/state.cjs.map +1 -1
  49. package/dist/maps/column-correlation/state.d.ts +31 -1
  50. package/dist/maps/column-correlation/state.js +63 -2
  51. package/dist/maps/column-correlation/state.js.map +1 -1
  52. package/dist/maps/layers/columns.cjs +5 -1
  53. package/dist/maps/layers/columns.cjs.map +1 -1
  54. package/dist/maps/layers/columns.js +5 -1
  55. package/dist/maps/layers/columns.js.map +1 -1
  56. package/dist/prepare-units/composite-scale.cjs +94 -8
  57. package/dist/prepare-units/composite-scale.cjs.map +1 -1
  58. package/dist/prepare-units/composite-scale.d.ts +39 -0
  59. package/dist/prepare-units/composite-scale.js +96 -10
  60. package/dist/prepare-units/composite-scale.js.map +1 -1
  61. package/dist/prepare-units/helpers.cjs +0 -1
  62. package/dist/prepare-units/helpers.cjs.map +1 -1
  63. package/dist/prepare-units/helpers.js +0 -1
  64. package/dist/prepare-units/helpers.js.map +1 -1
  65. package/dist/prepare-units/index.cjs +33 -12
  66. package/dist/prepare-units/index.cjs.map +1 -1
  67. package/dist/prepare-units/index.js +34 -13
  68. package/dist/prepare-units/index.js.map +1 -1
  69. package/dist/prepare-units/types.cjs.map +1 -1
  70. package/dist/prepare-units/types.d.ts +13 -0
  71. package/dist/prepare-units/types.js.map +1 -1
  72. package/dist/section.cjs +10 -2
  73. package/dist/section.cjs.map +1 -1
  74. package/dist/section.d.ts +5 -0
  75. package/dist/section.js +10 -2
  76. package/dist/section.js.map +1 -1
  77. package/dist/unit-details/age-range.cjs +2 -1
  78. package/dist/unit-details/age-range.cjs.map +1 -1
  79. package/dist/unit-details/age-range.js +2 -1
  80. package/dist/unit-details/age-range.js.map +1 -1
  81. package/dist/unit-details/index.d.ts +1 -1
  82. package/dist/unit-details/panel.cjs +2 -3
  83. package/dist/unit-details/panel.cjs.map +1 -1
  84. package/dist/unit-details/panel.js +1 -2
  85. package/dist/unit-details/panel.js.map +1 -1
  86. package/dist/unit-details/panel.module.sass.cjs +18 -22
  87. package/dist/unit-details/panel.module.sass.cjs.map +1 -1
  88. package/dist/unit-details/panel.module.sass.js +18 -22
  89. package/dist/unit-details/panel.module.sass.js.map +1 -1
  90. package/dist/unit-details/strat-names.cjs +1 -2
  91. package/dist/unit-details/strat-names.cjs.map +1 -1
  92. package/dist/unit-details/strat-names.js +1 -2
  93. package/dist/unit-details/strat-names.js.map +1 -1
  94. package/dist/units/boxes.cjs +20 -36
  95. package/dist/units/boxes.cjs.map +1 -1
  96. package/dist/units/boxes.js +22 -38
  97. package/dist/units/boxes.js.map +1 -1
  98. package/dist/units/composite.cjs +12 -12
  99. package/dist/units/composite.cjs.map +1 -1
  100. package/dist/units/composite.js +12 -12
  101. package/dist/units/composite.js.map +1 -1
  102. package/package.json +9 -9
  103. package/src/animated-age-window.ts +120 -0
  104. package/src/column.module.sass +6 -0
  105. package/src/column.ts +24 -0
  106. package/src/correlation-chart/main.module.sass +46 -2
  107. package/src/correlation-chart/main.ts +210 -17
  108. package/src/correlation-chart/prepare-data.ts +0 -1
  109. package/src/data-provider/store.ts +21 -2
  110. package/src/data-provider/unit-selection.ts +13 -2
  111. package/src/index.ts +1 -0
  112. package/src/maps/column-correlation/column-correlation-map.stories.ts +12 -0
  113. package/src/maps/column-correlation/core.ts +155 -9
  114. package/src/maps/column-correlation/state.ts +110 -2
  115. package/src/maps/layers/columns.ts +7 -1
  116. package/src/prepare-units/composite-scale.ts +210 -10
  117. package/src/prepare-units/helpers.ts +0 -2
  118. package/src/prepare-units/index.ts +68 -14
  119. package/src/prepare-units/types.ts +13 -0
  120. package/src/section.ts +15 -1
  121. package/src/unit-details/age-range.ts +3 -1
  122. package/src/unit-details/index.ts +7 -1
  123. package/src/unit-details/panel.module.sass +0 -8
  124. package/src/unit-details/panel.ts +6 -6
  125. package/src/unit-details/strat-names.ts +1 -1
  126. package/src/units/boxes.ts +33 -53
  127. package/src/units/composite.ts +21 -12
  128. package/dist/correlation-chart/stories/utils.d.ts +0 -6
  129. package/dist/unit-details/identifiers.cjs +0 -56
  130. package/dist/unit-details/identifiers.cjs.map +0 -1
  131. package/dist/unit-details/identifiers.d.ts +0 -31
  132. package/dist/unit-details/identifiers.js +0 -54
  133. package/dist/unit-details/identifiers.js.map +0 -1
  134. package/src/correlation-chart/stories/correlation-chart.stories.ts +0 -228
  135. package/src/correlation-chart/stories/namibia-correlation.stories.ts +0 -151
  136. package/src/correlation-chart/stories/stories.module.sass +0 -18
  137. package/src/correlation-chart/stories/utils.ts +0 -24
  138. package/src/unit-details/identifiers.ts +0 -67
@@ -118,6 +118,11 @@ export interface MacrostratColumnDataContext<T extends BaseUnit> {
118
118
  totalHeight?: number;
119
119
  axisType?: ColumnAxisType;
120
120
  allowUnitSelection?: boolean;
121
+ /** True while the age window is animating; propagated to each section's
122
+ * `ColumnProvider` so per-frame label/pattern work can be skipped. */
123
+ isTransitioning?: boolean;
124
+ /** Hide unit labels while transitioning (perf escape hatch; default false). */
125
+ hideLabelsWhileTransitioning?: boolean;
121
126
  }
122
127
 
123
128
  export interface ColumnDataProviderProps<T extends BaseUnit>
@@ -138,6 +143,8 @@ export function MacrostratColumnDataProvider<T extends BaseUnit>({
138
143
  allowUnitSelection,
139
144
  onUnitSelected,
140
145
  selectedUnit,
146
+ isTransitioning,
147
+ hideLabelsWhileTransitioning,
141
148
  ref,
142
149
  }: ColumnDataProviderProps<T>) {
143
150
  /** Internal provider for Macrostrat column data.
@@ -158,8 +165,17 @@ export function MacrostratColumnDataProvider<T extends BaseUnit>({
158
165
  sections,
159
166
  totalHeight,
160
167
  axisType,
168
+ isTransitioning,
169
+ hideLabelsWhileTransitioning,
161
170
  };
162
- }, [units, sections, totalHeight, axisType]);
171
+ }, [
172
+ units,
173
+ sections,
174
+ totalHeight,
175
+ axisType,
176
+ isTransitioning,
177
+ hideLabelsWhileTransitioning,
178
+ ]);
163
179
 
164
180
  return h(
165
181
  MacrostratColumnStateProvider,
@@ -242,7 +258,8 @@ export function MacrostratColumnProvider(props) {
242
258
  * generic concepts to Macrostrat-specific ones.
243
259
  */
244
260
 
245
- const { axisType } = useMacrostratColumnData();
261
+ const { axisType, isTransitioning, hideLabelsWhileTransitioning } =
262
+ useMacrostratColumnData();
246
263
  const { units, domain, pixelScale, scale, children } = props;
247
264
  return h(
248
265
  ColumnProvider,
@@ -252,6 +269,8 @@ export function MacrostratColumnProvider(props) {
252
269
  range: domain,
253
270
  pixelsPerMeter: pixelScale,
254
271
  scale,
272
+ isTransitioning,
273
+ hideLabelsWhileTransitioning,
255
274
  },
256
275
  children,
257
276
  );
@@ -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({
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export * from "./units";
2
2
  export * from "./data-provider";
3
3
  export * from "./age-axis";
4
4
  export * from "./prepare-units";
5
+ export * from "./animated-age-window";
5
6
  export * from "./column";
6
7
  export * from "./unit-details";
7
8
  export * from "./maps";
@@ -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
+ };
@@ -19,13 +19,34 @@ import { useMacrostratColumns } from "@macrostrat/data-provider";
19
19
  import { buffer } from "@turf/buffer";
20
20
  import { booleanPointInPolygon } from "@turf/boolean-point-in-polygon";
21
21
 
22
+ export type CorrelationSelectionMode = "line" | "manual";
23
+
22
24
  export interface CorrelationMapInput {
23
25
  columns: ColumnGeoJSONRecord[];
24
26
  focusedLine: LineString | null;
27
+ /** When set, columns are selected manually (by clicking) in this order,
28
+ * rather than derived from a line of section. */
29
+ manualColumns?: number[] | null;
25
30
  }
26
31
 
27
32
  export interface CorrelationMapStore extends CorrelationMapInput {
28
33
  onClickMap: (event: mapboxgl.MapMouseEvent, point: Point) => void;
34
+ /** Toggle a column in/out of the manual selection (used in "manual" mode) */
35
+ toggleColumn: (colID: number) => void;
36
+ /** Remove a column from the selection. In "line" mode this converts the
37
+ * current selection to a manual one (a line can't represent an arbitrary
38
+ * subset), so removing a column switches modes. */
39
+ removeColumn: (colID: number) => void;
40
+ /** Replace the manual column selection with an explicit ordered list (used
41
+ * e.g. for drag-and-drop reordering). Switches to manual mode. */
42
+ setManualColumns: (colIDs: number[]) => void;
43
+ setHoveredColumn: (colID: number | null) => void;
44
+ hoveredColumn: number | null;
45
+ /** Request the map to frame a particular column (e.g. on header click). */
46
+ zoomToColumn: (colID: number | null) => void;
47
+ zoomColumn: number | null;
48
+ /** Incremented on each zoom request so repeat clicks re-frame the column. */
49
+ zoomNonce: number;
29
50
  projectID?: number;
30
51
  }
31
52
 
@@ -45,13 +66,19 @@ const CorrelationStoreContext =
45
66
 
46
67
  type ComputedStore = {
47
68
  focusedColumns: FocusedColumnGeoJSONRecord[];
69
+ selectionMode: CorrelationSelectionMode;
48
70
  };
49
71
 
50
72
  /** A computed store that will automatically update when the state changes */
51
73
  const computed = createComputed((state: CorrelationMapStore): ComputedStore => {
74
+ const manual = state.manualColumns != null;
52
75
  return {
53
- // We compute the focused columns based on columns and focusedLine
54
- focusedColumns: buildCorrelationColumns(state.columns, state.focusedLine),
76
+ selectionMode: manual ? "manual" : "line",
77
+ // Focused columns are derived either from an explicit manual selection or
78
+ // from the columns intersecting the line of section.
79
+ focusedColumns: manual
80
+ ? buildManualColumns(state.columns, state.manualColumns)
81
+ : buildCorrelationColumns(state.columns, state.focusedLine),
55
82
  };
56
83
  }) as any;
57
84
 
@@ -61,6 +88,7 @@ export function ColumnCorrelationProvider({
61
88
  projectID,
62
89
  inProcess,
63
90
  focusedLine,
91
+ manualColumns = null,
64
92
  onSelectColumns,
65
93
  }: CorrelationProviderProps) {
66
94
  const [store] = useState(() => {
@@ -68,10 +96,16 @@ export function ColumnCorrelationProvider({
68
96
  computed((set, get): CorrelationMapStore => {
69
97
  return {
70
98
  focusedLine,
99
+ manualColumns,
100
+ hoveredColumn: null,
101
+ zoomColumn: null,
102
+ zoomNonce: 0,
71
103
  projectID,
72
104
  columns: null,
73
105
  onClickMap(event: mapboxgl.MapMouseEvent, point: Point) {
74
106
  const state = get();
107
+ // In manual-selection mode the map click is handled per-column
108
+ if (state.manualColumns != null) return;
75
109
  // Check if shift key is pressed
76
110
  const shiftKeyPressed = event.originalEvent.shiftKey;
77
111
  let existingCoords = state.focusedLine?.coordinates ?? [];
@@ -87,6 +121,36 @@ export function ColumnCorrelationProvider({
87
121
  },
88
122
  });
89
123
  },
124
+ toggleColumn(colID: number) {
125
+ const state = get();
126
+ const current =
127
+ state.manualColumns ??
128
+ state.focusedColumns.map((d) => d.properties.col_id);
129
+ const next = current.includes(colID)
130
+ ? current.filter((d) => d !== colID)
131
+ : [...current, colID];
132
+ set({ manualColumns: next, focusedLine: null });
133
+ },
134
+ removeColumn(colID: number) {
135
+ const state = get();
136
+ // Seed from the current selection (line-derived or manual)
137
+ const current =
138
+ state.manualColumns ??
139
+ state.focusedColumns.map((d) => d.properties.col_id);
140
+ set({
141
+ manualColumns: current.filter((d) => d !== colID),
142
+ focusedLine: null,
143
+ });
144
+ },
145
+ setManualColumns(colIDs: number[]) {
146
+ set({ manualColumns: colIDs, focusedLine: null });
147
+ },
148
+ setHoveredColumn(colID: number | null) {
149
+ set({ hoveredColumn: colID });
150
+ },
151
+ zoomToColumn(colID: number | null) {
152
+ set({ zoomColumn: colID, zoomNonce: get().zoomNonce + 1 });
153
+ },
90
154
  };
91
155
  }),
92
156
  );
@@ -122,6 +186,24 @@ export function useCorrelationMapStore(
122
186
  return useStore(storeApi, selector);
123
187
  }
124
188
 
189
+ export interface ColumnMapLink {
190
+ onColumnMouseOver: (colID: number | null) => void;
191
+ onColumnClick: (colID: number) => void;
192
+ }
193
+
194
+ /** Wire a correlation chart's column-level events to the correlation map:
195
+ * hovering a column highlights it on the map, and clicking a column header
196
+ * frames it. Spread the result into `CorrelationChart`. Must be used within a
197
+ * `ColumnCorrelationProvider`. */
198
+ export function useColumnMapLink(): ColumnMapLink {
199
+ const setHoveredColumn = useCorrelationMapStore((s) => s.setHoveredColumn);
200
+ const zoomToColumn = useCorrelationMapStore((s) => s.zoomToColumn);
201
+ return {
202
+ onColumnMouseOver: setHoveredColumn,
203
+ onColumnClick: zoomToColumn,
204
+ };
205
+ }
206
+
125
207
  function buildCorrelationColumns(
126
208
  columns: ColumnGeoJSONRecord[],
127
209
  line: LineString,
@@ -135,6 +217,32 @@ function buildCorrelationColumns(
135
217
  );
136
218
  }
137
219
 
220
+ function buildManualColumns(
221
+ columns: ColumnGeoJSONRecord[],
222
+ ids: number[],
223
+ ): FocusedColumnGeoJSONRecord[] {
224
+ /** Build focused columns from an explicit, ordered list of column IDs. The
225
+ * ordering line simply connects them in the order they were selected. */
226
+ if (columns == null || ids == null) return [];
227
+ const byID = new Map(columns.map((c) => [c.properties.col_id, c]));
228
+ const result: FocusedColumnGeoJSONRecord[] = [];
229
+ ids.forEach((id, i) => {
230
+ const col = byID.get(id);
231
+ if (col == null) return;
232
+ const c = centroid(col.geometry);
233
+ result.push({
234
+ ...col,
235
+ properties: {
236
+ ...col.properties,
237
+ centroid: c,
238
+ nearestPointOnLine: c,
239
+ distanceAlongLine: i,
240
+ },
241
+ });
242
+ });
243
+ return result;
244
+ }
245
+
138
246
  function computeIntersectingColumns(
139
247
  columns: ColumnGeoJSONRecord[],
140
248
  line: LineString,
@@ -75,7 +75,13 @@ export function buildColumnsStyle(
75
75
  opts.selectedColor ?? getCSSVariable("--selection-color", "purple");
76
76
 
77
77
  const lineColor = opts.lineColor ?? columnBaseColor;
78
- const isHovered = ["boolean", ["feature-state", "hover"], false];
78
+ // Highlight on either direct map hover or an externally-driven highlight
79
+ // (e.g. hovering the corresponding column header elsewhere in the UI).
80
+ const isHovered = [
81
+ "any",
82
+ ["boolean", ["feature-state", "hover"], false],
83
+ ["boolean", ["feature-state", "highlighted"], false],
84
+ ];
79
85
  // If color is in the feature state or geojson properties, use that as second choice
80
86
  if (typeof columnColor === "string") {
81
87
  columnColor = [