@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.
- package/dist/MapModeViewport.d.ts +29 -8
- package/dist/MapModeViewport.d.ts.map +1 -1
- package/dist/MapModeViewport.js +580 -129
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +3 -3
package/dist/MapModeViewport.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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 =
|
|
211
|
-
|
|
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,
|
|
214
|
-
|
|
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 =
|
|
220
|
-
|
|
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,
|
|
426
|
+
applyMapVisibility(map, mapVisibilityRef.current, opacityCacheRef);
|
|
223
427
|
setMapReady(true);
|
|
224
|
-
|
|
428
|
+
bindSelectionHandlers(map);
|
|
225
429
|
requestAnimationFrame(() => {
|
|
226
430
|
map.resize();
|
|
431
|
+
map.triggerRepaint();
|
|
227
432
|
if (debug) {
|
|
228
433
|
const mapCanvas = map.getCanvas();
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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,
|
|
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(
|
|
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 =
|
|
564
|
+
renderer.shadowMap.enabled = false;
|
|
362
565
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
363
|
-
(
|
|
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
|
|
602
|
+
const bindSelectionHandlers = (map) => {
|
|
400
603
|
if (handlersBoundRef.current)
|
|
401
604
|
return;
|
|
402
605
|
handlersBoundRef.current = true;
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
441
|
-
|
|
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", {
|
|
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
|
|
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
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
const fill =
|
|
558
|
-
|
|
559
|
-
scene.add(
|
|
560
|
-
|
|
561
|
-
|
|
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
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
}
|