@treasuryspatial/map-react 0.1.19 → 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')
@@ -34,34 +65,89 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
34
65
  const params = new URLSearchParams(window.location.search);
35
66
  return params.has('mapDebug') || process.env.NODE_ENV !== 'production';
36
67
  }, []);
68
+ const [themeMode, setThemeMode] = useState(() => {
69
+ if (typeof document === 'undefined')
70
+ return 'dark';
71
+ return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
72
+ });
37
73
  const log = useCallback((...args) => {
38
74
  if (!debug)
39
75
  return;
40
76
  console.info('[map-react]', ...args);
41
77
  }, [debug]);
78
+ const fmt = useCallback((value) => (Number.isFinite(value) ? value.toFixed(3) : 'nan'), []);
42
79
  useEffect(() => {
43
80
  lightingPresetRef.current = lightingPreset;
44
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]);
45
107
  useEffect(() => {
46
108
  confirmedSelectionRef.current = confirmedSelection ?? null;
47
109
  if (confirmedSelection?.origin) {
48
110
  lastOriginRef.current = confirmedSelection.origin;
49
111
  }
50
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]);
51
125
  useEffect(() => {
52
126
  onCandidateSelectRef.current = onCandidateSelect;
53
127
  }, [onCandidateSelect]);
54
128
  useEffect(() => {
55
129
  onCandidateClearRef.current = onCandidateClear;
56
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
+ }, []);
57
139
  const accessToken = useMemo(() => accessTokenProp ?? process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN ?? '', [accessTokenProp]);
58
140
  const mapStyle = useMemo(() => {
59
141
  if (mapStyleProp)
60
142
  return mapStyleProp;
61
143
  const styleDark = process.env.NEXT_PUBLIC_MAPBOX_FOOTPRINT_STYLE_DARK;
62
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';
63
148
  if (typeof window !== 'undefined' && (styleDark || styleLight)) {
64
- const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ?? false;
149
+ const prefersDark = themeMode === 'dark' ||
150
+ (themeMode !== 'light' && (window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ?? false));
65
151
  const chosen = prefersDark ? (styleDark ?? styleLight) : (styleLight ?? styleDark);
66
152
  if (chosen)
67
153
  return chosen;
@@ -69,16 +155,151 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
69
155
  return (process.env.NEXT_PUBLIC_MAPBOX_FOOTPRINT_STYLE ??
70
156
  styleDark ??
71
157
  styleLight ??
72
- 'mapbox://styles/treasuryadmin/cmc2gd70p00ui01spfnel4el2');
73
- }, [mapStyleProp]);
158
+ publicFallbackStyle);
159
+ }, [mapStyleProp, themeMode]);
160
+ useEffect(() => {
161
+ if (typeof document === 'undefined')
162
+ return;
163
+ const root = document.documentElement;
164
+ const readTheme = () => root.getAttribute('data-theme') === 'light' ? setThemeMode('light') : setThemeMode('dark');
165
+ readTheme();
166
+ const observer = new MutationObserver(readTheme);
167
+ observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
168
+ return () => observer.disconnect();
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]);
74
240
  useImperativeHandle(ref, () => ({
75
241
  setLightingPreset: (presetKey) => {
76
242
  lightingPresetRef.current = presetKey;
77
243
  if (sceneRef.current && rendererRef.current) {
78
- (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;
79
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
+ });
80
299
  },
81
- animateToView: () => { },
300
+ searchSuggestions: (query) => fetchSearchSuggestions(query),
301
+ applySearchSuggestion,
302
+ searchAddress,
82
303
  capturePngDataUrl: () => {
83
304
  const map = mapRef.current;
84
305
  if (!map)
@@ -90,7 +311,10 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
90
311
  return '';
91
312
  }
92
313
  },
93
- }), [applyLightingPreset]);
314
+ requestRepaint: () => {
315
+ mapRef.current?.triggerRepaint();
316
+ },
317
+ }), [applySceneLighting, applySearchSuggestion, fetchSearchSuggestions, searchAddress]);
94
318
  useEffect(() => {
95
319
  if (!active)
96
320
  return;
@@ -135,15 +359,6 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
135
359
  pitch: startPitch,
136
360
  });
137
361
  map.addControl(new mapboxgl.NavigationControl({ visualizePitch: true }), 'bottom-right');
