@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 +1 -1
- package/components/Map/LayerControl.tsx +21 -21
- package/components/Map/Map.tsx +138 -101
- package/components/Map/MapMdx.tsx +10 -4
- package/components/Map/README.md +201 -0
- package/components/Map/index.ts +1 -1
- package/components/Map/types.ts +42 -10
- package/components/Map/utils.ts +44 -0
- package/components/ZoteroGeoViewer/ZoteroGeoViewer.tsx +1 -1
- package/package.json +1 -1
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,
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 ||
|
|
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
|
-
{/*
|
|
154
|
-
{
|
|
153
|
+
{/* Overlay Layers Section */}
|
|
154
|
+
{overlayLayers.length > 0 && (
|
|
155
155
|
<div style={{ marginBottom: 10 }}>
|
|
156
|
-
{
|
|
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={`
|
|
161
|
-
checked={
|
|
162
|
-
onChange={() =>
|
|
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={`
|
|
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.
|
|
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.
|
|
357
|
+
}, [props.layerSearchQueries, props.overlayLayerVisibility, props.activeBaseLayer]);
|
|
358
358
|
|
|
359
359
|
// Create control instance only once
|
|
360
360
|
if (!controlRef.current) {
|
package/components/Map/Map.tsx
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
-
|
|
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 [
|
|
71
|
+
const [overlayLayerVisibility, setOverlayLayerVisibility] = useState<Record<string, boolean>>({});
|
|
52
72
|
const [mapLoaded, setMapLoaded] = useState(false);
|
|
53
73
|
const mapRef = useRef<MapRef>(null);
|
|
54
|
-
const
|
|
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
|
-
|
|
59
|
-
}>({ baseLayers: [],
|
|
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: [],
|
|
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
|
|
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
|
-
|
|
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,
|
|
187
|
+
setParsedStyleLayers({ baseLayers, overlayLayers });
|
|
168
188
|
setMergedMapStyle(styleObj);
|
|
169
189
|
} catch (error) {
|
|
170
190
|
console.error('Failed to parse mapStyle:', error);
|
|
171
|
-
setParsedStyleLayers({ baseLayers: [],
|
|
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
|
|
238
|
-
const
|
|
239
|
-
let layers = [...
|
|
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.
|
|
243
|
-
layers = [...parsedStyleLayers.
|
|
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
|
-
}, [
|
|
311
|
+
}, [overlayLayers, implicitSource, geojson, csv, mapStyle, parsedStyleLayers.overlayLayers]);
|
|
292
312
|
|
|
293
313
|
// Initialize and update visibility when layers change
|
|
294
314
|
useEffect(() => {
|
|
295
|
-
|
|
315
|
+
setOverlayLayerVisibility(prev => {
|
|
296
316
|
const newVisibility: Record<string, boolean> = { ...prev };
|
|
297
317
|
let hasChanges = false;
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
}, [
|
|
330
|
+
}, [allOverlayLayers]);
|
|
311
331
|
|
|
312
|
-
// Handle
|
|
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
|
|
328
|
-
|
|
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
|
|
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
|
|
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
|
|
346
|
-
|
|
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 (
|
|
358
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
419
|
+
|
|
420
|
+
if (allOverlayLayers.length > 0 && typeof window !== 'undefined') {
|
|
404
421
|
loadAllLayers();
|
|
405
422
|
}
|
|
406
|
-
}, [
|
|
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 =
|
|
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,
|
|
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
|
|
460
|
-
const hasLayers = resolvedBaseLayers.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
|
-
|
|
487
|
+
overlayLayers: allOverlayLayers.map(layer => ({
|
|
471
488
|
...layer,
|
|
472
489
|
id: `layer-${layer.name.replace(/\s+/g, '-')}`
|
|
473
490
|
})),
|
|
474
|
-
|
|
475
|
-
|
|
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,
|
|
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
|
-
|
|
519
|
+
overlayLayers: allOverlayLayers.map(layer => ({
|
|
503
520
|
...layer,
|
|
504
521
|
id: `layer-${layer.name.replace(/\s+/g, '-')}`
|
|
505
522
|
})),
|
|
506
|
-
|
|
507
|
-
|
|
523
|
+
overlayLayerVisibility,
|
|
524
|
+
onOverlayLayerToggle: handleOverlayLayerToggle,
|
|
508
525
|
onLayerSearch: handleLayerSearch,
|
|
509
526
|
layerSearchQueries
|
|
510
527
|
});
|
|
511
528
|
}
|
|
512
|
-
}, [resolvedBaseLayers, activeBaseLayer,
|
|
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.
|
|
537
|
+
parsedStyleLayers.overlayLayers.forEach(layer => {
|
|
521
538
|
const layerId = `layer-${layer.name.replace(/\s+/g, '-')}`;
|
|
522
|
-
const isVisible =
|
|
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
|
-
}, [
|
|
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
|
|
608
|
-
const layerConfig =
|
|
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
|
-
...
|
|
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={
|
|
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
|
-
{/*
|
|
643
|
-
{
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
package/components/Map/index.ts
CHANGED
|
@@ -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 {
|
|
5
|
+
export type { OverlayLayerConfig } from './types';
|
|
6
6
|
export * from './types';
|
package/components/Map/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
132
|
-
|
|
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;
|
package/components/Map/utils.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|