@treasuryspatial/map-react 0.1.20 → 0.1.22

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.
@@ -1,13 +1,29 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
4
- import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
5
4
  import * as THREE from 'three';
6
- import { buildGeoJsonPolygon, computeCentroid, computeFootprintStats, createMapThreeLayer, ensureFootprintLayers, getOuterRing, toLocalMeters, toPolygonFeature, } from '@treasuryspatial/map-kit';
5
+ import { buildGeoJsonPolygon, buildGeoJsonLineString, computeCentroid, computeFootprintStats, computePolylineStats, createMapThreeLayer, ensureSelectionLayers, getOuterRing, offsetLngLatFromLocalMeters, toLocalMeters, toLineFeature, toPolygonFeature, } from '@treasuryspatial/map-kit';
7
6
  const DEFAULT_CENTER = [-87.6298, 41.8781];
8
7
  const DEFAULT_ZOOM = 16.5;
9
8
  const DEFAULT_PITCH = 55;
10
- export const MapModeViewport = forwardRef(function MapModeViewport({ active, geometry3dm, meshGroup, materialSettings, lightingPreset = 'studio', confirmedSelection, confirmedFeature, candidate, mapVisibility, onCandidateSelect, onCandidateClear, accessToken: accessTokenProp, mapStyle: mapStyleProp, applyLightingPreset, resolveMeshGroup, }, ref) {
9
+ const DEFAULT_VIEW_DURATION_MS = 900;
10
+ const DEFAULT_PERSPECTIVE_PADDING = { top: 120, right: 120, bottom: 150, left: 120 };
11
+ const DEFAULT_INTERIOR_PADDING = { top: 110, right: 96, bottom: 210, left: 96 };
12
+ const MIN_VIEW_SPAN_METERS = 8;
13
+ const MAX_MAP_ZOOM = 21;
14
+ const SUPPORTED_MAP_SOURCE_KINDS = new Set(['building', 'waterway', 'road', 'rail']);
15
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16
+ const toneMappingLookup = {
17
+ neutral: THREE.NeutralToneMapping,
18
+ aces: THREE.ACESFilmicToneMapping,
19
+ linear: THREE.LinearToneMapping,
20
+ };
21
+ const shadowMapTypeLookup = {
22
+ pcfsoft: THREE.PCFSoftShadowMap,
23
+ pcf: THREE.PCFShadowMap,
24
+ basic: THREE.BasicShadowMap,
25
+ };
26
+ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geometry3dm, meshGroup, materialSettings, lightingPreset = 'studio', extrusionsVisible = false, extrusionColor = '#262b31', selectionConfig, confirmedSelection, confirmedFeature, candidate, mapVisibility, onCandidateSelect, onCandidateClear, accessToken: accessTokenProp, mapStyle: mapStyleProp, applyLightingPreset, resolveMeshGroup, }, ref) {
11
27
  const containerRef = useRef(null);
12
28
  const mapRef = useRef(null);
13
29
  const mapboxRef = useRef(null);
@@ -19,14 +35,29 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
19
35
  const lightingPresetRef = useRef(lightingPreset);
20
36
  const handlersBoundRef = useRef(false);
21
37
  const confirmedSelectionRef = useRef(confirmedSelection ?? null);
38
+ const candidateSelectionRef = useRef(candidate?.selection ?? null);
22
39
  const onCandidateSelectRef = useRef(onCandidateSelect);
23
40
  const onCandidateClearRef = useRef(onCandidateClear);
41
+ const selectionConfigRef = useRef(selectionConfig ?? null);
24
42
  const lastGeocodeLabelRef = useRef(null);
25
43
  const reverseGeocodeRequestRef = useRef(0);
26
44
  const resizeObserverRef = useRef(null);
27
45
  const lastOriginRef = useRef(confirmedSelection?.origin ?? null);
46
+ const candidateFeatureRef = useRef(candidate?.feature ?? null);
47
+ const confirmedFeatureRef = useRef(confirmedFeature ?? null);
48
+ const activeMeshGroupRef = useRef(meshGroup ?? null);
49
+ const extrusionsVisibleRef = useRef(extrusionsVisible);
50
+ const extrusionColorRef = useRef(extrusionColor);
51
+ const mapVisibilityRef = useRef(mapVisibility);
28
52
  const [mapReady, setMapReady] = useState(false);
29
53
  const [mapError, setMapError] = useState('');
54
+ const selectionConfigError = useMemo(() => {
55
+ const sourceKind = selectionConfig?.sourceKind;
56
+ if (!sourceKind || SUPPORTED_MAP_SOURCE_KINDS.has(sourceKind))
57
+ return '';
58
+ return `Unsupported map feature sourceKind '${sourceKind}'. Manifest must use building, waterway, road, or rail.`;
59
+ }, [selectionConfig?.sourceKind]);
60
+ const activeMapError = mapError || selectionConfigError;
30
61
  const [threeLayerReady, setThreeLayerReady] = useState(false);
31
62
  const debug = useMemo(() => {
32
63
  if (typeof window === 'undefined')
@@ -44,27 +75,76 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
44
75
  return;
45
76
  console.info('[map-react]', ...args);
46
77
  }, [debug]);
78
+ const fmt = useCallback((value) => (Number.isFinite(value) ? value.toFixed(3) : 'nan'), []);
47
79
  useEffect(() => {
48
80
  lightingPresetRef.current = lightingPreset;
49
81
  }, [lightingPreset]);
82
+ useEffect(() => {
83
+ extrusionsVisibleRef.current = extrusionsVisible;
84
+ }, [extrusionsVisible]);
85
+ useEffect(() => {
86
+ extrusionColorRef.current = extrusionColor;
87
+ }, [extrusionColor]);
88
+ useEffect(() => {
89
+ mapVisibilityRef.current = mapVisibility;
90
+ }, [mapVisibility]);
91
+ useEffect(() => {
92
+ selectionConfigRef.current = selectionConfig ?? null;
93
+ layerControllerRef.current?.setConfig(selectionConfig ?? null);
94
+ }, [selectionConfig]);
95
+ const applySceneLighting = useCallback((scene, renderer, presetKey) => {
96
+ const maybePromise = (applyLightingPreset ?? defaultApplyLightingPreset)(scene, renderer, presetKey);
97
+ if (maybePromise && typeof maybePromise.then === 'function') {
98
+ void Promise.resolve(maybePromise).finally(() => {
99
+ applyLightingReadabilityBoost(renderer, presetKey);
100
+ mapRef.current?.triggerRepaint();
101
+ });
102
+ return;
103
+ }
104
+ applyLightingReadabilityBoost(renderer, presetKey);
105
+ mapRef.current?.triggerRepaint();
106
+ }, [applyLightingPreset]);
50
107
  useEffect(() => {
51
108
  confirmedSelectionRef.current = confirmedSelection ?? null;
52
109
  if (confirmedSelection?.origin) {
53
110
  lastOriginRef.current = confirmedSelection.origin;
54
111
  }
55
112
  }, [confirmedSelection]);
113
+ useEffect(() => {
114
+ candidateSelectionRef.current = candidate?.selection ?? null;
115
+ if (candidate?.selection?.origin) {
116
+ lastOriginRef.current = candidate.selection.origin;
117
+ }
118
+ }, [candidate]);
119
+ useEffect(() => {
120
+ candidateFeatureRef.current = candidate?.feature ?? null;
121
+ }, [candidate]);
122
+ useEffect(() => {
123
+ confirmedFeatureRef.current = confirmedFeature ?? null;
124
+ }, [confirmedFeature]);
56
125
  useEffect(() => {
57
126
  onCandidateSelectRef.current = onCandidateSelect;
58
127
  }, [onCandidateSelect]);
59
128
  useEffect(() => {
60
129
  onCandidateClearRef.current = onCandidateClear;
61
130
  }, [onCandidateClear]);
131
+ const applyCurrentSelectionToController = useCallback(() => {
132
+ const controller = layerControllerRef.current;
133
+ if (!controller)
134
+ return;
135
+ const selectedFeature = candidateFeatureRef.current ?? confirmedFeatureRef.current ?? null;
136
+ const selectedSourceKind = candidateSelectionRef.current?.sourceKind ?? confirmedSelectionRef.current?.sourceKind ?? null;
137
+ controller.setSelected(selectedFeature, selectedSourceKind);
138
+ }, []);
62
139
  const accessToken = useMemo(() => accessTokenProp ?? process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN ?? '', [accessTokenProp]);
63
140
  const mapStyle = useMemo(() => {
64
141
  if (mapStyleProp)
65
142
  return mapStyleProp;
66
143
  const styleDark = process.env.NEXT_PUBLIC_MAPBOX_FOOTPRINT_STYLE_DARK;
67
144
  const styleLight = process.env.NEXT_PUBLIC_MAPBOX_FOOTPRINT_STYLE_LIGHT;
145
+ const publicFallbackStyle = themeMode === 'light'
146
+ ? 'mapbox://styles/mapbox/light-v11'
147
+ : 'mapbox://styles/mapbox/dark-v11';
68
148
  if (typeof window !== 'undefined' && (styleDark || styleLight)) {
69
149
  const prefersDark = themeMode === 'dark' ||
70
150
  (themeMode !== 'light' && (window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ?? false));
@@ -75,7 +155,7 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
75
155
  return (process.env.NEXT_PUBLIC_MAPBOX_FOOTPRINT_STYLE ??
76
156
  styleDark ??
77
157
  styleLight ??
78
- 'mapbox://styles/treasuryadmin/cmc2gd70p00ui01spfnel4el2');
158
+ publicFallbackStyle);
79
159
  }, [mapStyleProp, themeMode]);
80
160
  useEffect(() => {
81
161
  if (typeof document === 'undefined')
@@ -87,14 +167,139 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
87
167
  observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
88
168
  return () => observer.disconnect();
89
169
  }, []);
170
+ const fetchSearchSuggestions = useCallback(async (query, limit = 12) => {
171
+ const trimmed = query.trim();
172
+ if (!trimmed || !accessToken)
173
+ return [];
174
+ const map = mapRef.current;
175
+ const center = map?.getCenter().wrap();
176
+ const proximity = center && Number.isFinite(center.lng) && Number.isFinite(center.lat)
177
+ ? `${center.lng},${center.lat}`
178
+ : `${DEFAULT_CENTER[0]},${DEFAULT_CENTER[1]}`;
179
+ const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(trimmed)}.json` +
180
+ `?access_token=${encodeURIComponent(accessToken)}` +
181
+ `&limit=${Math.max(1, Math.min(12, limit))}` +
182
+ `&autocomplete=true&types=address,place,locality,neighborhood,poi` +
183
+ `&proximity=${encodeURIComponent(proximity)}`;
184
+ try {
185
+ const response = await fetch(url);
186
+ if (!response.ok)
187
+ return [];
188
+ const data = await response.json();
189
+ const features = Array.isArray(data?.features) ? data.features : [];
190
+ return features
191
+ .map((feature) => {
192
+ const featureCenter = feature?.center;
193
+ if (!Array.isArray(featureCenter) || featureCenter.length < 2)
194
+ return null;
195
+ const label = String(feature?.text ?? feature?.place_name ?? '').trim();
196
+ const placeName = String(feature?.place_name ?? '').trim();
197
+ const secondaryLabel = placeName && placeName !== label
198
+ ? placeName.replace(new RegExp(`^${escapeRegExp(label)}(?:,\\s*)?`, 'i'), '').trim() || undefined
199
+ : undefined;
200
+ const bboxValue = Array.isArray(feature?.bbox) && feature.bbox.length === 4
201
+ ? feature.bbox.map(Number)
202
+ : undefined;
203
+ return {
204
+ id: String(feature?.id ?? placeName ?? `${featureCenter[0]},${featureCenter[1]}`),
205
+ label: label || placeName || trimmed,
206
+ secondaryLabel,
207
+ center: [Number(featureCenter[0]), Number(featureCenter[1])],
208
+ bbox: bboxValue,
209
+ };
210
+ })
211
+ .filter((value) => Boolean(value));
212
+ }
213
+ catch {
214
+ return [];
215
+ }
216
+ }, [accessToken]);
217
+ const applySearchSuggestion = useCallback((suggestion) => {
218
+ const map = mapRef.current;
219
+ if (!map)
220
+ return false;
221
+ lastGeocodeLabelRef.current = suggestion.secondaryLabel
222
+ ? `${suggestion.label}, ${suggestion.secondaryLabel}`
223
+ : suggestion.label;
224
+ map.stop();
225
+ map.flyTo({
226
+ center: suggestion.center,
227
+ zoom: Math.max(map.getZoom(), suggestion.bbox ? 16.8 : 17.4),
228
+ speed: 1.8,
229
+ curve: 1.12,
230
+ essential: true,
231
+ });
232
+ return true;
233
+ }, []);
234
+ const searchAddress = useCallback(async (query) => {
235
+ const [firstSuggestion] = await fetchSearchSuggestions(query, 1);
236
+ if (!firstSuggestion)
237
+ return false;
238
+ return applySearchSuggestion(firstSuggestion);
239
+ }, [applySearchSuggestion, fetchSearchSuggestions]);
90
240
  useImperativeHandle(ref, () => ({
91
241
  setLightingPreset: (presetKey) => {
92
242
  lightingPresetRef.current = presetKey;
93
243
  if (sceneRef.current && rendererRef.current) {
94
- (applyLightingPreset ?? defaultApplyLightingPreset)(sceneRef.current, rendererRef.current, presetKey);
244
+ applySceneLighting(sceneRef.current, rendererRef.current, presetKey);
245
+ }
246
+ },
247
+ animateToView: (viewKey) => {
248
+ const map = mapRef.current;
249
+ const mapboxgl = mapboxRef.current;
250
+ if (!map || !mapboxgl)
251
+ return;
252
+ const origin = confirmedSelectionRef.current?.origin ??
253
+ candidateSelectionRef.current?.origin ??
254
+ lastOriginRef.current ??
255
+ null;
256
+ if (!origin)
257
+ return;
258
+ const frame = resolveGroundFrameMeters(activeMeshGroupRef.current) ?? resolveSelectionFrameMeters(confirmedSelectionRef.current ?? candidateSelectionRef.current ?? null);
259
+ if (!frame)
260
+ return;
261
+ const center = offsetLngLatFromLocalMeters(origin, {
262
+ eastMeters: frame.centerEastMeters,
263
+ northMeters: frame.centerNorthMeters,
264
+ }, mapboxgl);
265
+ const halfEast = Math.max(MIN_VIEW_SPAN_METERS, frame.spanEastMeters) / 2;
266
+ const halfNorth = Math.max(MIN_VIEW_SPAN_METERS, frame.spanNorthMeters) / 2;
267
+ const corners = [
268
+ offsetLngLatFromLocalMeters(origin, { eastMeters: frame.centerEastMeters - halfEast, northMeters: frame.centerNorthMeters - halfNorth }, mapboxgl),
269
+ offsetLngLatFromLocalMeters(origin, { eastMeters: frame.centerEastMeters - halfEast, northMeters: frame.centerNorthMeters + halfNorth }, mapboxgl),
270
+ offsetLngLatFromLocalMeters(origin, { eastMeters: frame.centerEastMeters + halfEast, northMeters: frame.centerNorthMeters - halfNorth }, mapboxgl),
271
+ offsetLngLatFromLocalMeters(origin, { eastMeters: frame.centerEastMeters + halfEast, northMeters: frame.centerNorthMeters + halfNorth }, mapboxgl),
272
+ ];
273
+ const bounds = corners.reduce((acc, point) => acc.extend([point.lng, point.lat]), new mapboxgl.LngLatBounds([corners[0].lng, corners[0].lat], [corners[0].lng, corners[0].lat]));
274
+ const isInteriorView = viewKey === 'interior';
275
+ const pitch = isInteriorView ? 72 : DEFAULT_PITCH;
276
+ const padding = isInteriorView ? DEFAULT_INTERIOR_PADDING : DEFAULT_PERSPECTIVE_PADDING;
277
+ const bearing = map.getBearing();
278
+ const camera = map.cameraForBounds(bounds, { padding, pitch, bearing });
279
+ const zoomDelta = isInteriorView ? 0.85 : 0;
280
+ if (camera) {
281
+ map.easeTo({
282
+ center: camera.center ?? center,
283
+ zoom: Math.min(MAX_MAP_ZOOM, (camera.zoom ?? map.getZoom()) + zoomDelta),
284
+ bearing,
285
+ pitch,
286
+ duration: DEFAULT_VIEW_DURATION_MS,
287
+ essential: true,
288
+ });
289
+ return;
95
290
  }
291
+ map.easeTo({
292
+ center: [center.lng, center.lat],
293
+ zoom: Math.min(MAX_MAP_ZOOM, map.getZoom() + (isInteriorView ? 0.9 : 0.2)),
294
+ bearing,
295
+ pitch,
296
+ duration: DEFAULT_VIEW_DURATION_MS,
297
+ essential: true,
298
+ });
96
299
  },
97
- animateToView: () => { },
300
+ searchSuggestions: (query) => fetchSearchSuggestions(query),
301
+ applySearchSuggestion,
302
+ searchAddress,
98
303
  capturePngDataUrl: () => {
99
304
  const map = mapRef.current;
100
305
  if (!map)
@@ -106,7 +311,10 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
106
311
  return '';
107
312
  }
108
313
  },
109
- }), [applyLightingPreset]);
314
+ requestRepaint: () => {
315
+ mapRef.current?.triggerRepaint();
316
+ },
317
+ }), [applySceneLighting, applySearchSuggestion, fetchSearchSuggestions, searchAddress]);
110
318
  useEffect(() => {
111
319
  if (!active)
112
320
  return;
@@ -151,15 +359,6 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
151
359
  pitch: startPitch,
152
360
  });
153
361
  map.addControl(new mapboxgl.NavigationControl({ visualizePitch: true }), 'bottom-right');
154
- const geocoder = new MapboxGeocoder({
155
- accessToken,
156
- mapboxgl: mapboxgl,
157
- marker: false,
158
- collapsed: false,
159
- placeholder: 'search address',
160
- proximity: { longitude: DEFAULT_CENTER[0], latitude: DEFAULT_CENTER[1] },
161
- });
162
- map.addControl(geocoder, 'top-left');
163
362
  mapRef.current = map;
164
363
  const onMapError = (event) => {
165
364
  const err = event?.error ?? event;
@@ -191,13 +390,12 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
191
390
  if (!mapRef.current)
192
391
  return;
193
392
  mapRef.current.resize();
393
+ mapRef.current.triggerRepaint();
194
394
  if (debug) {
195
395
  const mapCanvas = mapRef.current.getCanvas();
196
- log('map resize', {
197
- canvasSize: { width: mapCanvas.width, height: mapCanvas.height },
198
- canvasRect: mapCanvas.getBoundingClientRect(),
199
- containerRect: container.getBoundingClientRect(),
200
- });
396
+ const canvasRect = mapCanvas.getBoundingClientRect();
397
+ const containerRect = container.getBoundingClientRect();
398
+ log(`map resize canvas=${mapCanvas.width}x${mapCanvas.height} canvasRect=${fmt(canvasRect.width)}x${fmt(canvasRect.height)} containerRect=${fmt(containerRect.width)}x${fmt(containerRect.height)}`);
201
399
  }
202
400
  });
203
401
  observer.observe(container);
@@ -207,40 +405,38 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
207
405
  if (!map.isStyleLoaded())
208
406
  return;
209
407
  opacityCacheRef.current.clear();
210
- layerControllerRef.current = ensureFootprintLayers(map);
211
- disableFillExtrusions(map);
408
+ layerControllerRef.current = ensureSelectionLayers(map);
409
+ layerControllerRef.current.setConfig(selectionConfigRef.current);
410
+ layerControllerRef.current.setBuildingsVisible(extrusionsVisibleRef.current);
411
+ layerControllerRef.current.setBuildingsColor(extrusionColorRef.current);
412
+ applyCurrentSelectionToController();
212
413
  ensureCustomLayer(map);
213
- applyMapVisibility(map, mapVisibility, opacityCacheRef);
214
- bindFootprintHandlers(map);
414
+ applyMapVisibility(map, mapVisibilityRef.current, opacityCacheRef);
415
+ bindSelectionHandlers(map);
215
416
  });
216
417
  map.on('load', () => {
217
418
  if (!map.isStyleLoaded())
218
419
  return;
219
- layerControllerRef.current = ensureFootprintLayers(map);
220
- disableFillExtrusions(map);
420
+ layerControllerRef.current = ensureSelectionLayers(map);
421
+ layerControllerRef.current.setConfig(selectionConfigRef.current);
422
+ layerControllerRef.current.setBuildingsVisible(extrusionsVisibleRef.current);
423
+ layerControllerRef.current.setBuildingsColor(extrusionColorRef.current);
424
+ applyCurrentSelectionToController();
221
425
  ensureCustomLayer(map);
222
- applyMapVisibility(map, mapVisibility, opacityCacheRef);
426
+ applyMapVisibility(map, mapVisibilityRef.current, opacityCacheRef);
223
427
  setMapReady(true);
224
- bindFootprintHandlers(map);
428
+ bindSelectionHandlers(map);
225
429
  requestAnimationFrame(() => {
226
430
  map.resize();
431
+ map.triggerRepaint();
227
432
  if (debug) {
228
433
  const mapCanvas = map.getCanvas();
229
- log('map load', {
230
- canvasSize: { width: mapCanvas.width, height: mapCanvas.height },
231
- canvasRect: mapCanvas.getBoundingClientRect(),
232
- containerRect: map.getContainer().getBoundingClientRect(),
233
- });
434
+ const canvasRect = mapCanvas.getBoundingClientRect();
435
+ const containerRect = map.getContainer().getBoundingClientRect();
436
+ log(`map load canvas=${mapCanvas.width}x${mapCanvas.height} canvasRect=${fmt(canvasRect.width)}x${fmt(canvasRect.height)} containerRect=${fmt(containerRect.width)}x${fmt(containerRect.height)}`);
234
437
  }
235
438
  });
236
439
  });
237
- geocoder.on('result', (event) => {
238
- const center = event?.result?.center;
239
- if (!Array.isArray(center) || center.length < 2)
240
- return;
241
- lastGeocodeLabelRef.current = event?.result?.place_name ?? event?.result?.text ?? null;
242
- map.easeTo({ center: [Number(center[0]), Number(center[1])], zoom: Math.max(map.getZoom(), 17) });
243
- });
244
440
  }
245
441
  catch (err) {
246
442
  const message = err instanceof Error ? err.message : 'Failed to initialize Mapbox';
@@ -266,30 +462,40 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
266
462
  cleanupCanvasEvents?.();
267
463
  cleanupMapEvents?.();
268
464
  };
269
- }, [active, accessToken, mapStyle, mapVisibility]);
465
+ }, [active, accessToken, applyCurrentSelectionToController, mapStyle]);
270
466
  useEffect(() => {
271
467
  const map = mapRef.current;
272
468
  if (!map || !mapReady)
273
469
  return;
274
470
  applyMapVisibility(map, mapVisibility, opacityCacheRef);
275
471
  }, [mapVisibility, mapReady]);
472
+ useEffect(() => {
473
+ if (!mapReady)
474
+ return;
475
+ layerControllerRef.current?.setBuildingsVisible(extrusionsVisible);
476
+ }, [extrusionsVisible, mapReady]);
477
+ useEffect(() => {
478
+ if (!mapReady)
479
+ return;
480
+ layerControllerRef.current?.setBuildingsColor(extrusionColor);
481
+ }, [extrusionColor, mapReady]);
276
482
  useEffect(() => {
277
483
  const controller = layerControllerRef.current;
278
484
  if (!controller)
279
485
  return;
280
486
  if (candidate?.feature) {
281
- controller.setSelected(candidate.feature);
487
+ controller.setSelected(candidate.feature, candidate.selection?.sourceKind ?? null);
282
488
  if (candidate.selection?.origin) {
283
489
  lastOriginRef.current = candidate.selection.origin;
284
490
  }
285
491
  }
286
492
  else if (confirmedFeature) {
287
- controller.setSelected(confirmedFeature);
493
+ controller.setSelected(confirmedFeature, confirmedSelection?.sourceKind ?? null);
288
494
  }
289
495
  else {
290
- controller.setSelected(null);
496
+ controller.setSelected(null, null);
291
497
  }
292
- }, [candidate, confirmedFeature]);
498
+ }, [candidate, confirmedFeature, confirmedSelection?.sourceKind, mapReady]);
293
499
  useEffect(() => {
294
500
  if (!mapReady)
295
501
  return;
@@ -298,12 +504,7 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
298
504
  return;
299
505
  const selectionOrigin = confirmedSelection?.origin ?? candidate?.selection?.origin ?? null;
300
506
  if (debug) {
301
- log('origin sync', {
302
- hasLayer: Boolean(threeLayerRef.current),
303
- selectionOrigin,
304
- fallbackOrigin: lastOriginRef.current,
305
- hasGeometry: Boolean(geometry3dm || meshGroup),
306
- });
507
+ log(`origin sync hasLayer=${threeLayerRef.current ? '1' : '0'} selectionOrigin=${selectionOrigin ? `${fmt(selectionOrigin.lng)},${fmt(selectionOrigin.lat)}` : 'null'} fallbackOrigin=${lastOriginRef.current ? `${fmt(lastOriginRef.current.lng)},${fmt(lastOriginRef.current.lat)}` : 'null'} hasGeometry=${geometry3dm || meshGroup ? '1' : '0'}`);
307
508
  }
308
509
  if (selectionOrigin) {
309
510
  lastOriginRef.current = selectionOrigin;
@@ -339,6 +540,7 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
339
540
  else if (geometry3dm && resolveMeshGroup) {
340
541
  group = await resolveMeshGroup(geometry3dm);
341
542
  }
543
+ activeMeshGroupRef.current = group;
342
544
  if (!group || cancelled)
343
545
  return;
344
546
  applyMaterialOverrides(group, materialSettings);
@@ -347,6 +549,7 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
347
549
  void applyGroup();
348
550
  return () => {
349
551
  cancelled = true;
552
+ activeMeshGroupRef.current = null;
350
553
  };
351
554
  }, [mapReady, geometry3dm, meshGroup, materialSettings, resolveMeshGroup]);
352
555
  const ensureCustomLayer = (map) => {
@@ -358,9 +561,9 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
358
561
  onInit: (scene, renderer) => {
359
562
  sceneRef.current = scene;
360
563
  rendererRef.current = renderer;
361
- renderer.shadowMap.enabled = true;
564
+ renderer.shadowMap.enabled = false;
362
565
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
363
- (applyLightingPreset ?? defaultApplyLightingPreset)(scene, renderer, lightingPresetRef.current);
566
+ applySceneLighting(scene, renderer, lightingPresetRef.current);
364
567
  },
365
568
  });
366
569
  setThreeLayerReady(true);
@@ -396,34 +599,83 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
396
599
  }
397
600
  }
398
601
  };
399
- const bindFootprintHandlers = (map) => {
602
+ const bindSelectionHandlers = (map) => {
400
603
  if (handlersBoundRef.current)
401
604
  return;
402
605
  handlersBoundRef.current = true;
403
- map.on('mousemove', 'building-footprints-fill', (e) => {
404
- const feature = e.features?.[0];
405
- const poly = feature ? toPolygonFeature(feature) : null;
406
- layerControllerRef.current?.setHover(poly);
407
- });
408
- map.on('mouseleave', 'building-footprints-fill', () => {
409
- layerControllerRef.current?.setHover(null);
410
- });
411
- map.on('click', 'building-footprints-fill', (e) => {
412
- const feature = e.features?.[0];
413
- const poly = feature ? toPolygonFeature(feature) : null;
414
- if (!poly || !poly.geometry)
415
- return;
416
- const ring = getOuterRing(poly.geometry);
417
- const centroid = computeCentroid(ring) ?? { lng: map.getCenter().lng, lat: map.getCenter().lat };
606
+ const hitLayerForConfig = () => {
607
+ const config = selectionConfigRef.current;
608
+ if (!config)
609
+ return null;
610
+ switch (config.sourceKind) {
611
+ case 'building':
612
+ return 'building-footprints-fill';
613
+ case 'waterway':
614
+ return 'waterway-hit-line';
615
+ case 'road':
616
+ case 'rail':
617
+ return 'road-hit-line';
618
+ default:
619
+ return null;
620
+ }
621
+ };
622
+ const buildCandidateFromFeature = (rawFeature, config) => {
623
+ if (!rawFeature || !config)
624
+ return null;
418
625
  const mapboxgl = mapboxRef.current;
419
626
  if (!mapboxgl)
420
- return;
421
- const pointsLocal = toLocalMeters(ring, centroid, mapboxgl);
627
+ return null;
628
+ if (config.geometryKind === 'polyline' &&
629
+ (config.sourceKind === 'waterway' || config.sourceKind === 'road' || config.sourceKind === 'rail')) {
630
+ const line = toLineFeature(rawFeature);
631
+ if (!line?.geometry?.coordinates?.length)
632
+ return null;
633
+ const coords = line.geometry.coordinates;
634
+ const origin = computeCentroid(coords) ?? { lng: map.getCenter().lng, lat: map.getCenter().lat };
635
+ const pointsLocal = toLocalMeters(coords, origin, mapboxgl);
636
+ if (pointsLocal.length < 2)
637
+ return null;
638
+ const selection = {
639
+ sourceKind: config.sourceKind,
640
+ geometryKind: 'polyline',
641
+ closed: config.closed ?? false,
642
+ geojson: buildGeoJsonLineString(pointsLocal),
643
+ featureGeojson: JSON.stringify(line),
644
+ origin,
645
+ label: config.label,
646
+ mapView: {
647
+ center: { lng: map.getCenter().lng, lat: map.getCenter().lat },
648
+ zoom: map.getZoom(),
649
+ bearing: map.getBearing(),
650
+ pitch: map.getPitch(),
651
+ },
652
+ };
653
+ return {
654
+ selection,
655
+ feature: line,
656
+ pointsLocal,
657
+ stats: computePolylineStats(pointsLocal),
658
+ address: lastGeocodeLabelRef.current ?? '',
659
+ };
660
+ }
661
+ if (config.geometryKind !== 'polygon' || config.sourceKind !== 'building')
662
+ return null;
663
+ const polygon = toPolygonFeature(rawFeature);
664
+ if (!polygon?.geometry)
665
+ return null;
666
+ const ring = getOuterRing(polygon.geometry);
667
+ const origin = computeCentroid(ring) ?? { lng: map.getCenter().lng, lat: map.getCenter().lat };
668
+ const pointsLocal = toLocalMeters(ring, origin, mapboxgl);
422
669
  if (pointsLocal.length < 3)
423
- return;
670
+ return null;
424
671
  const selection = {
672
+ sourceKind: 'building',
673
+ geometryKind: 'polygon',
674
+ closed: config.closed ?? true,
425
675
  geojson: buildGeoJsonPolygon(pointsLocal),
426
- origin: centroid,
676
+ featureGeojson: JSON.stringify(polygon),
677
+ origin,
678
+ label: config.label,
427
679
  mapView: {
428
680
  center: { lng: map.getCenter().lng, lat: map.getCenter().lat },
429
681
  zoom: map.getZoom(),
@@ -431,16 +683,46 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
431
683
  pitch: map.getPitch(),
432
684
  },
433
685
  };
434
- const stats = computeFootprintStats(pointsLocal);
435
- const initialAddress = lastGeocodeLabelRef.current ?? '';
436
- onCandidateSelectRef.current({ selection, feature: poly, pointsLocal, stats, address: initialAddress });
437
- void reverseGeocodeAddress(centroid, selection, poly, pointsLocal, stats, initialAddress);
686
+ return {
687
+ selection,
688
+ feature: polygon,
689
+ pointsLocal,
690
+ stats: computeFootprintStats(pointsLocal),
691
+ address: lastGeocodeLabelRef.current ?? '',
692
+ };
693
+ };
694
+ map.on('mousemove', (e) => {
695
+ const config = selectionConfigRef.current;
696
+ const layerId = hitLayerForConfig();
697
+ if (!config || !layerId) {
698
+ layerControllerRef.current?.setHover(null, null);
699
+ map.getCanvas().style.cursor = '';
700
+ return;
701
+ }
702
+ const feature = map.queryRenderedFeatures(e.point, { layers: [layerId] })?.[0];
703
+ const candidate = buildCandidateFromFeature(feature, config);
704
+ layerControllerRef.current?.setHover(candidate?.feature ?? null, candidate?.selection.sourceKind ?? null);
705
+ map.getCanvas().style.cursor = candidate ? 'pointer' : '';
706
+ });
707
+ map.on('mouseout', () => {
708
+ layerControllerRef.current?.setHover(null, null);
709
+ map.getCanvas().style.cursor = '';
438
710
  });
439
711
  map.on('click', (e) => {
440
- const features = map.queryRenderedFeatures(e.point, { layers: ['building-footprints-fill'] });
441
- if (!features.length) {
712
+ const config = selectionConfigRef.current;
713
+ const layerId = hitLayerForConfig();
714
+ if (!config || !layerId) {
715
+ onCandidateClearRef.current();
716
+ return;
717
+ }
718
+ const feature = map.queryRenderedFeatures(e.point, { layers: [layerId] })?.[0];
719
+ const candidate = buildCandidateFromFeature(feature, config);
720
+ if (!candidate) {
442
721
  onCandidateClearRef.current();
722
+ return;
443
723
  }
724
+ onCandidateSelectRef.current(candidate);
725
+ void reverseGeocodeAddress(candidate.selection.origin, candidate.selection, candidate.feature, candidate.pointsLocal, candidate.stats, candidate.address ?? '');
444
726
  });
445
727
  };
446
728
  const reverseGeocodeAddress = async (center, selection, feature, pointsLocal, stats, initialAddress) => {
@@ -470,7 +752,48 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
470
752
  // ignore geocode errors
471
753
  }
472
754
  };
473
- return (_jsxs("div", { className: "absolute inset-0", children: [_jsx("div", { ref: containerRef, className: "w-full h-full" }), !accessToken && (_jsx("div", { className: "absolute inset-0 z-[2] flex items-center justify-center bg-black/70 text-xs text-white font-nunito", children: "Mapbox access token missing." })), mapError && (_jsx("div", { className: "absolute inset-0 z-[2] flex items-center justify-center bg-black/70 text-xs text-white font-nunito", children: mapError })), !mapReady && !mapError && accessToken && (_jsx("div", { className: "absolute inset-0 z-[2] flex items-center justify-center bg-black/60 text-xs text-white font-nunito", children: "loading map\u2026" }))] }));
755
+ return (_jsxs("div", { style: {
756
+ position: 'absolute',
757
+ inset: 0,
758
+ width: '100%',
759
+ height: '100%',
760
+ }, children: [_jsx("div", { ref: containerRef, style: {
761
+ width: '100%',
762
+ height: '100%',
763
+ } }), !accessToken && (_jsx("div", { style: {
764
+ position: 'absolute',
765
+ inset: 0,
766
+ zIndex: 2,
767
+ display: 'flex',
768
+ alignItems: 'center',
769
+ justifyContent: 'center',
770
+ background: 'rgba(0, 0, 0, 0.7)',
771
+ color: '#ffffff',
772
+ fontSize: 12,
773
+ fontFamily: 'var(--ui-font-family, system-ui, sans-serif)',
774
+ }, children: "Mapbox access token missing." })), activeMapError && (_jsx("div", { style: {
775
+ position: 'absolute',
776
+ inset: 0,
777
+ zIndex: 2,
778
+ display: 'flex',
779
+ alignItems: 'center',
780
+ justifyContent: 'center',
781
+ background: 'rgba(0, 0, 0, 0.7)',
782
+ color: '#ffffff',
783
+ fontSize: 12,
784
+ fontFamily: 'var(--ui-font-family, system-ui, sans-serif)',
785
+ }, children: activeMapError })), !mapReady && !activeMapError && accessToken && (_jsx("div", { style: {
786
+ position: 'absolute',
787
+ inset: 0,
788
+ zIndex: 2,
789
+ display: 'flex',
790
+ alignItems: 'center',
791
+ justifyContent: 'center',
792
+ background: 'rgba(0, 0, 0, 0.6)',
793
+ color: '#ffffff',
794
+ fontSize: 12,
795
+ fontFamily: 'var(--ui-font-family, system-ui, sans-serif)',
796
+ }, children: "loading map\u2026" }))] }));
474
797
  });
475
798
  const ensureGeometryUvs = (geometry) => {
476
799
  if (geometry.getAttribute('uv'))
@@ -500,7 +823,7 @@ const ensureGeometryUvs = (geometry) => {
500
823
  function applyMaterialOverrides(group, settings) {
501
824
  if (!group || typeof group.traverse !== 'function')
502
825
  return;
503
- const color = new THREE.Color(settings?.color ?? '#ffffff');
826
+ const color = settings?.color ? new THREE.Color(settings.color) : null;
504
827
  const textureUrl = settings?.textureUrl;
505
828
  const repeat = settings?.textureRepeat ?? 2;
506
829
  let texture = null;
@@ -517,48 +840,136 @@ function applyMaterialOverrides(group, settings) {
517
840
  return;
518
841
  const mesh = child;
519
842
  mesh.frustumCulled = false;
520
- if (mesh.material) {
521
- const material = mesh.material;
522
- if (Array.isArray(material))
523
- material.forEach((m) => m?.dispose?.());
524
- else
525
- material?.dispose?.();
526
- }
527
843
  if (texture) {
528
844
  ensureGeometryUvs(mesh.geometry);
529
845
  }
530
- mesh.material = new THREE.MeshStandardMaterial({
531
- color,
532
- roughness: 0.75,
533
- metalness: 0,
534
- side: THREE.FrontSide,
535
- map: texture ?? null,
846
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
847
+ materials.forEach((material) => {
848
+ if (!material)
849
+ return;
850
+ const anyMaterial = material;
851
+ if (color && anyMaterial.color instanceof THREE.Color) {
852
+ anyMaterial.color.copy(color);
853
+ }
854
+ if (texture && 'map' in anyMaterial) {
855
+ anyMaterial.map = texture;
856
+ }
857
+ if ('side' in anyMaterial) {
858
+ anyMaterial.side = THREE.DoubleSide;
859
+ }
860
+ anyMaterial.needsUpdate = true;
536
861
  });
537
- mesh.material.transparent = false;
538
- mesh.material.opacity = 1;
539
- mesh.material.depthWrite = true;
540
- mesh.material.depthTest = true;
541
- mesh.material.polygonOffset = true;
542
- mesh.material.polygonOffsetFactor = 1.0;
543
- mesh.material.polygonOffsetUnits = 1.0;
544
862
  mesh.castShadow = false;
545
863
  mesh.receiveShadow = false;
546
864
  });
547
865
  }
548
- function defaultApplyLightingPreset(scene, renderer, _presetKey) {
549
- scene.background = new THREE.Color('#ffffff');
550
- const lightsToRemove = scene.children.filter((child) => child instanceof THREE.Light);
866
+ function parseLightingPresetSpec(presetKey) {
867
+ if (!presetKey)
868
+ return null;
869
+ try {
870
+ const parsed = JSON.parse(presetKey);
871
+ return parsed && typeof parsed === 'object' ? parsed : null;
872
+ }
873
+ catch {
874
+ switch (presetKey) {
875
+ case 'dark':
876
+ return {
877
+ renderer: { toneMapping: 'neutral', toneMappingExposure: 0.95, outputColorSpace: 'srgb' },
878
+ lighting: {
879
+ ambient: { color: '#dce4f0', intensity: 0.26 },
880
+ hemisphere: { skyColor: '#e8edf5', groundColor: '#8a8f97', intensity: 0.9 },
881
+ key: { color: '#ffffff', intensity: 0.95, position: [8, 12, 6], castShadow: false },
882
+ fill: { color: '#ffffff', intensity: 0.42, position: [-8, 9, -5] },
883
+ },
884
+ };
885
+ case 'light':
886
+ return {
887
+ renderer: { toneMapping: 'neutral', toneMappingExposure: 1.08, outputColorSpace: 'srgb' },
888
+ lighting: {
889
+ ambient: { color: '#ffffff', intensity: 0.42 },
890
+ hemisphere: { skyColor: '#f8fbff', groundColor: '#e5e7eb', intensity: 1.1 },
891
+ key: { color: '#ffffff', intensity: 1.15, position: [10, 15, 8], castShadow: false },
892
+ fill: { color: '#ffffff', intensity: 0.5, position: [-10, 10, -6] },
893
+ },
894
+ };
895
+ default:
896
+ return null;
897
+ }
898
+ }
899
+ }
900
+ function applyLightingReadabilityBoost(renderer, presetKey) {
901
+ const preset = parseLightingPresetSpec(presetKey);
902
+ if (preset?.renderer?.outputColorSpace) {
903
+ renderer.outputColorSpace =
904
+ preset.renderer.outputColorSpace === 'linear' ? THREE.LinearSRGBColorSpace : THREE.SRGBColorSpace;
905
+ }
906
+ if (preset?.renderer?.toneMapping) {
907
+ renderer.toneMapping = toneMappingLookup[preset.renderer.toneMapping];
908
+ }
909
+ if (typeof preset?.renderer?.toneMappingExposure === 'number') {
910
+ renderer.toneMappingExposure = preset.renderer.toneMappingExposure;
911
+ }
912
+ else {
913
+ renderer.toneMappingExposure = 1.02;
914
+ }
915
+ const shadowMapEnabled = preset?.renderer?.shadowMap?.enabled ?? preset?.lighting?.key?.castShadow ?? false;
916
+ renderer.shadowMap.enabled = shadowMapEnabled;
917
+ renderer.shadowMap.type = shadowMapTypeLookup[preset?.renderer?.shadowMap?.type ?? 'pcfsoft'];
918
+ }
919
+ function resolveColor(value, fallback) {
920
+ return typeof value === 'string' || typeof value === 'number' ? value : fallback;
921
+ }
922
+ function configureDirectionalShadow(light, shadow) {
923
+ if (!light.shadow)
924
+ return;
925
+ const cameraSize = shadow?.cameraSize ?? 25;
926
+ light.shadow.camera.left = -cameraSize;
927
+ light.shadow.camera.right = cameraSize;
928
+ light.shadow.camera.top = cameraSize;
929
+ light.shadow.camera.bottom = -cameraSize;
930
+ light.shadow.camera.near = shadow?.near ?? 0.1;
931
+ light.shadow.camera.far = shadow?.far ?? 60;
932
+ const mapSize = shadow?.mapSize ?? 2048;
933
+ light.shadow.mapSize.set(mapSize, mapSize);
934
+ light.shadow.bias = shadow?.bias ?? -0.0001;
935
+ light.shadow.radius = shadow?.radius ?? 3;
936
+ if (typeof light.shadow.camera.updateProjectionMatrix === 'function') {
937
+ light.shadow.camera.updateProjectionMatrix();
938
+ }
939
+ }
940
+ function defaultApplyLightingPreset(scene, renderer, presetKey) {
941
+ scene.background = null;
942
+ const lightsToRemove = scene.children.filter((child) => child instanceof THREE.Light || child.userData?.__mapLightTarget === true);
551
943
  lightsToRemove.forEach((light) => scene.remove(light));
552
- scene.add(new THREE.AmbientLight('#ffffff', 0.6));
553
- const key = new THREE.DirectionalLight('#ffffff', 0.8);
554
- key.position.set(8, 12, 6);
555
- key.castShadow = false;
556
- scene.add(key);
557
- const fill = new THREE.DirectionalLight('#ffffff', 0.4);
558
- fill.position.set(-6, 8, -6);
559
- scene.add(fill);
560
- renderer.toneMapping = THREE.NeutralToneMapping;
561
- renderer.toneMappingExposure = 1.05;
944
+ const preset = parseLightingPresetSpec(presetKey);
945
+ applyLightingReadabilityBoost(renderer, presetKey);
946
+ const ambient = preset?.lighting?.ambient;
947
+ const hemi = preset?.lighting?.hemisphere;
948
+ const key = preset?.lighting?.key;
949
+ const fill = preset?.lighting?.fill;
950
+ const lightTarget = preset?.sceneExtras?.lightTarget ?? [0, 1.5, 0];
951
+ scene.add(new THREE.AmbientLight(new THREE.Color(resolveColor(ambient?.color, '#ffffff')), ambient?.intensity ?? 0.22));
952
+ scene.add(new THREE.HemisphereLight(new THREE.Color(resolveColor(hemi?.skyColor ?? hemi?.sky, '#f8fbff')), new THREE.Color(resolveColor(hemi?.groundColor ?? hemi?.ground, '#d4d4d4')), hemi?.intensity ?? 0.85));
953
+ const keyLight = new THREE.DirectionalLight(new THREE.Color(resolveColor(key?.color, '#ffffff')), key?.intensity ?? 0.9);
954
+ keyLight.position.set(...(key?.position ?? [8, 12, 6]));
955
+ keyLight.castShadow = key?.castShadow ?? false;
956
+ if (keyLight.castShadow) {
957
+ configureDirectionalShadow(keyLight, preset?.lighting?.shadow);
958
+ }
959
+ const keyTarget = new THREE.Object3D();
960
+ keyTarget.userData.__mapLightTarget = true;
961
+ keyTarget.position.set(...lightTarget);
962
+ keyLight.target = keyTarget;
963
+ scene.add(keyTarget);
964
+ scene.add(keyLight);
965
+ const fillLight = new THREE.DirectionalLight(new THREE.Color(resolveColor(fill?.color, '#ffffff')), fill?.intensity ?? 0.38);
966
+ fillLight.position.set(...(fill?.position ?? [-6, 8, -6]));
967
+ const fillTarget = new THREE.Object3D();
968
+ fillTarget.userData.__mapLightTarget = true;
969
+ fillTarget.position.set(...lightTarget);
970
+ fillLight.target = fillTarget;
971
+ scene.add(fillTarget);
972
+ scene.add(fillLight);
562
973
  }
563
974
  function applyMapVisibility(map, mode, cacheRef) {
564
975
  if (!map?.getStyle?.())
@@ -623,18 +1034,58 @@ function applyMapVisibility(map, mode, cacheRef) {
623
1034
  }
624
1035
  }
625
1036
  }
626
- function disableFillExtrusions(map) {
627
- const style = map.getStyle();
628
- if (!style?.layers)
629
- return;
630
- style.layers.forEach((layer) => {
631
- if (layer.type !== 'fill-extrusion')
632
- return;
633
- try {
634
- map.setLayoutProperty(layer.id, 'visibility', 'none');
635
- }
636
- catch {
637
- // ignore
638
- }
639
- });
1037
+ function resolveGroundFrameMeters(group) {
1038
+ if (!group || typeof group.updateWorldMatrix !== 'function')
1039
+ return null;
1040
+ group.updateWorldMatrix(true, true);
1041
+ const box = new THREE.Box3().setFromObject(group);
1042
+ if (box.isEmpty())
1043
+ return null;
1044
+ const size = box.getSize(new THREE.Vector3());
1045
+ const center = box.getCenter(new THREE.Vector3());
1046
+ return {
1047
+ centerEastMeters: center.x,
1048
+ centerNorthMeters: -center.z,
1049
+ spanEastMeters: Math.max(MIN_VIEW_SPAN_METERS, size.x),
1050
+ spanNorthMeters: Math.max(MIN_VIEW_SPAN_METERS, size.z),
1051
+ };
1052
+ }
1053
+ function resolveSelectionFrameMeters(selection) {
1054
+ if (!selection?.geojson)
1055
+ return null;
1056
+ try {
1057
+ const parsed = JSON.parse(selection.geojson);
1058
+ const points = parsed.type === 'Polygon'
1059
+ ? (parsed.coordinates?.[0] ?? [])
1060
+ : (parsed.coordinates ?? []);
1061
+ if (!points.length)
1062
+ return null;
1063
+ let minX = Number.POSITIVE_INFINITY;
1064
+ let maxX = Number.NEGATIVE_INFINITY;
1065
+ let minY = Number.POSITIVE_INFINITY;
1066
+ let maxY = Number.NEGATIVE_INFINITY;
1067
+ points.forEach(([x, y]) => {
1068
+ if (!Number.isFinite(x) || !Number.isFinite(y))
1069
+ return;
1070
+ if (x < minX)
1071
+ minX = x;
1072
+ if (x > maxX)
1073
+ maxX = x;
1074
+ if (y < minY)
1075
+ minY = y;
1076
+ if (y > maxY)
1077
+ maxY = y;
1078
+ });
1079
+ if (![minX, maxX, minY, maxY].every(Number.isFinite))
1080
+ return null;
1081
+ return {
1082
+ centerEastMeters: (minX + maxX) / 2,
1083
+ centerNorthMeters: (minY + maxY) / 2,
1084
+ spanEastMeters: Math.max(MIN_VIEW_SPAN_METERS, maxX - minX),
1085
+ spanNorthMeters: Math.max(MIN_VIEW_SPAN_METERS, maxY - minY),
1086
+ };
1087
+ }
1088
+ catch {
1089
+ return null;
1090
+ }
640
1091
  }