138
- const geocoder = new MapboxGeocoder({
139
- accessToken,
140
- mapboxgl: mapboxgl,
141
- marker: false,
142
- collapsed: false,
143
- placeholder: 'search address',
144
- proximity: { longitude: DEFAULT_CENTER[0], latitude: DEFAULT_CENTER[1] },
145
- });
146
- map.addControl(geocoder, 'top-left');
147
362
  mapRef.current = map;
148
363
  const onMapError = (event) => {
149
364
  const err = event?.error ?? event;
@@ -175,13 +390,12 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
175
390
  if (!mapRef.current)
176
391
  return;
177
392
  mapRef.current.resize();
393
+ mapRef.current.triggerRepaint();
178
394
  if (debug) {
179
395
  const mapCanvas = mapRef.current.getCanvas();
180
- log('map resize', {
181
- canvasSize: { width: mapCanvas.width, height: mapCanvas.height },
182
- canvasRect: mapCanvas.getBoundingClientRect(),
183
- containerRect: container.getBoundingClientRect(),
184
- });
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)}`);
185
399
  }
186
400
  });
187
401
  observer.observe(container);
@@ -191,40 +405,38 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
191
405
  if (!map.isStyleLoaded())
192
406
  return;
193
407
  opacityCacheRef.current.clear();
194
- layerControllerRef.current = ensureFootprintLayers(map);
195
- 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();
196
413
  ensureCustomLayer(map);
197
- applyMapVisibility(map, mapVisibility, opacityCacheRef);
198
- bindFootprintHandlers(map);
414
+ applyMapVisibility(map, mapVisibilityRef.current, opacityCacheRef);
415
+ bindSelectionHandlers(map);
199
416
  });
200
417
  map.on('load', () => {
201
418
  if (!map.isStyleLoaded())
202
419
  return;
203
- layerControllerRef.current = ensureFootprintLayers(map);
204
- 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();
205
425
  ensureCustomLayer(map);
206
- applyMapVisibility(map, mapVisibility, opacityCacheRef);
426
+ applyMapVisibility(map, mapVisibilityRef.current, opacityCacheRef);
207
427
  setMapReady(true);
208
- bindFootprintHandlers(map);
428
+ bindSelectionHandlers(map);
209
429
  requestAnimationFrame(() => {
210
430
  map.resize();
431
+ map.triggerRepaint();
211
432
  if (debug) {
212
433
  const mapCanvas = map.getCanvas();
213
- log('map load', {
214
- canvasSize: { width: mapCanvas.width, height: mapCanvas.height },
215
- canvasRect: mapCanvas.getBoundingClientRect(),
216
- containerRect: map.getContainer().getBoundingClientRect(),
217
- });
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)}`);
218
437
  }
219
438
  });
220
439
  });
221
- geocoder.on('result', (event) => {
222
- const center = event?.result?.center;
223
- if (!Array.isArray(center) || center.length < 2)
224
- return;
225
- lastGeocodeLabelRef.current = event?.result?.place_name ?? event?.result?.text ?? null;
226
- map.easeTo({ center: [Number(center[0]), Number(center[1])], zoom: Math.max(map.getZoom(), 17) });
227
- });
228
440
  }
229
441
  catch (err) {
230
442
  const message = err instanceof Error ? err.message : 'Failed to initialize Mapbox';
@@ -250,30 +462,40 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
250
462
  cleanupCanvasEvents?.();
251
463
  cleanupMapEvents?.();
252
464
  };
