@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.
Files changed (64) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +73 -0
  3. package/components/BSNavbar.tsx +264 -0
  4. package/components/DataTb/DataTb.astro +33 -0
  5. package/components/DataTb/DataTb.tsx +340 -0
  6. package/components/DataTb/DataTbMdx.tsx +75 -0
  7. package/components/DataTb/README.md +275 -0
  8. package/components/DataTb/index.ts +36 -0
  9. package/components/DataTb/sources/ApiSource.tsx +89 -0
  10. package/components/DataTb/sources/CsvSource.tsx +69 -0
  11. package/components/DataTb/sources/DirectusSource.tsx +98 -0
  12. package/components/DataTb/sources/JsonSource.tsx +65 -0
  13. package/components/DataTb/sources/index.ts +17 -0
  14. package/components/DataTb/types.ts +215 -0
  15. package/components/DataTb/utils.ts +186 -0
  16. package/components/Gallery/Gallery.astro +65 -0
  17. package/components/Gallery/Gallery.tsx +136 -0
  18. package/components/Gallery/README.md +118 -0
  19. package/components/Gallery/__tests__/galleryUtils.test.ts +178 -0
  20. package/components/Gallery/galleryUtils.ts +186 -0
  21. package/components/Gallery/index.ts +7 -0
  22. package/components/Gallery/react-photoswipe-gallery.d.ts +26 -0
  23. package/components/Gallery/types.ts +46 -0
  24. package/components/Gallery/virtual-gallery.d.ts +15 -0
  25. package/components/Map/LayerControl.tsx +368 -0
  26. package/components/Map/Map.tsx +735 -0
  27. package/components/Map/MapMdx.tsx +37 -0
  28. package/components/Map/RasterLayerLibre.tsx +33 -0
  29. package/components/Map/Search/SearchOperators.ts +31 -0
  30. package/components/Map/Search/SearchUI.tsx +75 -0
  31. package/components/Map/Search/SearchUIAdvanced.tsx +289 -0
  32. package/components/Map/Search/SearchUISimple.tsx +115 -0
  33. package/components/Map/Search/index.ts +7 -0
  34. package/components/Map/defaultBasemaps.ts +88 -0
  35. package/components/Map/index.ts +6 -0
  36. package/components/Map/types.ts +179 -0
  37. package/components/Map/utils.ts +367 -0
  38. package/components/README.md +62 -0
  39. package/components/Record/Field.tsx +35 -0
  40. package/components/Record/Image.tsx +114 -0
  41. package/components/Record/RecordFetcher.tsx +127 -0
  42. package/components/Record/context.tsx +14 -0
  43. package/components/Record/index.ts +11 -0
  44. package/components/Record/useRecordFetcher.ts +104 -0
  45. package/components/Record/utils.ts +13 -0
  46. package/components/SEO/SEO.astro +132 -0
  47. package/components/TableOfContents/TableOfContents.astro +86 -0
  48. package/components/ZoteroGeoViewer/README.md +195 -0
  49. package/components/ZoteroGeoViewer/ZoteroGeoViewer.astro +26 -0
  50. package/components/ZoteroGeoViewer/ZoteroGeoViewer.tsx +483 -0
  51. package/components/ZoteroGeoViewer/ZoteroRecordsPreview.tsx +185 -0
  52. package/components/ZoteroGeoViewer/index.ts +16 -0
  53. package/components/ZoteroGeoViewer/types.ts +123 -0
  54. package/index.ts +26 -0
  55. package/integrations/__tests__/galleryIntegration.test.ts +70 -0
  56. package/integrations/contentAssetsIntegration.ts +132 -0
  57. package/integrations/directusLoader.ts +98 -0
  58. package/integrations/galleryIntegration.ts +78 -0
  59. package/package.json +56 -0
  60. package/scms.ts +95 -0
  61. package/utils/content.ts +88 -0
  62. package/utils/data-fetcher.ts +128 -0
  63. package/utils/directus-config.ts +160 -0
  64. 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
+ }