@lad-sapienza/scms-core 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +14 -0
- package/README.md +73 -0
- package/components/BSNavbar.tsx +264 -0
- package/components/DataTb/DataTb.astro +33 -0
- package/components/DataTb/DataTb.tsx +340 -0
- package/components/DataTb/DataTbMdx.tsx +75 -0
- package/components/DataTb/README.md +275 -0
- package/components/DataTb/index.ts +36 -0
- package/components/DataTb/sources/ApiSource.tsx +89 -0
- package/components/DataTb/sources/CsvSource.tsx +69 -0
- package/components/DataTb/sources/DirectusSource.tsx +98 -0
- package/components/DataTb/sources/JsonSource.tsx +65 -0
- package/components/DataTb/sources/index.ts +17 -0
- package/components/DataTb/types.ts +215 -0
- package/components/DataTb/utils.ts +186 -0
- package/components/Gallery/Gallery.astro +65 -0
- package/components/Gallery/Gallery.tsx +136 -0
- package/components/Gallery/README.md +118 -0
- package/components/Gallery/__tests__/galleryUtils.test.ts +178 -0
- package/components/Gallery/galleryUtils.ts +186 -0
- package/components/Gallery/index.ts +7 -0
- package/components/Gallery/react-photoswipe-gallery.d.ts +26 -0
- package/components/Gallery/types.ts +46 -0
- package/components/Gallery/virtual-gallery.d.ts +15 -0
- package/components/Map/LayerControl.tsx +368 -0
- package/components/Map/Map.tsx +735 -0
- package/components/Map/MapMdx.tsx +37 -0
- package/components/Map/RasterLayerLibre.tsx +33 -0
- package/components/Map/Search/SearchOperators.ts +31 -0
- package/components/Map/Search/SearchUI.tsx +75 -0
- package/components/Map/Search/SearchUIAdvanced.tsx +289 -0
- package/components/Map/Search/SearchUISimple.tsx +115 -0
- package/components/Map/Search/index.ts +7 -0
- package/components/Map/defaultBasemaps.ts +88 -0
- package/components/Map/index.ts +6 -0
- package/components/Map/types.ts +179 -0
- package/components/Map/utils.ts +367 -0
- package/components/README.md +62 -0
- package/components/Record/Field.tsx +35 -0
- package/components/Record/Image.tsx +114 -0
- package/components/Record/RecordFetcher.tsx +127 -0
- package/components/Record/context.tsx +14 -0
- package/components/Record/index.ts +11 -0
- package/components/Record/useRecordFetcher.ts +104 -0
- package/components/Record/utils.ts +13 -0
- package/components/SEO/SEO.astro +132 -0
- package/components/TableOfContents/TableOfContents.astro +86 -0
- package/components/ZoteroGeoViewer/README.md +195 -0
- package/components/ZoteroGeoViewer/ZoteroGeoViewer.astro +26 -0
- package/components/ZoteroGeoViewer/ZoteroGeoViewer.tsx +483 -0
- package/components/ZoteroGeoViewer/ZoteroRecordsPreview.tsx +185 -0
- package/components/ZoteroGeoViewer/index.ts +16 -0
- package/components/ZoteroGeoViewer/types.ts +123 -0
- package/index.ts +26 -0
- package/integrations/__tests__/galleryIntegration.test.ts +70 -0
- package/integrations/contentAssetsIntegration.ts +132 -0
- package/integrations/directusLoader.ts +98 -0
- package/integrations/galleryIntegration.ts +78 -0
- package/package.json +56 -0
- package/scms.ts +95 -0
- package/utils/content.ts +88 -0
- package/utils/data-fetcher.ts +128 -0
- package/utils/directus-config.ts +160 -0
- package/utils/record-fetcher.ts +132 -0
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
import { useState, useMemo, useEffect, useRef } from 'react';
|
|
2
|
+
import MapLibreMap, {
|
|
3
|
+
NavigationControl,
|
|
4
|
+
FullscreenControl,
|
|
5
|
+
ScaleControl,
|
|
6
|
+
GeolocateControl,
|
|
7
|
+
Source,
|
|
8
|
+
Layer,
|
|
9
|
+
Popup,
|
|
10
|
+
type MapRef
|
|
11
|
+
} from '@vis.gl/react-maplibre';
|
|
12
|
+
import 'maplibre-gl/dist/maplibre-gl.css';
|
|
13
|
+
import type { MapProps, VectorLayerConfig } from './types';
|
|
14
|
+
import { RasterLayerLibre } from './RasterLayerLibre';
|
|
15
|
+
import { LayerControlIControl } from './LayerControl';
|
|
16
|
+
import { directusShorthandToConfig } from '../../utils/directus-config';
|
|
17
|
+
import type { BaseLayerConfig } from './types';
|
|
18
|
+
import { defaultBasemaps, getBasemap } from './defaultBasemaps';
|
|
19
|
+
import { fetchData } from '../../utils/data-fetcher';
|
|
20
|
+
import { dataToGeoJson, parseStringTemplate, filterObjectToPredicate, searchQueryToMapLibreFilter, searchQueryToPredicate } from './utils';
|
|
21
|
+
import type { FeatureCollection } from 'geojson';
|
|
22
|
+
import type { CircleLayerSpecification } from 'maplibre-gl';
|
|
23
|
+
import type { SearchQuery } from './types';
|
|
24
|
+
|
|
25
|
+
// Default base layer (OSM)
|
|
26
|
+
const DEFAULT_BASE_LAYERS: BaseLayerConfig[] = [defaultBasemaps.OSM];
|
|
27
|
+
|
|
28
|
+
export function Map({
|
|
29
|
+
height = '600px',
|
|
30
|
+
center = '0,0,2',
|
|
31
|
+
mapStyle,
|
|
32
|
+
styleOverrides,
|
|
33
|
+
baseLayers = DEFAULT_BASE_LAYERS,
|
|
34
|
+
vectorLayers = [],
|
|
35
|
+
geolocateControl,
|
|
36
|
+
fullscreenControl,
|
|
37
|
+
navigationControl = 'top-left',
|
|
38
|
+
scaleControl,
|
|
39
|
+
layerControl = 'top-right',
|
|
40
|
+
geojson,
|
|
41
|
+
csv,
|
|
42
|
+
json,
|
|
43
|
+
directus,
|
|
44
|
+
}: MapProps) {
|
|
45
|
+
const [lng, lat, zoom] = center.split(',').map(Number);
|
|
46
|
+
const [activeBaseLayer, setActiveBaseLayer] = useState<number>(0);
|
|
47
|
+
const [layersData, setLayersData] = useState<Record<string, FeatureCollection>>({});
|
|
48
|
+
const [hoveredFeature, setHoveredFeature] = useState<any>(null);
|
|
49
|
+
const [vectorLayerVisibility, setVectorLayerVisibility] = useState<Record<string, boolean>>({});
|
|
50
|
+
const [mapLoaded, setMapLoaded] = useState(false);
|
|
51
|
+
const mapRef = useRef<MapRef>(null);
|
|
52
|
+
const loadedLayers = useRef<Set<string>>(new Set());
|
|
53
|
+
const layerControlRef = useRef<LayerControlIControl | null>(null);
|
|
54
|
+
const [parsedStyleLayers, setParsedStyleLayers] = useState<{
|
|
55
|
+
baseLayers: BaseLayerConfig[];
|
|
56
|
+
vectorLayers: VectorLayerConfig[];
|
|
57
|
+
}>({ baseLayers: [], vectorLayers: [] });
|
|
58
|
+
const [mergedMapStyle, setMergedMapStyle] = useState<any>(null);
|
|
59
|
+
const [layerExpansions, setLayerExpansions] = useState<Record<string, {
|
|
60
|
+
popupTemplate?: string;
|
|
61
|
+
fitToContent?: boolean;
|
|
62
|
+
}>>({});
|
|
63
|
+
const [layerSearchQueries, setLayerSearchQueries] = useState<Record<string, SearchQuery>>({});
|
|
64
|
+
|
|
65
|
+
// Parse mapStyle JSON to extract layers and apply overrides
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!mapStyle) {
|
|
68
|
+
setParsedStyleLayers({ baseLayers: [], vectorLayers: [] });
|
|
69
|
+
setMergedMapStyle(null);
|
|
70
|
+
setLayerExpansions({});
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const parseStyle = async () => {
|
|
75
|
+
try {
|
|
76
|
+
let styleObj: any;
|
|
77
|
+
|
|
78
|
+
if (typeof mapStyle === 'string') {
|
|
79
|
+
// Fetch from URL
|
|
80
|
+
const response = await fetch(mapStyle);
|
|
81
|
+
styleObj = await response.json();
|
|
82
|
+
} else {
|
|
83
|
+
styleObj = { ...mapStyle };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const baseLayers: BaseLayerConfig[] = [];
|
|
87
|
+
const vectorLayers: VectorLayerConfig[] = [];
|
|
88
|
+
const expansions: Record<string, any> = {};
|
|
89
|
+
|
|
90
|
+
// Apply styleOverrides to layers
|
|
91
|
+
if (styleOverrides && styleObj.layers) {
|
|
92
|
+
styleObj.layers = styleObj.layers.map((layer: any) => {
|
|
93
|
+
const override = styleOverrides[layer.id];
|
|
94
|
+
if (!override) return layer;
|
|
95
|
+
|
|
96
|
+
// Merge paint and layout properties
|
|
97
|
+
const mergedLayer = { ...layer };
|
|
98
|
+
if (override.paint) {
|
|
99
|
+
mergedLayer.paint = { ...layer.paint, ...override.paint };
|
|
100
|
+
}
|
|
101
|
+
if (override.layout) {
|
|
102
|
+
mergedLayer.layout = { ...layer.layout, ...override.layout };
|
|
103
|
+
}
|
|
104
|
+
if (override.visible !== undefined) {
|
|
105
|
+
mergedLayer.layout = {
|
|
106
|
+
...mergedLayer.layout,
|
|
107
|
+
visibility: override.visible ? 'visible' : 'none'
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Store expanded properties separately
|
|
112
|
+
if (override.popupTemplate || override.fitToContent !== undefined) {
|
|
113
|
+
expansions[layer.id] = {
|
|
114
|
+
popupTemplate: override.popupTemplate,
|
|
115
|
+
fitToContent: override.fitToContent
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return mergedLayer;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
setLayerExpansions(expansions);
|
|
124
|
+
|
|
125
|
+
// Extract raster sources as base layers
|
|
126
|
+
if (styleObj.sources) {
|
|
127
|
+
Object.entries(styleObj.sources).forEach(([sourceId, source]: [string, any]) => {
|
|
128
|
+
if (source.type === 'raster') {
|
|
129
|
+
// Find the layer that uses this source to get metadata
|
|
130
|
+
const layer = styleObj.layers?.find((l: any) => l.source === sourceId);
|
|
131
|
+
const name = layer?.metadata?.label || sourceId;
|
|
132
|
+
|
|
133
|
+
baseLayers.push({
|
|
134
|
+
name,
|
|
135
|
+
url: source.tiles?.[0] || '',
|
|
136
|
+
attribution: source.attribution
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Extract vector layers
|
|
143
|
+
if (styleObj.layers) {
|
|
144
|
+
styleObj.layers.forEach((layer: any) => {
|
|
145
|
+
if (layer.type !== 'raster' && layer.source) {
|
|
146
|
+
const source = styleObj.sources[layer.source];
|
|
147
|
+
if (source && source.type === 'vector') {
|
|
148
|
+
const expansion = expansions[layer.id] || {};
|
|
149
|
+
vectorLayers.push({
|
|
150
|
+
name: layer.metadata?.label || layer.id,
|
|
151
|
+
source: {
|
|
152
|
+
type: 'vector',
|
|
153
|
+
url: source.url || source.tiles?.[0]
|
|
154
|
+
},
|
|
155
|
+
style: layer,
|
|
156
|
+
visible: layer.layout?.visibility !== 'none',
|
|
157
|
+
popupTemplate: expansion.popupTemplate,
|
|
158
|
+
fitToContent: expansion.fitToContent
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
setParsedStyleLayers({ baseLayers, vectorLayers });
|
|
166
|
+
setMergedMapStyle(styleObj);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.error('Failed to parse mapStyle:', error);
|
|
169
|
+
setParsedStyleLayers({ baseLayers: [], vectorLayers: [] });
|
|
170
|
+
setMergedMapStyle(null);
|
|
171
|
+
setLayerExpansions({});
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
parseStyle();
|
|
176
|
+
}, [mapStyle, styleOverrides]);
|
|
177
|
+
|
|
178
|
+
// Resolve baseLayers - support both BaseLayerConfig[] and BasemapKey[], or mixed arrays
|
|
179
|
+
const resolvedBaseLayers = useMemo(() => {
|
|
180
|
+
// If using mapStyle, use parsed base layers from style JSON
|
|
181
|
+
if (mapStyle && parsedStyleLayers.baseLayers.length > 0) {
|
|
182
|
+
return parsedStyleLayers.baseLayers;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!baseLayers || baseLayers.length === 0) return DEFAULT_BASE_LAYERS;
|
|
186
|
+
|
|
187
|
+
// Handle mixed arrays: resolve each element individually
|
|
188
|
+
return baseLayers.map(layer => {
|
|
189
|
+
if (typeof layer === 'string') {
|
|
190
|
+
// It's a basemap key - resolve it
|
|
191
|
+
return getBasemap(layer);
|
|
192
|
+
}
|
|
193
|
+
// It's already a BaseLayerConfig object
|
|
194
|
+
return layer as BaseLayerConfig;
|
|
195
|
+
}).filter((config): config is BaseLayerConfig => config !== undefined);
|
|
196
|
+
}, [baseLayers, mapStyle, parsedStyleLayers.baseLayers]);
|
|
197
|
+
|
|
198
|
+
// Determine implicit source configuration from shorthand props
|
|
199
|
+
const implicitSource = useMemo(() => {
|
|
200
|
+
if (geojson) {
|
|
201
|
+
if (typeof geojson === 'string') {
|
|
202
|
+
return { type: 'geojson', url: geojson } as const;
|
|
203
|
+
} else if ('path' in geojson) {
|
|
204
|
+
return { type: 'geojson', url: geojson.path } as const;
|
|
205
|
+
} else {
|
|
206
|
+
return { type: 'geojson', data: geojson } as const;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (csv) {
|
|
210
|
+
if (typeof csv === 'string') {
|
|
211
|
+
return { type: 'csv', url: csv } as const;
|
|
212
|
+
} else {
|
|
213
|
+
return { type: 'csv', url: csv.path, lng: csv.lng, lat: csv.lat } as const;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (json) return { type: 'json', data: Array.isArray(json) ? json : undefined, url: typeof json === 'string' ? json : undefined } as const;
|
|
217
|
+
if (directus) {
|
|
218
|
+
const config = directusShorthandToConfig({
|
|
219
|
+
table: directus.table,
|
|
220
|
+
queryString: directus.queryString,
|
|
221
|
+
url: directus.url,
|
|
222
|
+
token: directus.token
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Add geoField if it's a map component using directus
|
|
226
|
+
if (config && directus.geoField) {
|
|
227
|
+
config.geoField = directus.geoField;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return config;
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}, [geojson, csv, json, directus]);
|
|
234
|
+
|
|
235
|
+
// Combine explicit vectorLayers with implicit source
|
|
236
|
+
const allVectorLayers = useMemo(() => {
|
|
237
|
+
let layers = [...vectorLayers];
|
|
238
|
+
|
|
239
|
+
// If using mapStyle, add parsed vector layers from style JSON
|
|
240
|
+
if (mapStyle && parsedStyleLayers.vectorLayers.length > 0) {
|
|
241
|
+
layers = [...parsedStyleLayers.vectorLayers, ...layers];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (implicitSource) {
|
|
245
|
+
let layerName = 'Default Layer';
|
|
246
|
+
const layerConfig: any = {
|
|
247
|
+
name: layerName,
|
|
248
|
+
source: implicitSource,
|
|
249
|
+
fitToContent: true, // default to true
|
|
250
|
+
visible: true,
|
|
251
|
+
filter: undefined // will be set below if provided
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// Add style, popup, name, and fitToContent from shorthand config if provided
|
|
255
|
+
if (geojson && typeof geojson === 'object' && 'path' in geojson) {
|
|
256
|
+
if (geojson.name) layerConfig.name = geojson.name;
|
|
257
|
+
if (geojson.style) layerConfig.style = geojson.style;
|
|
258
|
+
if (geojson.popup) layerConfig.popupTemplate = geojson.popup;
|
|
259
|
+
if (geojson.fitToContent !== undefined) layerConfig.fitToContent = geojson.fitToContent;
|
|
260
|
+
if (geojson.filter) {
|
|
261
|
+
// Convert filter object to predicate function if needed
|
|
262
|
+
layerConfig.filter = typeof geojson.filter === 'function'
|
|
263
|
+
? geojson.filter
|
|
264
|
+
: filterObjectToPredicate(geojson.filter);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (csv && typeof csv === 'object' && 'path' in csv) {
|
|
268
|
+
if (csv.name) layerConfig.name = csv.name;
|
|
269
|
+
if (csv.style) layerConfig.style = csv.style;
|
|
270
|
+
if (csv.popup) layerConfig.popupTemplate = csv.popup;
|
|
271
|
+
if (csv.fitToContent !== undefined) layerConfig.fitToContent = csv.fitToContent;
|
|
272
|
+
if (csv.filter) {
|
|
273
|
+
// Convert filter object to predicate function if needed
|
|
274
|
+
layerConfig.filter = typeof csv.filter === 'function'
|
|
275
|
+
? csv.filter
|
|
276
|
+
: filterObjectToPredicate(csv.filter);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (directus) {
|
|
280
|
+
if (directus.name) layerConfig.name = directus.name;
|
|
281
|
+
if (directus.style) layerConfig.style = directus.style;
|
|
282
|
+
if (directus.popup) layerConfig.popupTemplate = directus.popup;
|
|
283
|
+
if (directus.fitToContent !== undefined) layerConfig.fitToContent = directus.fitToContent;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
layers.push(layerConfig);
|
|
287
|
+
}
|
|
288
|
+
return layers;
|
|
289
|
+
}, [vectorLayers, implicitSource, geojson, csv, mapStyle, parsedStyleLayers.vectorLayers]);
|
|
290
|
+
|
|
291
|
+
// Initialize and update visibility when layers change
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
setVectorLayerVisibility(prev => {
|
|
294
|
+
const newVisibility: Record<string, boolean> = { ...prev };
|
|
295
|
+
let hasChanges = false;
|
|
296
|
+
|
|
297
|
+
allVectorLayers.forEach(layer => {
|
|
298
|
+
const layerId = `layer-${layer.name.replace(/\s+/g, '-')}`;
|
|
299
|
+
|
|
300
|
+
if (newVisibility[layerId] === undefined) {
|
|
301
|
+
newVisibility[layerId] = layer.visible !== false;
|
|
302
|
+
hasChanges = true;
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
return hasChanges ? newVisibility : prev;
|
|
307
|
+
});
|
|
308
|
+
}, [allVectorLayers]);
|
|
309
|
+
|
|
310
|
+
// Handle vector layer visibility toggle
|
|
311
|
+
const handleVectorLayerToggle = (layerId: string) => {
|
|
312
|
+
setVectorLayerVisibility(prev => ({
|
|
313
|
+
...prev,
|
|
314
|
+
[layerId]: !prev[layerId]
|
|
315
|
+
}));
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// Handle search for vector layers
|
|
319
|
+
const handleLayerSearch = (layerId: string, query: SearchQuery) => {
|
|
320
|
+
setLayerSearchQueries(prev => ({
|
|
321
|
+
...prev,
|
|
322
|
+
[layerId]: query
|
|
323
|
+
}));
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// Load data for all vector layers
|
|
327
|
+
useEffect(() => {
|
|
328
|
+
const loadAllLayers = async () => {
|
|
329
|
+
const newLayersData: Record<string, FeatureCollection> = {};
|
|
330
|
+
const layersToLoad: typeof allVectorLayers = [];
|
|
331
|
+
|
|
332
|
+
// Check which layers need to be loaded
|
|
333
|
+
for (const layer of allVectorLayers) {
|
|
334
|
+
// Vector-tile sources (parsed from a mapStyle JSON) are rendered
|
|
335
|
+
// natively by MapLibre from the style's own tiles/sources — they
|
|
336
|
+
// aren't rows to fetch, so skip them here to avoid a spurious
|
|
337
|
+
// "unsupported source type" error on every load.
|
|
338
|
+
if (layer.source.type === 'vector') continue;
|
|
339
|
+
|
|
340
|
+
const layerKey = JSON.stringify(layer.source);
|
|
341
|
+
if (!loadedLayers.current.has(layerKey) && layer.visible !== false) {
|
|
342
|
+
layersToLoad.push(layer);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Only proceed if there are new layers to load
|
|
347
|
+
if (layersToLoad.length === 0) return;
|
|
348
|
+
|
|
349
|
+
for (const layer of layersToLoad) {
|
|
350
|
+
try {
|
|
351
|
+
const data = await fetchData(layer.source);
|
|
352
|
+
|
|
353
|
+
// Extract custom column names from CSV source if provided
|
|
354
|
+
const customLng = layer.source.type === 'csv' ? layer.source.lng : undefined;
|
|
355
|
+
const customLat = layer.source.type === 'csv' ? layer.source.lat : undefined;
|
|
356
|
+
|
|
357
|
+
// Extract geoField from Directus source if provided
|
|
358
|
+
const geoField = layer.source.type === 'directus' ? layer.source.geoField : undefined;
|
|
359
|
+
|
|
360
|
+
let geoJson = dataToGeoJson(data, customLng, customLat, geoField);
|
|
361
|
+
|
|
362
|
+
// Apply client-side filter if provided
|
|
363
|
+
if (layer.filter && typeof layer.filter === 'function') {
|
|
364
|
+
geoJson = {
|
|
365
|
+
...geoJson,
|
|
366
|
+
features: geoJson.features.filter(layer.filter)
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
newLayersData[layer.name] = geoJson;
|
|
371
|
+
|
|
372
|
+
// Mark this layer as loaded
|
|
373
|
+
const layerKey = JSON.stringify(layer.source);
|
|
374
|
+
loadedLayers.current.add(layerKey);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
console.error(`Failed to load layer "${layer.name}":`, err);
|
|
377
|
+
// Continue loading other layers even if one fails
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Only update state if we actually loaded new data
|
|
382
|
+
if (Object.keys(newLayersData).length > 0) {
|
|
383
|
+
setLayersData(prev => ({ ...prev, ...newLayersData }));
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
if (allVectorLayers.length > 0 && typeof window !== 'undefined') {
|
|
388
|
+
loadAllLayers();
|
|
389
|
+
}
|
|
390
|
+
}, [allVectorLayers]);
|
|
391
|
+
|
|
392
|
+
// Handle fitToContent separately when map is loaded and data is ready
|
|
393
|
+
useEffect(() => {
|
|
394
|
+
const map = mapRef.current?.getMap();
|
|
395
|
+
if (!map || !mapLoaded || Object.keys(layersData).length === 0) return;
|
|
396
|
+
|
|
397
|
+
// Find first layer with fitToContent
|
|
398
|
+
const layerToFit = allVectorLayers.find(layer =>
|
|
399
|
+
layer.fitToContent && layersData[layer.name] && layersData[layer.name].features.length > 0
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
if (!layerToFit) return;
|
|
403
|
+
|
|
404
|
+
const geoJson = layersData[layerToFit.name];
|
|
405
|
+
|
|
406
|
+
// Small delay to ensure map is fully ready
|
|
407
|
+
setTimeout(() => {
|
|
408
|
+
const coordinates = geoJson.features.flatMap(f => {
|
|
409
|
+
const geom = f.geometry;
|
|
410
|
+
if (geom.type === 'Point') {
|
|
411
|
+
return [geom.coordinates];
|
|
412
|
+
} else if (geom.type === 'LineString') {
|
|
413
|
+
return geom.coordinates;
|
|
414
|
+
} else if (geom.type === 'Polygon') {
|
|
415
|
+
return geom.coordinates[0];
|
|
416
|
+
} else if (geom.type === 'MultiPoint') {
|
|
417
|
+
return geom.coordinates;
|
|
418
|
+
} else if (geom.type === 'MultiLineString') {
|
|
419
|
+
return geom.coordinates.flat();
|
|
420
|
+
} else if (geom.type === 'MultiPolygon') {
|
|
421
|
+
return geom.coordinates.map(p => p[0]).flat();
|
|
422
|
+
}
|
|
423
|
+
return [];
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
if (coordinates.length > 0) {
|
|
427
|
+
const lons = coordinates.map(c => c[0]);
|
|
428
|
+
const lats = coordinates.map(c => c[1]);
|
|
429
|
+
const bounds: [[number, number], [number, number]] = [
|
|
430
|
+
[Math.min(...lons), Math.min(...lats)],
|
|
431
|
+
[Math.max(...lons), Math.max(...lats)]
|
|
432
|
+
];
|
|
433
|
+
map.fitBounds(bounds, { padding: 50, duration: 1000 });
|
|
434
|
+
}
|
|
435
|
+
}, 100);
|
|
436
|
+
}, [mapLoaded, layersData, allVectorLayers]);
|
|
437
|
+
|
|
438
|
+
// Add LayerControl as a proper IControl
|
|
439
|
+
useEffect(() => {
|
|
440
|
+
const map = mapRef.current?.getMap();
|
|
441
|
+
if (!map || !mapLoaded || !layerControl) return;
|
|
442
|
+
|
|
443
|
+
// Show layer control if we have base layers or vector layers
|
|
444
|
+
const hasLayers = resolvedBaseLayers.length > 0 || allVectorLayers.length > 0;
|
|
445
|
+
if (!hasLayers) return;
|
|
446
|
+
|
|
447
|
+
const position = typeof layerControl === 'string' ? layerControl : 'top-right';
|
|
448
|
+
|
|
449
|
+
// Create and add control
|
|
450
|
+
const control = new LayerControlIControl({
|
|
451
|
+
baseLayers: resolvedBaseLayers,
|
|
452
|
+
activeBaseLayer,
|
|
453
|
+
onBaseLayerChange: setActiveBaseLayer,
|
|
454
|
+
vectorLayers: allVectorLayers.map(layer => ({
|
|
455
|
+
...layer,
|
|
456
|
+
id: `layer-${layer.name.replace(/\s+/g, '-')}`
|
|
457
|
+
})),
|
|
458
|
+
vectorLayerVisibility,
|
|
459
|
+
onVectorLayerToggle: handleVectorLayerToggle,
|
|
460
|
+
onLayerSearch: handleLayerSearch,
|
|
461
|
+
layerSearchQueries
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
layerControlRef.current = control;
|
|
465
|
+
map.addControl(control, position);
|
|
466
|
+
|
|
467
|
+
return () => {
|
|
468
|
+
if (layerControlRef.current && map) {
|
|
469
|
+
setTimeout(() => {
|
|
470
|
+
if (layerControlRef.current && map) {
|
|
471
|
+
map.removeControl(layerControlRef.current);
|
|
472
|
+
}
|
|
473
|
+
}, 0);
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
}, [mapLoaded, layerControl, resolvedBaseLayers, allVectorLayers.length, handleLayerSearch]);
|
|
477
|
+
|
|
478
|
+
// Update layer control props when they change
|
|
479
|
+
useEffect(() => {
|
|
480
|
+
if (layerControlRef.current) {
|
|
481
|
+
layerControlRef.current.updateProps({
|
|
482
|
+
baseLayers: resolvedBaseLayers,
|
|
483
|
+
activeBaseLayer,
|
|
484
|
+
onBaseLayerChange: setActiveBaseLayer,
|
|
485
|
+
vectorLayers: allVectorLayers.map(layer => ({
|
|
486
|
+
...layer,
|
|
487
|
+
id: `layer-${layer.name.replace(/\s+/g, '-')}`
|
|
488
|
+
})),
|
|
489
|
+
vectorLayerVisibility,
|
|
490
|
+
onVectorLayerToggle: handleVectorLayerToggle,
|
|
491
|
+
onLayerSearch: handleLayerSearch,
|
|
492
|
+
layerSearchQueries
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
}, [resolvedBaseLayers, activeBaseLayer, allVectorLayers, vectorLayerVisibility, handleLayerSearch, layerSearchQueries]);
|
|
496
|
+
|
|
497
|
+
// Control visibility of layers from style JSON
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
const map = mapRef.current?.getMap();
|
|
500
|
+
if (!map || !mapLoaded || !mapStyle) return;
|
|
501
|
+
|
|
502
|
+
// Update visibility for vector layers from parsed style
|
|
503
|
+
parsedStyleLayers.vectorLayers.forEach(layer => {
|
|
504
|
+
const layerId = `layer-${layer.name.replace(/\s+/g, '-')}`;
|
|
505
|
+
const isVisible = vectorLayerVisibility[layerId] !== false;
|
|
506
|
+
|
|
507
|
+
// The original layer ID from the style JSON
|
|
508
|
+
const styleLayerId = layer.style?.id;
|
|
509
|
+
if (styleLayerId && map.getLayer(styleLayerId)) {
|
|
510
|
+
map.setLayoutProperty(
|
|
511
|
+
styleLayerId,
|
|
512
|
+
'visibility',
|
|
513
|
+
isVisible ? 'visible' : 'none'
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
}, [vectorLayerVisibility, mapLoaded, mapStyle, parsedStyleLayers.vectorLayers]);
|
|
518
|
+
|
|
519
|
+
// Handle fitToContent for layers with expansions
|
|
520
|
+
useEffect(() => {
|
|
521
|
+
const map = mapRef.current?.getMap();
|
|
522
|
+
if (!map || !mapLoaded || !mapStyle || Object.keys(layerExpansions).length === 0) return;
|
|
523
|
+
|
|
524
|
+
// Find first layer with fitToContent: true
|
|
525
|
+
const layerToFit = Object.entries(layerExpansions).find(([_, expansion]) => expansion.fitToContent);
|
|
526
|
+
if (!layerToFit) return;
|
|
527
|
+
|
|
528
|
+
const [layerId] = layerToFit;
|
|
529
|
+
|
|
530
|
+
// Wait for the layer to be fully loaded
|
|
531
|
+
const timeout = setTimeout(() => {
|
|
532
|
+
try {
|
|
533
|
+
// Query rendered features for the layer
|
|
534
|
+
const features = map.queryRenderedFeatures(undefined, { layers: [layerId] });
|
|
535
|
+
|
|
536
|
+
if (features && features.length > 0) {
|
|
537
|
+
const coordinates = features.flatMap(f => {
|
|
538
|
+
const geom = f.geometry;
|
|
539
|
+
if (geom.type === 'Point') {
|
|
540
|
+
return [geom.coordinates];
|
|
541
|
+
} else if (geom.type === 'LineString') {
|
|
542
|
+
return geom.coordinates;
|
|
543
|
+
} else if (geom.type === 'Polygon') {
|
|
544
|
+
return geom.coordinates[0];
|
|
545
|
+
} else if (geom.type === 'MultiPoint') {
|
|
546
|
+
return geom.coordinates;
|
|
547
|
+
} else if (geom.type === 'MultiLineString') {
|
|
548
|
+
return geom.coordinates.flat();
|
|
549
|
+
} else if (geom.type === 'MultiPolygon') {
|
|
550
|
+
return geom.coordinates.map(p => p[0]).flat();
|
|
551
|
+
}
|
|
552
|
+
return [];
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
if (coordinates.length > 0) {
|
|
556
|
+
const lons = coordinates.map(c => c[0]);
|
|
557
|
+
const lats = coordinates.map(c => c[1]);
|
|
558
|
+
const bounds: [[number, number], [number, number]] = [
|
|
559
|
+
[Math.min(...lons), Math.min(...lats)],
|
|
560
|
+
[Math.max(...lons), Math.max(...lats)]
|
|
561
|
+
];
|
|
562
|
+
map.fitBounds(bounds, { padding: 50, duration: 1000 });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
} catch (error) {
|
|
566
|
+
console.error('Failed to fit bounds for layer:', layerId, error);
|
|
567
|
+
}
|
|
568
|
+
}, 1000);
|
|
569
|
+
|
|
570
|
+
return () => clearTimeout(timeout);
|
|
571
|
+
}, [mapLoaded, mapStyle, layerExpansions]);
|
|
572
|
+
|
|
573
|
+
return (
|
|
574
|
+
<div style={{ height, position: 'relative', width: '100%' }}>
|
|
575
|
+
<MapLibreMap
|
|
576
|
+
ref={mapRef}
|
|
577
|
+
initialViewState={{
|
|
578
|
+
longitude: lng,
|
|
579
|
+
latitude: lat,
|
|
580
|
+
zoom: zoom
|
|
581
|
+
}}
|
|
582
|
+
mapStyle={mergedMapStyle || mapStyle as any}
|
|
583
|
+
style={{ width: '100%', height: '100%' }}
|
|
584
|
+
onLoad={() => setMapLoaded(true)}
|
|
585
|
+
onClick={(e: any) => {
|
|
586
|
+
// Check if any vector layer with popup was clicked
|
|
587
|
+
if (e.features && e.features.length > 0) {
|
|
588
|
+
const clickedLayerId = e.features[0].layer?.id;
|
|
589
|
+
|
|
590
|
+
// Check for popup in allVectorLayers (includes parsed style layers)
|
|
591
|
+
const layerConfig = allVectorLayers.find(l => {
|
|
592
|
+
const layerId = `layer-${l.name.replace(/\s+/g, '-')}`;
|
|
593
|
+
return layerId === clickedLayerId;
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
// Also check if the clicked layer has popup expansion from styleOverrides
|
|
597
|
+
const expansion = layerExpansions[clickedLayerId];
|
|
598
|
+
const popupTemplate = layerConfig?.popupTemplate || expansion?.popupTemplate;
|
|
599
|
+
|
|
600
|
+
if (popupTemplate) {
|
|
601
|
+
setHoveredFeature({
|
|
602
|
+
feature: e.features[0],
|
|
603
|
+
lngLat: e.lngLat,
|
|
604
|
+
template: popupTemplate
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}}
|
|
609
|
+
interactiveLayerIds={[
|
|
610
|
+
...allVectorLayers.map(l => `layer-${l.name.replace(/\s+/g, '-')}`),
|
|
611
|
+
...Object.keys(layerExpansions) // Add layer IDs that have expansions
|
|
612
|
+
]}
|
|
613
|
+
>
|
|
614
|
+
{/* Base Layers (only if no full mapStyle is provided) - render ALL but control visibility */}
|
|
615
|
+
{!mapStyle && resolvedBaseLayers.map((layer, index) => (
|
|
616
|
+
<RasterLayerLibre
|
|
617
|
+
key={`basemap-${index}`}
|
|
618
|
+
id={`base-layer-${index}`}
|
|
619
|
+
url={[layer.url]}
|
|
620
|
+
attribution={layer.attribution}
|
|
621
|
+
visible={index === activeBaseLayer}
|
|
622
|
+
/>
|
|
623
|
+
))}
|
|
624
|
+
|
|
625
|
+
{/* Vector Layers from prop - render AFTER base layer so they appear on top */}
|
|
626
|
+
{allVectorLayers.map((layer) => {
|
|
627
|
+
const geoJson = layersData[layer.name];
|
|
628
|
+
const layerId = `layer-${layer.name.replace(/\s+/g, '-')}`;
|
|
629
|
+
const sourceId = `source-${layer.name}`;
|
|
630
|
+
const isVisible = vectorLayerVisibility[layerId] !== false;
|
|
631
|
+
|
|
632
|
+
if (!geoJson) return null;
|
|
633
|
+
|
|
634
|
+
// Apply search filter if present
|
|
635
|
+
const searchQuery = layerSearchQueries[layerId];
|
|
636
|
+
let filteredGeoJson = geoJson;
|
|
637
|
+
|
|
638
|
+
if (searchQuery && searchQuery.filters && searchQuery.filters.length > 0) {
|
|
639
|
+
// Try MapLibre filter first, fall back to client-side filtering
|
|
640
|
+
try {
|
|
641
|
+
const mapLibreFilter = searchQueryToMapLibreFilter(searchQuery);
|
|
642
|
+
if (mapLibreFilter) {
|
|
643
|
+
// Use MapLibre filtering for performance
|
|
644
|
+
filteredGeoJson = geoJson;
|
|
645
|
+
} else {
|
|
646
|
+
// Fall back to client-side filtering
|
|
647
|
+
const predicate = searchQueryToPredicate(searchQuery);
|
|
648
|
+
filteredGeoJson = {
|
|
649
|
+
...geoJson,
|
|
650
|
+
features: geoJson.features.filter(predicate)
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
} catch (error) {
|
|
654
|
+
console.warn('Error applying search filter, falling back to client-side filtering:', error);
|
|
655
|
+
const predicate = searchQueryToPredicate(searchQuery);
|
|
656
|
+
filteredGeoJson = {
|
|
657
|
+
...geoJson,
|
|
658
|
+
features: geoJson.features.filter(predicate)
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const defaultStyle: CircleLayerSpecification = {
|
|
664
|
+
id: layerId,
|
|
665
|
+
type: 'circle',
|
|
666
|
+
source: sourceId,
|
|
667
|
+
paint: {
|
|
668
|
+
'circle-radius': 6,
|
|
669
|
+
'circle-color': '#3b82f6',
|
|
670
|
+
'circle-stroke-width': 2,
|
|
671
|
+
'circle-stroke-color': '#ffffff'
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
// Merge custom style with default, ensuring id and source are preserved
|
|
676
|
+
// Remove source-layer if present (only for vector tiles, not for GeoJSON sources)
|
|
677
|
+
const customStyle = layer.style ? { ...layer.style } : {};
|
|
678
|
+
delete customStyle['source-layer'];
|
|
679
|
+
|
|
680
|
+
const layerStyle = layer.style
|
|
681
|
+
? { ...defaultStyle, ...customStyle, id: layerId, source: sourceId }
|
|
682
|
+
: defaultStyle;
|
|
683
|
+
|
|
684
|
+
// Apply MapLibre filter if using server-side filtering
|
|
685
|
+
const searchQuery2 = layerSearchQueries[layerId];
|
|
686
|
+
const mapLibreFilter = searchQuery2 && searchQuery2.filters.length > 0
|
|
687
|
+
? searchQueryToMapLibreFilter(searchQuery2)
|
|
688
|
+
: undefined;
|
|
689
|
+
|
|
690
|
+
if (mapLibreFilter) {
|
|
691
|
+
layerStyle.filter = mapLibreFilter;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
return (
|
|
695
|
+
<Source key={sourceId} id={sourceId} type="geojson" data={filteredGeoJson}>
|
|
696
|
+
<Layer
|
|
697
|
+
{...layerStyle}
|
|
698
|
+
layout={{
|
|
699
|
+
...layerStyle.layout,
|
|
700
|
+
visibility: isVisible ? 'visible' : 'none'
|
|
701
|
+
}}
|
|
702
|
+
/>
|
|
703
|
+
</Source>
|
|
704
|
+
);
|
|
705
|
+
})}
|
|
706
|
+
|
|
707
|
+
{/* Controls */}
|
|
708
|
+
{geolocateControl && <GeolocateControl position={geolocateControl} />}
|
|
709
|
+
{fullscreenControl && <FullscreenControl position={fullscreenControl} />}
|
|
710
|
+
{navigationControl && <NavigationControl position={navigationControl} />}
|
|
711
|
+
{scaleControl && <ScaleControl position={scaleControl} />}
|
|
712
|
+
|
|
713
|
+
{/* LayerControl is now added via IControl in useEffect */}
|
|
714
|
+
|
|
715
|
+
{/* Popup */}
|
|
716
|
+
{hoveredFeature && (
|
|
717
|
+
<Popup
|
|
718
|
+
longitude={hoveredFeature.lngLat.lng}
|
|
719
|
+
latitude={hoveredFeature.lngLat.lat}
|
|
720
|
+
onClose={() => setHoveredFeature(null)}
|
|
721
|
+
closeOnClick={false}
|
|
722
|
+
>
|
|
723
|
+
<div dangerouslySetInnerHTML={{
|
|
724
|
+
__html: parseStringTemplate(
|
|
725
|
+
hoveredFeature.template,
|
|
726
|
+
hoveredFeature.feature.properties
|
|
727
|
+
)
|
|
728
|
+
}} />
|
|
729
|
+
</Popup>
|
|
730
|
+
)}
|
|
731
|
+
|
|
732
|
+
</MapLibreMap>
|
|
733
|
+
</div>
|
|
734
|
+
);
|
|
735
|
+
}
|