@lad-sapienza/scms-core 0.1.0-alpha.7 → 0.1.0-alpha.9

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/README.md CHANGED
@@ -64,7 +64,7 @@ import BSNavbar from '@lad-sapienza/scms-core/components/BSNavbar';
64
64
  import { directusLoader } from '@lad-sapienza/scms-core/integrations/directusLoader';
65
65
  ```
66
66
 
67
- See each component folder's own `README.md` (`components/DataTb`, `components/Gallery`, `components/RedirectPage`, `components/ZoteroGeoViewer`, `components/`) for detailed API docs and examples.
67
+ See each component folder's own `README.md` (`components/DataTb`, `components/Gallery`, `components/Map`, `components/RedirectPage`, `components/ZoteroGeoViewer`, `components/`) for detailed API docs and examples.
68
68
 
69
69
  ## Creating a new site
70
70
 
@@ -3,7 +3,7 @@ import { createRoot, type Root } from 'react-dom/client';
3
3
  import { createPortal } from 'react-dom';
4
4
  import { Layers, Search, X } from 'lucide-react';
5
5
  import type { IControl } from 'maplibre-gl';
6
- import type { BaseLayerConfig, VectorLayerConfig, ControlPosition, SearchInFields, SearchQuery } from './types';
6
+ import type { BaseLayerConfig, OverlayLayerConfig, ControlPosition, SearchInFields, SearchQuery } from './types';
7
7
  import { SearchUI } from './Search';
8
8
  import { useTranslation } from '../i18n';
9
9
 
@@ -11,22 +11,22 @@ export interface LayerControlProps {
11
11
  baseLayers?: BaseLayerConfig[];
12
12
  activeBaseLayer?: number;
13
13
  onBaseLayerChange?: (index: number) => void;
14
- vectorLayers?: Array<VectorLayerConfig & { id: string }>;
15
- vectorLayerVisibility?: Record<string, boolean>;
16
- onVectorLayerToggle?: (layerId: string) => void;
14
+ overlayLayers?: Array<OverlayLayerConfig & { id: string }>;
15
+ overlayLayerVisibility?: Record<string, boolean>;
16
+ onOverlayLayerToggle?: (layerId: string) => void;
17
17
  onLayerSearch?: (layerId: string, query: SearchQuery) => void;
18
18
  layerSearchQueries?: Record<string, SearchQuery>;
19
19
  position?: ControlPosition;
20
20
  }
21
21
 
22
22
  // Internal React component for the control UI
23
- function LayerControlUI({
24
- baseLayers = [],
25
- activeBaseLayer = 0,
23
+ function LayerControlUI({
24
+ baseLayers = [],
25
+ activeBaseLayer = 0,
26
26
  onBaseLayerChange,
27
- vectorLayers = [],
28
- vectorLayerVisibility = {},
29
- onVectorLayerToggle,
27
+ overlayLayers = [],
28
+ overlayLayerVisibility = {},
29
+ onOverlayLayerToggle,
30
30
  onLayerSearch,
31
31
  layerSearchQueries = {},
32
32
  }: Omit<LayerControlProps, 'position'>) {
@@ -36,7 +36,7 @@ function LayerControlUI({
36
36
  const radioGroupId = React.useId();
37
37
  const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
38
38
 
39
- const hasContent = baseLayers.length > 0 || vectorLayers.length > 0;
39
+ const hasContent = baseLayers.length > 0 || overlayLayers.length > 0;
40
40
  if (!hasContent) return null;
41
41
 
42
42
  const handleMouseEnter = () => {
@@ -150,20 +150,20 @@ function LayerControlUI({
150
150
  whiteSpace: 'nowrap'
151
151
  }}>
152
152
 
153
- {/* Vector Layers Section */}
154
- {vectorLayers.length > 0 && (
153
+ {/* Overlay Layers Section */}
154
+ {overlayLayers.length > 0 && (
155
155
  <div style={{ marginBottom: 10 }}>
156
- {vectorLayers.map((layer) => (
156
+ {overlayLayers.map((layer) => (
157
157
  <div key={layer.id} style={{ marginBottom: 6, display: 'flex', alignItems: 'center' }}>
158
158
  <input
159
159
  type="checkbox"
160
- id={`vector-layer-${layer.id}`}
161
- checked={vectorLayerVisibility[layer.id] !== false}
162
- onChange={() => onVectorLayerToggle?.(layer.id)}
160
+ id={`overlay-layer-${layer.id}`}
161
+ checked={overlayLayerVisibility[layer.id] !== false}
162
+ onChange={() => onOverlayLayerToggle?.(layer.id)}
163
163
  style={{ marginRight: 8, cursor: 'pointer' }}
164
164
  />
165
- <label
166
- htmlFor={`vector-layer-${layer.id}`}
165
+ <label
166
+ htmlFor={`overlay-layer-${layer.id}`}
167
167
  style={{ cursor: 'pointer', fontSize: '12px', userSelect: 'none', flex: 1 }}
168
168
  >
169
169
  {layer.name}
@@ -321,7 +321,7 @@ export class LayerControlClass implements IControl {
321
321
  // Compare props to avoid unnecessary re-renders
322
322
  const hasChanged =
323
323
  JSON.stringify(this.props.layerSearchQueries) !== JSON.stringify(newProps.layerSearchQueries) ||
324
- JSON.stringify(this.props.vectorLayerVisibility) !== JSON.stringify(newProps.vectorLayerVisibility) ||
324
+ JSON.stringify(this.props.overlayLayerVisibility) !== JSON.stringify(newProps.overlayLayerVisibility) ||
325
325
  this.props.activeBaseLayer !== newProps.activeBaseLayer;
326
326
 
327
327
  if (hasChanged) {
@@ -354,7 +354,7 @@ export function LayerControl(props: LayerControlProps) {
354
354
  }
355
355
  }
356
356
  previousPropsRef.current = props;
357
- }, [props.layerSearchQueries, props.vectorLayerVisibility, props.activeBaseLayer]);
357
+ }, [props.layerSearchQueries, props.overlayLayerVisibility, props.activeBaseLayer]);
358
358
 
359
359
  // Create control instance only once
360
360
  if (!controlRef.current) {
@@ -10,14 +10,15 @@ import MapLibreMap, {
10
10
  type MapRef
11
11
  } from '@vis.gl/react-maplibre';
12
12
  import 'maplibre-gl/dist/maplibre-gl.css';
13
- import type { MapProps, VectorLayerConfig } from './types';
13
+ import type { MapProps, OverlayLayerConfig } from './types';
14
14
  import { RasterLayerLibre } from './RasterLayerLibre';
15
15
  import { LayerControlIControl } from './LayerControl';
16
16
  import { directusShorthandToConfig } from '../../utils/directus-config';
17
17
  import type { BaseLayerConfig } from './types';
18
18
  import { defaultBasemaps, getBasemap } from './defaultBasemaps';
19
19
  import { fetchData } from '../../utils/data-fetcher';
20
- import { dataToGeoJson, parseStringTemplate, filterObjectToPredicate, searchQueryToMapLibreFilter, searchQueryToPredicate } from './utils';
20
+ import type { SourceConfig } from '../../utils/data-fetcher';
21
+ import { dataToGeoJson, parseStringTemplate, filterObjectToPredicate, searchQueryToMapLibreFilter, searchQueryToPredicate, expandXyzSubdomains, buildWmsUrl } from './utils';
21
22
  import { useTranslation } from '../i18n';
22
23
  import type { FeatureCollection } from 'geojson';
23
24
  import type { CircleLayerSpecification } from 'maplibre-gl';
@@ -26,13 +27,32 @@ import type { SearchQuery } from './types';
26
27
  // Default base layer (OSM)
27
28
  const DEFAULT_BASE_LAYERS: BaseLayerConfig[] = [defaultBasemaps.OSM];
28
29
 
30
+ // Stable identity for the "no explicit overlayLayers prop" case. A literal
31
+ // `overlayLayers = []` default in the destructure below allocates a fresh
32
+ // array on every render whenever the caller uses only a shorthand prop
33
+ // (`geojson` / `csv` / `json` / `directus`) and omits `overlayLayers`. That
34
+ // fresh array is a dependency of the `allOverlayLayers` useMemo, so the memo
35
+ // recomputes every render → the data-loading effect keyed on it re-runs →
36
+ // `setLayersData` → re-render → loop ("Maximum update depth exceeded", plus a
37
+ // `fitToContent` fitBounds animation that restarts forever). Hoisting the
38
+ // empty array to a module constant (same pattern as DEFAULT_BASE_LAYERS)
39
+ // keeps its reference stable across renders.
40
+ const EMPTY_OVERLAY_LAYERS: OverlayLayerConfig[] = [];
41
+
42
+ // Vector-tile, xyz and wms sources are rendered natively/via RasterLayerLibre
43
+ // and never go through fetchData — this narrows the union so TS knows
44
+ // `layer.source` is a plain SourceConfig wherever this guard is used.
45
+ function isFetchableLayer(layer: OverlayLayerConfig): layer is OverlayLayerConfig & { source: SourceConfig } {
46
+ return layer.source.type !== 'vector' && layer.source.type !== 'xyz' && layer.source.type !== 'wms';
47
+ }
48
+
29
49
  export function Map({
30
50
  height = '600px',
31
51
  center = '0,0,2',
32
52
  mapStyle,
33
53
  styleOverrides,
34
54
  baseLayers = DEFAULT_BASE_LAYERS,
35
- vectorLayers = [],
55
+ overlayLayers = EMPTY_OVERLAY_LAYERS,
36
56
  geolocateControl,
37
57
  fullscreenControl,
38
58
  navigationControl = 'top-left',
@@ -48,15 +68,15 @@ export function Map({
48
68
  const [activeBaseLayer, setActiveBaseLayer] = useState<number>(0);
49
69
  const [layersData, setLayersData] = useState<Record<string, FeatureCollection>>({});
50
70
  const [hoveredFeature, setHoveredFeature] = useState<any>(null);
51
- const [vectorLayerVisibility, setVectorLayerVisibility] = useState<Record<string, boolean>>({});
71
+ const [overlayLayerVisibility, setOverlayLayerVisibility] = useState<Record<string, boolean>>({});
52
72
  const [mapLoaded, setMapLoaded] = useState(false);
53
73
  const mapRef = useRef<MapRef>(null);
54
- const loadedLayers = useRef<Set<string>>(new Set());
74
+ const rawSourceCache = useRef<globalThis.Map<string, FeatureCollection>>(new globalThis.Map());
55
75
  const layerControlRef = useRef<LayerControlIControl | null>(null);
56
76
  const [parsedStyleLayers, setParsedStyleLayers] = useState<{
57
77
  baseLayers: BaseLayerConfig[];
58
- vectorLayers: VectorLayerConfig[];
59
- }>({ baseLayers: [], vectorLayers: [] });
78
+ overlayLayers: OverlayLayerConfig[];
79
+ }>({ baseLayers: [], overlayLayers: [] });
60
80
  const [mergedMapStyle, setMergedMapStyle] = useState<any>(null);
61
81
  const [layerExpansions, setLayerExpansions] = useState<Record<string, {
62
82
  popupTemplate?: string;
@@ -67,7 +87,7 @@ export function Map({
67
87
  // Parse mapStyle JSON to extract layers and apply overrides
68
88
  useEffect(() => {
69
89
  if (!mapStyle) {
70
- setParsedStyleLayers({ baseLayers: [], vectorLayers: [] });
90
+ setParsedStyleLayers({ baseLayers: [], overlayLayers: [] });
71
91
  setMergedMapStyle(null);
72
92
  setLayerExpansions({});
73
93
  return;
@@ -86,7 +106,7 @@ export function Map({
86
106
  }
87
107
 
88
108
  const baseLayers: BaseLayerConfig[] = [];
89
- const vectorLayers: VectorLayerConfig[] = [];
109
+ const overlayLayers: OverlayLayerConfig[] = [];
90
110
  const expansions: Record<string, any> = {};
91
111
 
92
112
  // Apply styleOverrides to layers
@@ -148,7 +168,7 @@ export function Map({
148
168
  const source = styleObj.sources[layer.source];
149
169
  if (source && source.type === 'vector') {
150
170
  const expansion = expansions[layer.id] || {};
151
- vectorLayers.push({
171
+ overlayLayers.push({
152
172
  name: layer.metadata?.label || layer.id,
153
173
  source: {
154
174
  type: 'vector',
@@ -164,11 +184,11 @@ export function Map({
164
184
  });
165
185
  }
166
186
 
167
- setParsedStyleLayers({ baseLayers, vectorLayers });
187
+ setParsedStyleLayers({ baseLayers, overlayLayers });
168
188
  setMergedMapStyle(styleObj);
169
189
  } catch (error) {
170
190
  console.error('Failed to parse mapStyle:', error);
171
- setParsedStyleLayers({ baseLayers: [], vectorLayers: [] });
191
+ setParsedStyleLayers({ baseLayers: [], overlayLayers: [] });
172
192
  setMergedMapStyle(null);
173
193
  setLayerExpansions({});
174
194
  }
@@ -234,13 +254,13 @@ export function Map({
234
254
  return null;
235
255
  }, [geojson, csv, json, directus]);
236
256
 
237
- // Combine explicit vectorLayers with implicit source
238
- const allVectorLayers = useMemo(() => {
239
- let layers = [...vectorLayers];
240
-
257
+ // Combine explicit overlayLayers with implicit source
258
+ const allOverlayLayers = useMemo(() => {
259
+ let layers = [...overlayLayers];
260
+
241
261
  // If using mapStyle, add parsed vector layers from style JSON
242
- if (mapStyle && parsedStyleLayers.vectorLayers.length > 0) {
243
- layers = [...parsedStyleLayers.vectorLayers, ...layers];
262
+ if (mapStyle && parsedStyleLayers.overlayLayers.length > 0) {
263
+ layers = [...parsedStyleLayers.overlayLayers, ...layers];
244
264
  }
245
265
 
246
266
  if (implicitSource) {
@@ -288,28 +308,28 @@ export function Map({
288
308
  layers.push(layerConfig);
289
309
  }
290
310
  return layers;
291
- }, [vectorLayers, implicitSource, geojson, csv, mapStyle, parsedStyleLayers.vectorLayers]);
311
+ }, [overlayLayers, implicitSource, geojson, csv, mapStyle, parsedStyleLayers.overlayLayers]);
292
312
 
293
313
  // Initialize and update visibility when layers change
294
314
  useEffect(() => {
295
- setVectorLayerVisibility(prev => {
315
+ setOverlayLayerVisibility(prev => {
296
316
  const newVisibility: Record<string, boolean> = { ...prev };
297
317
  let hasChanges = false;
298
-
299
- allVectorLayers.forEach(layer => {
318
+
319
+ allOverlayLayers.forEach(layer => {
300
320
  const layerId = `layer-${layer.name.replace(/\s+/g, '-')}`;
301
-
321
+
302
322
  if (newVisibility[layerId] === undefined) {
303
323
  newVisibility[layerId] = layer.visible !== false;
304
324
  hasChanges = true;
305
325
  }
306
326
  });
307
-
327
+
308
328
  return hasChanges ? newVisibility : prev;
309
329
  });
310
- }, [allVectorLayers]);
330
+ }, [allOverlayLayers]);
311
331
 
312
- // Handle vector layer visibility toggle
332
+ // Handle overlay layer visibility toggle
313
333
  //
314
334
  // Wrapped in useCallback (both here and below) so its identity stays
315
335
  // stable across renders — it's one of two functions passed into the
@@ -324,14 +344,14 @@ export function Map({
324
344
  // created (correct) control was the one actually getting removed a tick
325
345
  // later, leaving the stale first control (still showing the original
326
346
  // basemap selected) as the only one left on the map.
327
- const handleVectorLayerToggle = useCallback((layerId: string) => {
328
- setVectorLayerVisibility(prev => ({
347
+ const handleOverlayLayerToggle = useCallback((layerId: string) => {
348
+ setOverlayLayerVisibility(prev => ({
329
349
  ...prev,
330
350
  [layerId]: !prev[layerId]
331
351
  }));
332
352
  }, []);
333
353
 
334
- // Handle search for vector layers — see handleVectorLayerToggle above.
354
+ // Handle search for overlay layers — see handleOverlayLayerToggle above.
335
355
  const handleLayerSearch = useCallback((layerId: string, query: SearchQuery) => {
336
356
  setLayerSearchQueries(prev => ({
337
357
  ...prev,
@@ -339,71 +359,68 @@ export function Map({
339
359
  }));
340
360
  }, []);
341
361
 
342
- // Load data for all vector layers
362
+ // Load data for all fetchable overlay layers (vector-tile, xyz and wms
363
+ // sources are rendered natively/via RasterLayerLibre and never reach
364
+ // fetchData). Raw fetched+converted GeoJSON is cached per unique source
365
+ // (rawSourceCache, keyed by JSON.stringify(source)) so N layer entries
366
+ // sharing the same source — differing only in filter/style — fetch once;
367
+ // each layer's own filter is applied on top of the cached raw data.
368
+ // Loading no longer depends on `visible`: visibility only controls
369
+ // display (see the render below), not whether data is ever fetched —
370
+ // otherwise a layer started as visible:false could never be loaded even
371
+ // after the user later checks it on, since this effect only re-runs when
372
+ // allOverlayLayers itself changes, not on a visibility toggle.
343
373
  useEffect(() => {
344
374
  const loadAllLayers = async () => {
345
- const newLayersData: Record<string, FeatureCollection> = {};
346
- const layersToLoad: typeof allVectorLayers = [];
347
-
348
- // Check which layers need to be loaded
349
- for (const layer of allVectorLayers) {
350
- // Vector-tile sources (parsed from a mapStyle JSON) are rendered
351
- // natively by MapLibre from the style's own tiles/sources — they
352
- // aren't rows to fetch, so skip them here to avoid a spurious
353
- // "unsupported source type" error on every load.
354
- if (layer.source.type === 'vector') continue;
375
+ const fetchableLayers = allOverlayLayers.filter(isFetchableLayer);
376
+ if (fetchableLayers.length === 0) return;
355
377
 
378
+ const seen = new Set<string>();
379
+ for (const layer of fetchableLayers) {
356
380
  const layerKey = JSON.stringify(layer.source);
357
- if (!loadedLayers.current.has(layerKey) && layer.visible !== false) {
358
- layersToLoad.push(layer);
359
- }
360
- }
361
-
362
- // Only proceed if there are new layers to load
363
- if (layersToLoad.length === 0) return;
364
-
365
- for (const layer of layersToLoad) {
381
+ if (rawSourceCache.current.has(layerKey) || seen.has(layerKey)) continue;
382
+ seen.add(layerKey);
383
+
366
384
  try {
367
385
  const data = await fetchData(layer.source);
368
-
386
+
369
387
  // Extract custom column names from CSV source if provided
370
388
  const customLng = layer.source.type === 'csv' ? layer.source.lng : undefined;
371
389
  const customLat = layer.source.type === 'csv' ? layer.source.lat : undefined;
372
-
390
+
373
391
  // Extract geoField from Directus source if provided
374
392
  const geoField = layer.source.type === 'directus' ? layer.source.geoField : undefined;
375
-
376
- let geoJson = dataToGeoJson(data, customLng, customLat, geoField);
377
-
378
- // Apply client-side filter if provided
379
- if (layer.filter && typeof layer.filter === 'function') {
380
- geoJson = {
381
- ...geoJson,
382
- features: geoJson.features.filter(layer.filter)
383
- };
384
- }
385
-
386
- newLayersData[layer.name] = geoJson;
387
-
388
- // Mark this layer as loaded
389
- const layerKey = JSON.stringify(layer.source);
390
- loadedLayers.current.add(layerKey);
393
+
394
+ const geoJson = dataToGeoJson(data, customLng, customLat, geoField);
395
+ rawSourceCache.current.set(layerKey, geoJson);
391
396
  } catch (err) {
392
397
  console.error(`Failed to load layer "${layer.name}":`, err);
393
398
  // Continue loading other layers even if one fails
394
399
  }
395
400
  }
396
-
397
- // Only update state if we actually loaded new data
401
+
402
+ // Project cached raw data into each layer's own (filtered) view —
403
+ // cheap client-side work, no network, safe to redo whenever
404
+ // allOverlayLayers changes (e.g. a filter edit).
405
+ const newLayersData: Record<string, FeatureCollection> = {};
406
+ for (const layer of fetchableLayers) {
407
+ const raw = rawSourceCache.current.get(JSON.stringify(layer.source));
408
+ if (!raw) continue; // fetch failed for this source
409
+
410
+ newLayersData[layer.name] = layer.filter && typeof layer.filter === 'function'
411
+ ? { ...raw, features: raw.features.filter(layer.filter) }
412
+ : raw;
413
+ }
414
+
398
415
  if (Object.keys(newLayersData).length > 0) {
399
416
  setLayersData(prev => ({ ...prev, ...newLayersData }));
400
417
  }
401
418
  };
402
-
403
- if (allVectorLayers.length > 0 && typeof window !== 'undefined') {
419
+
420
+ if (allOverlayLayers.length > 0 && typeof window !== 'undefined') {
404
421
  loadAllLayers();
405
422
  }
406
- }, [allVectorLayers]);
423
+ }, [allOverlayLayers]);
407
424
 
408
425
  // Handle fitToContent separately when map is loaded and data is ready
409
426
  useEffect(() => {
@@ -411,7 +428,7 @@ export function Map({
411
428
  if (!map || !mapLoaded || Object.keys(layersData).length === 0) return;
412
429
 
413
430
  // Find first layer with fitToContent
414
- const layerToFit = allVectorLayers.find(layer =>
431
+ const layerToFit = allOverlayLayers.find(layer =>
415
432
  layer.fitToContent && layersData[layer.name] && layersData[layer.name].features.length > 0
416
433
  );
417
434
 
@@ -449,30 +466,30 @@ export function Map({
449
466
  map.fitBounds(bounds, { padding: 50, duration: 1000 });
450
467
  }
451
468
  }, 100);
452
- }, [mapLoaded, layersData, allVectorLayers]);
469
+ }, [mapLoaded, layersData, allOverlayLayers]);
453
470
 
454
471
  // Add LayerControl as a proper IControl
455
472
  useEffect(() => {
456
473
  const map = mapRef.current?.getMap();
457
474
  if (!map || !mapLoaded || !layerControl) return;
458
-
459
- // Show layer control if we have base layers or vector layers
460
- const hasLayers = resolvedBaseLayers.length > 0 || allVectorLayers.length > 0;
475
+
476
+ // Show layer control if we have base layers or overlay layers
477
+ const hasLayers = resolvedBaseLayers.length > 0 || allOverlayLayers.length > 0;
461
478
  if (!hasLayers) return;
462
479
 
463
480
  const position = typeof layerControl === 'string' ? layerControl : 'top-right';
464
-
481
+
465
482
  // Create and add control
466
483
  const control = new LayerControlIControl({
467
484
  baseLayers: resolvedBaseLayers,
468
485
  activeBaseLayer,
469
486
  onBaseLayerChange: setActiveBaseLayer,
470
- vectorLayers: allVectorLayers.map(layer => ({
487
+ overlayLayers: allOverlayLayers.map(layer => ({
471
488
  ...layer,
472
489
  id: `layer-${layer.name.replace(/\s+/g, '-')}`
473
490
  })),
474
- vectorLayerVisibility,
475
- onVectorLayerToggle: handleVectorLayerToggle,
491
+ overlayLayerVisibility,
492
+ onOverlayLayerToggle: handleOverlayLayerToggle,
476
493
  onLayerSearch: handleLayerSearch,
477
494
  layerSearchQueries
478
495
  });
@@ -490,7 +507,7 @@ export function Map({
490
507
  map.removeControl(control);
491
508
  }, 0);
492
509
  };
493
- }, [mapLoaded, layerControl, resolvedBaseLayers, allVectorLayers.length, handleLayerSearch]);
510
+ }, [mapLoaded, layerControl, resolvedBaseLayers, allOverlayLayers.length, handleLayerSearch]);
494
511
 
495
512
  // Update layer control props when they change
496
513
  useEffect(() => {
@@ -499,17 +516,17 @@ export function Map({
499
516
  baseLayers: resolvedBaseLayers,
500
517
  activeBaseLayer,
501
518
  onBaseLayerChange: setActiveBaseLayer,
502
- vectorLayers: allVectorLayers.map(layer => ({
519
+ overlayLayers: allOverlayLayers.map(layer => ({
503
520
  ...layer,
504
521
  id: `layer-${layer.name.replace(/\s+/g, '-')}`
505
522
  })),
506
- vectorLayerVisibility,
507
- onVectorLayerToggle: handleVectorLayerToggle,
523
+ overlayLayerVisibility,
524
+ onOverlayLayerToggle: handleOverlayLayerToggle,
508
525
  onLayerSearch: handleLayerSearch,
509
526
  layerSearchQueries
510
527
  });
511
528
  }
512
- }, [resolvedBaseLayers, activeBaseLayer, allVectorLayers, vectorLayerVisibility, handleLayerSearch, layerSearchQueries]);
529
+ }, [resolvedBaseLayers, activeBaseLayer, allOverlayLayers, overlayLayerVisibility, handleLayerSearch, layerSearchQueries]);
513
530
 
514
531
  // Control visibility of layers from style JSON
515
532
  useEffect(() => {
@@ -517,10 +534,10 @@ export function Map({
517
534
  if (!map || !mapLoaded || !mapStyle) return;
518
535
 
519
536
  // Update visibility for vector layers from parsed style
520
- parsedStyleLayers.vectorLayers.forEach(layer => {
537
+ parsedStyleLayers.overlayLayers.forEach(layer => {
521
538
  const layerId = `layer-${layer.name.replace(/\s+/g, '-')}`;
522
- const isVisible = vectorLayerVisibility[layerId] !== false;
523
-
539
+ const isVisible = overlayLayerVisibility[layerId] !== false;
540
+
524
541
  // The original layer ID from the style JSON
525
542
  const styleLayerId = layer.style?.id;
526
543
  if (styleLayerId && map.getLayer(styleLayerId)) {
@@ -531,7 +548,7 @@ export function Map({
531
548
  );
532
549
  }
533
550
  });
534
- }, [vectorLayerVisibility, mapLoaded, mapStyle, parsedStyleLayers.vectorLayers]);
551
+ }, [overlayLayerVisibility, mapLoaded, mapStyle, parsedStyleLayers.overlayLayers]);
535
552
 
536
553
  // Handle fitToContent for layers with expansions
537
554
  useEffect(() => {
@@ -604,8 +621,8 @@ export function Map({
604
621
  if (e.features && e.features.length > 0) {
605
622
  const clickedLayerId = e.features[0].layer?.id;
606
623
 
607
- // Check for popup in allVectorLayers (includes parsed style layers)
608
- const layerConfig = allVectorLayers.find(l => {
624
+ // Check for popup in allOverlayLayers (includes parsed style layers)
625
+ const layerConfig = allOverlayLayers.find(l => {
609
626
  const layerId = `layer-${l.name.replace(/\s+/g, '-')}`;
610
627
  return layerId === clickedLayerId;
611
628
  });
@@ -624,30 +641,50 @@ export function Map({
624
641
  }
625
642
  }}
626
643
  interactiveLayerIds={[
627
- ...allVectorLayers.map(l => `layer-${l.name.replace(/\s+/g, '-')}`),
644
+ ...allOverlayLayers.map(l => `layer-${l.name.replace(/\s+/g, '-')}`),
628
645
  ...Object.keys(layerExpansions) // Add layer IDs that have expansions
629
646
  ]}
630
647
  >
631
648
  {/* Base Layers (only if no full mapStyle is provided) - render ALL but control visibility */}
632
649
  {!mapStyle && resolvedBaseLayers.map((layer, index) => (
633
- <RasterLayerLibre
650
+ <RasterLayerLibre
634
651
  key={`basemap-${index}`}
635
652
  id={`base-layer-${index}`}
636
- url={[layer.url]}
653
+ url={expandXyzSubdomains(layer.url)}
637
654
  attribution={layer.attribution}
655
+ tileSize={layer.tileSize}
638
656
  visible={index === activeBaseLayer}
639
657
  />
640
658
  ))}
641
659
 
642
- {/* Vector Layers from prop - render AFTER base layer so they appear on top */}
643
- {allVectorLayers.map((layer) => {
644
- const geoJson = layersData[layer.name];
660
+ {/* Overlay Layers from prop - render AFTER base layer so they appear on top */}
661
+ {allOverlayLayers.map((layer) => {
645
662
  const layerId = `layer-${layer.name.replace(/\s+/g, '-')}`;
663
+ const isVisible = overlayLayerVisibility[layerId] !== false;
664
+
665
+ // Raster overlays (XYZ/WMS) render natively via MapLibre tiles —
666
+ // they never go through fetchData/dataToGeoJson/layersData.
667
+ if (layer.source.type === 'xyz' || layer.source.type === 'wms') {
668
+ const urls = layer.source.type === 'xyz'
669
+ ? expandXyzSubdomains(layer.source.url)
670
+ : [buildWmsUrl(layer.source)];
671
+ return (
672
+ <RasterLayerLibre
673
+ key={layerId}
674
+ id={layerId}
675
+ url={urls}
676
+ tileSize={layer.source.tileSize}
677
+ attribution={layer.source.attribution}
678
+ visible={isVisible}
679
+ />
680
+ );
681
+ }
682
+
683
+ const geoJson = layersData[layer.name];
646
684
  const sourceId = `source-${layer.name}`;
647
- const isVisible = vectorLayerVisibility[layerId] !== false;
648
-
685
+
649
686
  if (!geoJson) return null;
650
-
687
+
651
688
  // Apply search filter if present
652
689
  const searchQuery = layerSearchQueries[layerId];
653
690
  let filteredGeoJson = geoJson;
@@ -18,20 +18,26 @@ export function Map(props: MapProps) {
18
18
 
19
19
  // Memoize array/object props to prevent unnecessary re-renders in MDX
20
20
  const stableBaseLayers = useMemo(() => props?.baseLayers, [JSON.stringify(props?.baseLayers || [])]);
21
- const stableVectorLayers = useMemo(() => props?.vectorLayers, [JSON.stringify(props?.vectorLayers || [])]);
21
+ const stableOverlayLayers = useMemo(() => props?.overlayLayers, [JSON.stringify(props?.overlayLayers || [])]);
22
22
  const stableGeojson = useMemo(() => props?.geojson, [JSON.stringify(props?.geojson || null)]);
23
23
  const stableCsv = useMemo(() => props?.csv, [JSON.stringify(props?.csv || null)]);
24
24
  const stableJson = useMemo(() => props?.json, [JSON.stringify(props?.json || null)]);
25
+ // `directus` feeds the same `implicitSource` → `allOverlayLayers` memo chain
26
+ // as `geojson`/`csv`/`json`; without stabilising it here an inline
27
+ // `directus={{ table: '…' }}` object from MDX gets a new identity on every
28
+ // render and drives the same render loop the others are memoised against.
29
+ const stableDirectus = useMemo(() => props?.directus, [JSON.stringify(props?.directus || null)]);
25
30
 
26
31
  return (
27
- <MapCore
28
- {...props}
32
+ <MapCore
33
+ {...props}
29
34
  center={stableCenter}
30
35
  baseLayers={stableBaseLayers}
31
- vectorLayers={stableVectorLayers}
36
+ overlayLayers={stableOverlayLayers}
32
37
  geojson={stableGeojson}
33
38
  csv={stableCsv}
34
39
  json={stableJson}
40
+ directus={stableDirectus}
35
41
  />
36
42
  );
37
43
  }
@@ -0,0 +1,201 @@
1
+ # Map Component
2
+
3
+ Interactive map built on [MapLibre GL JS](https://maplibre.org/) (via
4
+ `@vis.gl/react-maplibre`). Renders a raster basemap, any number of toggleable
5
+ overlay layers pulled from heterogeneous data sources (GeoJSON, CSV, JSON,
6
+ Directus, a generic API, or raster XYZ/WMS tiles), and a built-in layer
7
+ control for switching basemaps and toggling overlays.
8
+
9
+ ## Basic usage
10
+
11
+ ```mdx
12
+ import { Map } from '@lad-sapienza/scms-core';
13
+
14
+ <Map />
15
+ ```
16
+
17
+ With no props, `Map` renders a single OSM basemap centered on `0,0` at zoom
18
+ `2`. The shorthand props below cover the common single-layer case without
19
+ touching `overlayLayers` directly:
20
+
21
+ ```mdx
22
+ <Map geojson="/data/sites.geojson" popup="<b>${name}</b>" fitToContent />
23
+
24
+ <Map csv={{ path: '/data/sites.csv', lng: 'longitude', lat: 'latitude' }} />
25
+
26
+ <Map directus={{ table: 'sites', geoField: 'location' }} />
27
+ ```
28
+
29
+ `geojson`/`csv`/`json`/`directus` each accept either a bare string (path/URL)
30
+ or an object with `name`, `style`, `popup`, `fitToContent`, `filter` — see
31
+ `MapProps` in [`types.ts`](./types.ts) for the exact shape of each.
32
+
33
+ ## Base layers (`baseLayers`)
34
+
35
+ Base layers are **radio-exclusive** — exactly one is visible at a time,
36
+ switched via the layer control's "BASE MAPS" section. Accepts either keys
37
+ from the built-in registry ([`defaultBasemaps.ts`](./defaultBasemaps.ts): OSM,
38
+ EsriSatellite, EsriStreets, EsriTopo, GoogleSatellite/Roadmap/Terrain,
39
+ CartoDB, Imperium, CAWM, ...) or custom `BaseLayerConfig` objects, and the two
40
+ forms can be mixed in the same array:
41
+
42
+ ```mdx
43
+ <Map baseLayers={['OSM', 'EsriSatellite']} />
44
+
45
+ <Map baseLayers={[
46
+ { name: 'My WMTS', url: 'https://tiles.example.org/{z}/{x}/{y}.png', tileSize: 512 },
47
+ 'OSM',
48
+ ]} />
49
+ ```
50
+
51
+ `{s}` in a tile URL (subdomain rotation, e.g. `https://{s}.tile.osm.org/...`)
52
+ is expanded automatically into `a`/`b`/`c`. `tileSize` defaults to 256 and
53
+ only needs setting for services that don't use standard 256px tiles.
54
+
55
+ ## Overlay layers (`overlayLayers`)
56
+
57
+ Overlays are **independent and toggleable** (checkbox in the layer control) —
58
+ any number can be visible at once, layered on top of the basemap in array
59
+ order. Each entry is a `name` plus a `source`, where `source.type` picks one
60
+ of two families:
61
+
62
+ **Row/feature sources** — fetched once, converted to GeoJSON, and rendered as
63
+ a MapLibre `circle`/custom-style layer. Supports `style` (paint/layout
64
+ overrides), `popupTemplate`, `fitToContent`, `filter`, `searchInFields`:
65
+
66
+ | `source.type` | Notes |
67
+ |---|---|
68
+ | `geojson` | `{ data }` inline or `{ url }` to fetch |
69
+ | `csv` | `{ url, lng?, lat?, delimiter?, skipRows? }` — auto-detects lat/lng column names if `lng`/`lat` aren't given |
70
+ | `json` | `{ data }` inline array or `{ url }` |
71
+ | `directus` | Directus collection query, `geoField` names the geometry column |
72
+ | `api` | Generic `{ url, method?, headers?, body?, transformer? }` |
73
+ | `vector` | MapLibre vector-tile source (only produced internally when parsing a full `mapStyle`) |
74
+
75
+ If two overlay entries point at the exact same `source` (deep-equal), the
76
+ underlying fetch happens only once — each entry's own `filter` is applied on
77
+ top of the shared cached data, so e.g. the same Directus table can back
78
+ several differently-filtered/styled layers without redundant requests.
79
+
80
+ **Raster tile sources** — rendered directly as MapLibre raster tiles (same
81
+ code path as `baseLayers`), never fetched/converted. `style`, `popupTemplate`,
82
+ `fitToContent`, `filter` and `searchInFields` don't apply to these:
83
+
84
+ ```mdx
85
+ <Map
86
+ baseLayers={['OSM']}
87
+ overlayLayers={[{
88
+ name: 'Satellite',
89
+ source: { type: 'xyz', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}' },
90
+ }]}
91
+ />
92
+ ```
93
+
94
+ This is the case that motivated adding raster support to `overlayLayers` in
95
+ the first place: a raster layer (historical map, satellite imagery, ...)
96
+ shown as an independent overlay on top of a basemap the user picks — not
97
+ possible before, since a raster source could previously only be a
98
+ radio-exclusive `baseLayers` entry.
99
+
100
+ `XyzSourceConfig`: `{ type: 'xyz', url, tileSize?, attribution? }` — same
101
+ `{s}` subdomain expansion and `tileSize` default as `baseLayers`.
102
+
103
+ `WmsSourceConfig`: `{ type: 'wms', url, layers, format?, version?, transparent?, styles?, crs?, tileSize?, attribution? }` builds a GetMap tile URL
104
+ using MapLibre's `{bbox-epsg-3857}` placeholder. Defaults: `format:
105
+ 'image/png'`, `version: '1.3.0'`, `transparent: true`, `crs: 'EPSG:3857'`,
106
+ `tileSize: 256`.
107
+
108
+ ```mdx
109
+ <Map overlayLayers={[{
110
+ name: 'Historical layer',
111
+ source: {
112
+ type: 'wms',
113
+ url: 'https://wms.example.org/geoserver/wms',
114
+ layers: 'workspace:layer_name',
115
+ },
116
+ }]} />
117
+ ```
118
+
119
+ Both raster source types need the tile server to send CORS headers
120
+ (`Access-Control-Allow-Origin`) — MapLibre renders tiles as WebGL textures,
121
+ which browsers refuse to upload cross-origin without a successful CORS
122
+ check, unlike a plain `<img>`-based renderer (e.g. Leaflet). If tiles fail to
123
+ load with a CORS error in the console, the fix is on the tile server, not
124
+ here — for object-storage-backed static tile sets (e.g. OpenStack Swift),
125
+ this is typically one container-metadata header
126
+ (`X-Container-Meta-Access-Control-Allow-Origin: *`).
127
+
128
+ ## Controls
129
+
130
+ | Prop | Type | Default | Description |
131
+ |---|---|---|---|
132
+ | `layerControl` | `boolean \| ControlPosition` | `'top-right'` | Basemap radio + overlay checkboxes. `false` hides it. |
133
+ | `navigationControl` | `ControlPosition` | `'top-left'` | Zoom +/- and compass |
134
+ | `geolocateControl` | `ControlPosition` | — | "find my location" |
135
+ | `fullscreenControl` | `ControlPosition` | — | Fullscreen toggle |
136
+ | `scaleControl` | `ControlPosition` | — | Scale bar |
137
+
138
+ `ControlPosition` is one of `'top-right' | 'top-left' | 'bottom-right' |
139
+ 'bottom-left'`.
140
+
141
+ ## Full style mode (`mapStyle`)
142
+
143
+ Pass a full MapLibre style URL/object via `mapStyle` to bypass `baseLayers`/
144
+ `overlayLayers` resolution entirely — raster sources in the style become
145
+ basemap entries, vector-tile layers become overlay entries, and
146
+ `styleOverrides` can patch individual style layers' `paint`/`layout` plus
147
+ add `popupTemplate`/`fitToContent`/`visible` (properties the style spec
148
+ itself has no room for). See `MapProps` in [`types.ts`](./types.ts) for the
149
+ exact shape.
150
+
151
+ ## Props
152
+
153
+ ### `Map`
154
+
155
+ | Prop | Type | Default | Description |
156
+ |---|---|---|---|
157
+ | `height` | `string` | `'600px'` | CSS height |
158
+ | `center` | `string` | `'0,0,2'` | `"lng,lat,zoom"` |
159
+ | `baseLayers` | `(BaseLayerConfig \| BasemapKey)[]` | `['OSM']` | See above |
160
+ | `overlayLayers` | `OverlayLayerConfig[]` | `[]` | See above |
161
+ | `mapStyle` | `string \| object` | — | Full MapLibre style, see above |
162
+ | `styleOverrides` | `object` | — | Per-layer patches when using `mapStyle` |
163
+ | `geojson` / `csv` / `json` / `directus` | — | — | Single-layer shorthands, see Basic usage |
164
+ | `sprite` | `string` | — | Sprite URL |
165
+
166
+ ### `BaseLayerConfig`
167
+
168
+ | Field | Type | Description |
169
+ |---|---|---|
170
+ | `name` | `string` | Label in the layer control |
171
+ | `url` | `string` | Tile URL template, `{z}/{x}/{y}` (+ optional `{s}`) |
172
+ | `attribution` | `string?` | Attribution HTML |
173
+ | `tileSize` | `number?` | Default 256 |
174
+
175
+ ### `OverlayLayerConfig`
176
+
177
+ | Field | Type | Description |
178
+ |---|---|---|
179
+ | `name` | `string` | Label in the layer control |
180
+ | `source` | `SourceConfig \| XyzSourceConfig \| WmsSourceConfig` | See above |
181
+ | `style` | `any?` | MapLibre paint/layout override (row/feature sources only) |
182
+ | `popupTemplate` | `string?` | e.g. `"<b>${Title}</b>: ${Description}"` (row/feature sources only) |
183
+ | `visible` | `boolean?` | Initial visibility (default `true`) |
184
+ | `fitToContent` | `boolean?` | Fit map bounds to this layer's data on load (row/feature sources only) |
185
+ | `filter` | `FilterObject \| ((feature) => boolean)?` | Client-side filter (row/feature sources only) |
186
+ | `searchInFields` | `SearchInFields?` | Enables the per-layer search UI (row/feature sources only) |
187
+
188
+ ## Architecture notes
189
+
190
+ - `Map.tsx` renders `RasterLayerLibre` for every `baseLayers` entry and for
191
+ any `overlayLayers` entry whose `source.type` is `xyz`/`wms`; everything
192
+ else goes through `fetchData`/`dataToGeoJson`
193
+ ([`utils/data-fetcher.ts`](../../utils/data-fetcher.ts),
194
+ [`utils.ts`](./utils.ts)) into a MapLibre `geojson` source.
195
+ - `LayerControl.tsx` is a plain MapLibre `IControl` (not a React child of the
196
+ map) so it survives the basemap/overlay state living in `Map.tsx`'s own
197
+ React state rather than needing a portal.
198
+ - `expandXyzSubdomains`/`buildWmsUrl` (in [`utils.ts`](./utils.ts)) are the
199
+ only two functions that build tile URL templates — both `baseLayers` and
200
+ raster `overlayLayers` go through them, so there's one place to fix or
201
+ extend tile-URL construction.
@@ -2,5 +2,5 @@ export { Map } from './MapMdx';
2
2
  export { Map as MapCore } from './Map';
3
3
  export { defaultBasemaps, getBasemap, getBasemaps } from './defaultBasemaps';
4
4
  export type { BasemapKey } from './defaultBasemaps';
5
- export type { VectorLayerConfig } from './types';
5
+ export type { OverlayLayerConfig } from './types';
6
6
  export * from './types';
@@ -71,30 +71,62 @@ export interface BaseLayerConfig {
71
71
  name: string;
72
72
  url: string;
73
73
  attribution?: string;
74
+ /** Tile size in pixels, for services that don't use 256px tiles (default: 256) */
75
+ tileSize?: number;
74
76
  }
75
77
 
76
- export interface VectorLayerConfig {
78
+ /**
79
+ * XYZ raster tile source — for a raster layer used as an independent,
80
+ * toggleable overlay rather than a radio-exclusive basemap.
81
+ */
82
+ export interface XyzSourceConfig {
83
+ type: 'xyz';
84
+ /** Tile URL template with {z}/{x}/{y}, optionally {s} (expanded to a/b/c) */
85
+ url: string;
86
+ tileSize?: number;
87
+ attribution?: string;
88
+ }
89
+
90
+ /**
91
+ * WMS raster source — builds a GetMap tile URL template from friendly params.
92
+ */
93
+ export interface WmsSourceConfig {
94
+ type: 'wms';
95
+ /** WMS endpoint base URL */
96
+ url: string;
97
+ /** GetMap LAYERS param */
98
+ layers: string;
99
+ format?: string;
100
+ version?: string;
101
+ transparent?: boolean;
102
+ styles?: string;
103
+ crs?: string;
104
+ tileSize?: number;
105
+ attribution?: string;
106
+ }
107
+
108
+ export interface OverlayLayerConfig {
77
109
  /** Layer name/ID */
78
110
  name: string;
79
-
111
+
80
112
  /** Data source configuration */
81
- source: SourceConfig;
82
-
113
+ source: SourceConfig | XyzSourceConfig | WmsSourceConfig;
114
+
83
115
  /** Layer style (MapLibre paint/layout properties) */
84
116
  style?: any;
85
-
117
+
86
118
  /** Popup template string (e.g., "<b>${Title}</b>: ${Description}") */
87
119
  popupTemplate?: string;
88
-
120
+
89
121
  /** Initial visibility */
90
122
  visible?: boolean;
91
-
123
+
92
124
  /** Fit map bounds to layer content */
93
125
  fitToContent?: boolean;
94
126
 
95
127
  /** Optional client-side filter for features */
96
128
  filter?: FilterObject | ((feature: any) => boolean);
97
-
129
+
98
130
  /** Search configuration - defines which fields are searchable */
99
131
  searchInFields?: SearchInFields;
100
132
  }
@@ -128,8 +160,8 @@ export interface MapProps {
128
160
  /** Base layers configuration (can be array of configs or array of basemap keys) */
129
161
  baseLayers?: BaseLayerConfig[] | BasemapKey[];
130
162
 
131
- /** Vector layers configuration (alternative to children for hydration-safe usage) */
132
- vectorLayers?: VectorLayerConfig[];
163
+ /** Overlay layers configuration (alternative to children for hydration-safe usage) */
164
+ overlayLayers?: OverlayLayerConfig[];
133
165
 
134
166
  /** Controls positions (null/undefined to hide) */
135
167
  geolocateControl?: ControlPosition;
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { DataRow } from '../../utils/data-fetcher';
6
6
  import type { FeatureCollection, Feature, Geometry } from 'geojson';
7
+ import type { WmsSourceConfig } from './types';
7
8
 
8
9
  /**
9
10
  * Tries to identify latitude and longitude fields from a data row
@@ -116,6 +117,49 @@ export function dataToGeoJson(
116
117
  };
117
118
  }
118
119
 
120
+ /**
121
+ * Expands an XYZ tile URL template's {s} subdomain placeholder into the
122
+ * standard a/b/c rotation (as Leaflet does natively). Returns the url
123
+ * unchanged, wrapped in an array, if it has no {s} placeholder.
124
+ */
125
+ export function expandXyzSubdomains(url: string): string[] {
126
+ if (!url.includes('{s}')) return [url];
127
+ return ['a', 'b', 'c'].map(s => url.replace('{s}', s));
128
+ }
129
+
130
+ /**
131
+ * Builds a WMS GetMap tile URL template from friendly params, using
132
+ * MapLibre's {bbox-epsg-3857} placeholder for the per-tile bounding box.
133
+ */
134
+ export function buildWmsUrl(source: WmsSourceConfig): string {
135
+ const {
136
+ url,
137
+ layers,
138
+ format = 'image/png',
139
+ version = '1.3.0',
140
+ transparent = true,
141
+ styles = '',
142
+ crs = 'EPSG:3857',
143
+ tileSize = 256
144
+ } = source;
145
+
146
+ const params = new URLSearchParams({
147
+ service: 'WMS',
148
+ request: 'GetMap',
149
+ version,
150
+ layers,
151
+ styles,
152
+ format,
153
+ transparent: String(transparent),
154
+ width: String(tileSize),
155
+ height: String(tileSize),
156
+ crs
157
+ });
158
+
159
+ const separator = url.includes('?') ? '&' : '?';
160
+ return `${url}${separator}${params.toString()}&bbox={bbox-epsg-3857}`;
161
+ }
162
+
119
163
  /**
120
164
  * Escapes a string for safe interpolation into HTML markup/attributes.
121
165
  */
@@ -360,7 +360,7 @@ export function ZoteroGeoViewer(props: ZoteroGeoViewerProps) {
360
360
  height={mapHeight}
361
361
  center={mapCenter}
362
362
  baseLayers={['EsriSatellite', 'GoogleTerrain', 'Imperium']}
363
- vectorLayers={mapCenter && geojson && geojson.features.length > 0 ? [{
363
+ overlayLayers={mapCenter && geojson && geojson.features.length > 0 ? [{
364
364
  name: t('zoteroGeoViewer.layerName'),
365
365
  source: { type: 'geojson', data: geojson },
366
366
  style: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lad-sapienza/scms-core",
3
- "version": "0.1.0-alpha.7",
3
+ "version": "0.1.0-alpha.9",
4
4
  "description": "Framework components and Astro integrations for s:CMS — DataTb, Gallery, Map, ZoteroGeoViewer, Record.",
5
5
  "type": "module",
6
6
  "license": "BSD-0-Clause",