253
- }, [active, accessToken, mapStyle, mapVisibility]);
465
+ }, [active, accessToken, applyCurrentSelectionToController, mapStyle]);
254
466
  useEffect(() => {
255
467
  const map = mapRef.current;
256
468
  if (!map || !mapReady)
257
469
  return;
258
470
  applyMapVisibility(map, mapVisibility, opacityCacheRef);
259
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]);
260
482
  useEffect(() => {
261
483
  const controller = layerControllerRef.current;
262
484
  if (!controller)
263
485
  return;
264
486
  if (candidate?.feature) {
265
- controller.setSelected(candidate.feature);
487
+ controller.setSelected(candidate.feature, candidate.selection?.sourceKind ?? null);
266
488
  if (candidate.selection?.origin) {
267
489
  lastOriginRef.current = candidate.selection.origin;
268
490
  }
269
491
  }
270
492
  else if (confirmedFeature) {
271
- controller.setSelected(confirmedFeature);
493
+ controller.setSelected(confirmedFeature, confirmedSelection?.sourceKind ?? null);
272
494
  }
273
495
  else {
274
- controller.setSelected(null);
496
+ controller.setSelected(null, null);
275
497
  }
276
- }, [candidate, confirmedFeature]);
498
+ }, [candidate, confirmedFeature, confirmedSelection?.sourceKind, mapReady]);
277
499
  useEffect(() => {
278
500
  if (!mapReady)
279
501
  return;
@@ -282,12 +504,7 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
282
504
  return;
283
505
  const selectionOrigin = confirmedSelection?.origin ?? candidate?.selection?.origin ?? null;
284
506
  if (debug) {
285
- log('origin sync', {
286
- hasLayer: Boolean(threeLayerRef.current),
287
- selectionOrigin,
288
- fallbackOrigin: lastOriginRef.current,
289
- hasGeometry: Boolean(geometry3dm || meshGroup),
290
- });
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'}`);
291
508
  }
292
509
  if (selectionOrigin) {
293
510
  lastOriginRef.current = selectionOrigin;
@@ -323,6 +540,7 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
323
540
  else if (geometry3dm && resolveMeshGroup) {
324
541
  group = await resolveMeshGroup(geometry3dm);
325
542
  }
543
+ activeMeshGroupRef.current = group;
326
544
  if (!group || cancelled)
327
545
  return;
328
546
  applyMaterialOverrides(group, materialSettings);
@@ -331,6 +549,7 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
331
549
  void applyGroup();
332
550
  return () => {
333
551
  cancelled = true;
552
+ activeMeshGroupRef.current = null;
334
553
  };
335
554
  }, [mapReady, geometry3dm, meshGroup, materialSettings, resolveMeshGroup]);
336
555
  const ensureCustomLayer = (map) => {
@@ -342,9 +561,9 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
342
561
  onInit: (scene, renderer) => {
343
562
  sceneRef.current = scene;
344
563
  rendererRef.current = renderer;
345
- renderer.shadowMap.enabled = true;
564
+ renderer.shadowMap.enabled = false;
346
565
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
347
- (applyLightingPreset ?? defaultApplyLightingPreset)(scene, renderer, lightingPresetRef.current);
566
+ applySceneLighting(scene, renderer, lightingPresetRef.current);
348
567
  },
349
568
  });
350
569
  setThreeLayerReady(true);
@@ -380,34 +599,83 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
380
599
  }
381
600
  }
382
601
  };
383
- const bindFootprintHandlers = (map) => {
602
+ const bindSelectionHandlers = (map) => {
384
603
  if (handlersBoundRef.current)
385
604
  return;
386
605
  handlersBoundRef.current = true;
387
- map.on('mousemove', 'building-footprints-fill', (e) => {
388
- const feature = e.features?.[0];
389
- const poly = feature ? toPolygonFeature(feature) : null;
390
- layerControllerRef.current?.setHover(poly);
391
- });
392
- map.on('mouseleave', 'building-footprints-fill', () => {
393
- layerControllerRef.current?.setHover(null);
394
- });
395
- map.on('click', 'building-footprints-fill', (e) => {
396
- const feature = e.features?.[0];
397
- const poly = feature ? toPolygonFeature(feature) : null;
398
- if (!poly || !poly.geometry)
399
- return;
400
- const ring = getOuterRing(poly.geometry);
401
- 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;
402
625
  const mapboxgl = mapboxRef.current;
403
626
  if (!mapboxgl)
404
- return;
405
- 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);
406
669
  if (pointsLocal.length < 3)
407
- return;
670
+ return null;
408
671
  const selection = {
672
+ sourceKind: 'building',
673
+ geometryKind: 'polygon',
674
+ closed: config.closed ?? true,
409
675
  geojson: buildGeoJsonPolygon(pointsLocal),
410
- origin: centroid,
676
+ featureGeojson: JSON.stringify(polygon),
677
+ origin,
678
+ label: config.label,
411
679
  mapView: {
412
680
  center: { lng: map.getCenter().lng, lat: map.getCenter().lat },
413
681
  zoom: map.getZoom(),
@@ -415,16 +683,46 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
415
683
  pitch: map.getPitch(),
416
684
  },
417
685
  };
418
- const stats = computeFootprintStats(pointsLocal);
419
- const initialAddress = lastGeocodeLabelRef.current ?? '';
420
- onCandidateSelectRef.current({ selection, feature: poly, pointsLocal, stats, address: initialAddress });
421
- 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 = '';
422
710
  });
423
711
  map.on('click', (e) => {
424
- const features = map.queryRenderedFeatures(e.point, { layers: ['building-footprints-fill'] });
425
- if (!features.length) {
712
+ const config = selectionConfigRef.current;
713
+ const layerId = hitLayerForConfig();
714
+ if (!config || !layerId) {
426
715
  onCandidateClearRef.current();
716
+ return;
427
717
  }
718
+ const feature = map.queryRenderedFeatures(e.point, { layers: [layerId] })?.[0];
719
+ const candidate = buildCandidateFromFeature(feature, config);
720
+ if (!candidate) {
721
+ onCandidateClearRef.current();
722
+ return;
723
+ }
724
+ onCandidateSelectRef.current(candidate);
725
+ void reverseGeocodeAddress(candidate.selection.origin, candidate.selection, candidate.feature, candidate.pointsLocal, candidate.stats, candidate.address ?? '');
428
726
  });
429
727
  };
430
728
  const reverseGeocodeAddress = async (center, selection, feature, pointsLocal, stats, initialAddress) => {
@@ -454,7 +752,48 @@ export const MapModeViewport = forwardRef(function MapModeViewport({ active, geo
454
752
  // ignore geocode errors
455
753
  }
456
754
  };
457
- 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" }))] }));
458
797
  });
459
798
  const ensureGeometryUvs = (geometry) => {
460
799
  if (geometry.getAttribute('uv'))
@@ -484,7 +823,7 @@ const ensureGeometryUvs = (geometry) => {
484
823
  function applyMaterialOverrides(group, settings) {
485
824
  if (!group || typeof group.traverse !== 'function')
486
825
  return;
487
- const color = new THREE.Color(settings?.color ?? '#ffffff');
826
+ const color = settings?.color ? new THREE.Color(settings.color) : null;
488
827
  const textureUrl = settings?.textureUrl;
489
828
  const repeat = settings?.textureRepeat ?? 2;
490
829
  let texture = null;
@@ -501,48 +840,136 @@ function applyMaterialOverrides(group, settings) {
501
840
  return;
502
841
  const mesh = child;
503
842
  mesh.frustumCulled = false;
504
- if (mesh.material) {
505
- const material = mesh.material;
506
- if (Array.isArray(material))
507
- material.forEach((m) => m?.dispose?.());
508
- else
509
- material?.dispose?.();
510
- }
511
843
  if (texture) {
512
844
  ensureGeometryUvs(mesh.geometry);
513
845
  }
514
- mesh.material = new THREE.MeshStandardMaterial({
515
- color,
516
- roughness: 0.75,
517
- metalness: 0,
518
- side: THREE.FrontSide,
519
- 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;
520
861
  });
521
- mesh.material.transparent = false;
522
- mesh.material.opacity = 1;
523
- mesh.material.depthWrite = true;
524
- mesh.material.depthTest = true;
525
- mesh.material.polygonOffset = true;
526
- mesh.material.polygonOffsetFactor = 1.0;
527
- mesh.material.polygonOffsetUnits = 1.0;
528
862
  mesh.castShadow = false;
529
863
  mesh.receiveShadow = false;
530
864
  });
531
865
  }
532
- function defaultApplyLightingPreset(scene, renderer, _presetKey) {
533
- scene.background = new THREE.Color('#ffffff');
534
- 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);
535
943
  lightsToRemove.forEach((light) => scene.remove(light));
536
- scene.add(new THREE.AmbientLight('#ffffff', 0.6));
537
- const key = new THREE.DirectionalLight('#ffffff', 0.8);
538
- key.position.set(8, 12, 6);
539
- key.castShadow = false;
540
- scene.add(key);
541
- const fill = new THREE.DirectionalLight('#ffffff', 0.4);
542
- fill.position.set(-6, 8, -6);
543
- scene.add(fill);
544
- renderer.toneMapping = THREE.NeutralToneMapping;
545
- 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);
546
973
  }
547
974
  function applyMapVisibility(map, mode, cacheRef) {
548
975
  if (!map?.getStyle?.())
@@ -607,18 +1034,58 @@ function applyMapVisibility(map, mode, cacheRef) {
607
1034
  }
608
1035
  }
609
1036
  }
610
- function disableFillExtrusions(map) {
611
- const style = map.getStyle();
612
- if (!style?.layers)
613
- return;
614
- style.layers.forEach((layer) => {
615
- if (layer.type !== 'fill-extrusion')
616
- return;
617
- try {
618
- map.setLayoutProperty(layer.id, 'visibility', 'none');
619
- }
620
- catch {
621
- // ignore
622
- }
623
- });
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
+ }
624
1091
  }