@lad-sapienza/scms-core 0.1.0-alpha.6 → 0.1.0-alpha.8
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 +2 -1
- package/components/Gallery/Gallery.astro +1 -25
- package/components/Map/LayerControl.tsx +21 -21
- package/components/Map/Map.tsx +126 -101
- package/components/Map/MapMdx.tsx +2 -2
- 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/RedirectPage/README.md +119 -0
- package/components/RedirectPage/RedirectPage.astro +34 -0
- package/components/ZoteroGeoViewer/ZoteroGeoViewer.tsx +1 -1
- package/index.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,6 +51,7 @@ import { DataTb, Map, Gallery } from '@lad-sapienza/scms-core';
|
|
|
51
51
|
| `MapComponent` (`Map`) | Interactive map with MapLibre GL JS |
|
|
52
52
|
| `Gallery` | Responsive image gallery with lightbox |
|
|
53
53
|
| `TableOfContents` | Auto-generated TOC from headings |
|
|
54
|
+
| `RedirectPage` | Static client-side redirect stub (meta-refresh + JS fallback) for static hosts |
|
|
54
55
|
| `ZoteroGeoViewer` | Zotero library visualised on a map |
|
|
55
56
|
| `RecordProvider`, `Field`, `Image`, `RecordFetcher`, `useRecordFetcher` | Build single-record detail pages against Directus |
|
|
56
57
|
| `SearchUI`, `SearchUISimple`, `SearchUIAdvanced` | Field/operator/value search UI (used by `Map`'s `searchInFields`) |
|
|
@@ -63,7 +64,7 @@ import BSNavbar from '@lad-sapienza/scms-core/components/BSNavbar';
|
|
|
63
64
|
import { directusLoader } from '@lad-sapienza/scms-core/integrations/directusLoader';
|
|
64
65
|
```
|
|
65
66
|
|
|
66
|
-
See each component folder's own `README.md` (`components/DataTb`, `components/Gallery`, `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.
|
|
67
68
|
|
|
68
69
|
## Creating a new site
|
|
69
70
|
|
|
@@ -31,35 +31,11 @@ if (explicitImages && explicitImages.length > 0) {
|
|
|
31
31
|
}
|
|
32
32
|
---
|
|
33
33
|
|
|
34
|
-
{finalImages.length > 0
|
|
34
|
+
{finalImages.length > 0 && (
|
|
35
35
|
<GalleryClient
|
|
36
36
|
images={finalImages}
|
|
37
37
|
columns={columns}
|
|
38
38
|
className={className}
|
|
39
39
|
client:idle
|
|
40
40
|
/>
|
|
41
|
-
) : (
|
|
42
|
-
<p class="gallery-empty">
|
|
43
|
-
No images found{name && <> for gallery "<code>{name}</code>"</>}.
|
|
44
|
-
{!name && !explicitImages && (
|
|
45
|
-
<> Add images to a <code>gallery/</code> folder next to this page.</>
|
|
46
|
-
)}
|
|
47
|
-
</p>
|
|
48
41
|
)}
|
|
49
|
-
|
|
50
|
-
<style>
|
|
51
|
-
.gallery-empty {
|
|
52
|
-
padding: 2rem;
|
|
53
|
-
background: #f5f5f5;
|
|
54
|
-
border-radius: 8px;
|
|
55
|
-
text-align: center;
|
|
56
|
-
color: #666;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
.gallery-empty code {
|
|
60
|
-
background: #e0e0e0;
|
|
61
|
-
padding: 2px 6px;
|
|
62
|
-
border-radius: 3px;
|
|
63
|
-
font-family: monospace;
|
|
64
|
-
}
|
|
65
|
-
</style>
|
|
@@ -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,20 @@ import type { SearchQuery } from './types';
|
|
|
26
27
|
// Default base layer (OSM)
|
|
27
28
|
const DEFAULT_BASE_LAYERS: BaseLayerConfig[] = [defaultBasemaps.OSM];
|
|
28
29
|
|
|
30
|
+
// Vector-tile, xyz and wms sources are rendered natively/via RasterLayerLibre
|
|
31
|
+
// and never go through fetchData — this narrows the union so TS knows
|
|
32
|
+
// `layer.source` is a plain SourceConfig wherever this guard is used.
|
|
33
|
+
function isFetchableLayer(layer: OverlayLayerConfig): layer is OverlayLayerConfig & { source: SourceConfig } {
|
|
34
|
+
return layer.source.type !== 'vector' && layer.source.type !== 'xyz' && layer.source.type !== 'wms';
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
export function Map({
|
|
30
38
|
height = '600px',
|
|
31
39
|
center = '0,0,2',
|
|
32
40
|
mapStyle,
|
|
33
41
|
styleOverrides,
|
|
34
42
|
baseLayers = DEFAULT_BASE_LAYERS,
|
|
35
|
-
|
|
43
|
+
overlayLayers = [],
|
|
36
44
|
geolocateControl,
|
|
37
45
|
fullscreenControl,
|
|
38
46
|
navigationControl = 'top-left',
|
|
@@ -48,15 +56,15 @@ export function Map({
|
|
|
48
56
|
const [activeBaseLayer, setActiveBaseLayer] = useState<number>(0);
|
|
49
57
|
const [layersData, setLayersData] = useState<Record<string, FeatureCollection>>({});
|
|
50
58
|
const [hoveredFeature, setHoveredFeature] = useState<any>(null);
|
|
51
|
-
const [
|
|
59
|
+
const [overlayLayerVisibility, setOverlayLayerVisibility] = useState<Record<string, boolean>>({});
|
|
52
60
|
const [mapLoaded, setMapLoaded] = useState(false);
|
|
53
61
|
const mapRef = useRef<MapRef>(null);
|
|
54
|
-
const
|
|
62
|
+
const rawSourceCache = useRef<globalThis.Map<string, FeatureCollection>>(new globalThis.Map());
|
|
55
63
|
const layerControlRef = useRef<LayerControlIControl | null>(null);
|
|
56
64
|
const [parsedStyleLayers, setParsedStyleLayers] = useState<{
|
|
57
65
|
baseLayers: BaseLayerConfig[];
|
|
58
|
-
|
|
59
|
-
}>({ baseLayers: [],
|
|
66
|
+
overlayLayers: OverlayLayerConfig[];
|
|
67
|
+
}>({ baseLayers: [], overlayLayers: [] });
|
|
60
68
|
const [mergedMapStyle, setMergedMapStyle] = useState<any>(null);
|
|
61
69
|
const [layerExpansions, setLayerExpansions] = useState<Record<string, {
|
|
62
70
|
popupTemplate?: string;
|
|
@@ -67,7 +75,7 @@ export function Map({
|
|
|
67
75
|
// Parse mapStyle JSON to extract layers and apply overrides
|
|
68
76
|
useEffect(() => {
|
|
69
77
|
if (!mapStyle) {
|
|
70
|
-
setParsedStyleLayers({ baseLayers: [],
|
|
78
|
+
setParsedStyleLayers({ baseLayers: [], overlayLayers: [] });
|
|
71
79
|
setMergedMapStyle(null);
|
|
72
80
|
setLayerExpansions({});
|
|
73
81
|
return;
|
|
@@ -86,7 +94,7 @@ export function Map({
|
|
|
86
94
|
}
|
|
87
95
|
|
|
88
96
|
const baseLayers: BaseLayerConfig[] = [];
|
|
89
|
-
const
|
|
97
|
+
const overlayLayers: OverlayLayerConfig[] = [];
|
|
90
98
|
const expansions: Record<string, any> = {};
|
|
91
99
|
|
|
92
100
|
// Apply styleOverrides to layers
|
|
@@ -148,7 +156,7 @@ export function Map({
|
|
|
148
156
|
const source = styleObj.sources[layer.source];
|
|
149
157
|
if (source && source.type === 'vector') {
|
|
150
158
|
const expansion = expansions[layer.id] || {};
|
|
151
|
-
|
|
159
|
+
overlayLayers.push({
|
|
152
160
|
name: layer.metadata?.label || layer.id,
|
|
153
161
|
source: {
|
|
154
162
|
type: 'vector',
|
|
@@ -164,11 +172,11 @@ export function Map({
|
|
|
164
172
|
});
|
|
165
173
|
}
|
|
166
174
|
|
|
167
|
-
setParsedStyleLayers({ baseLayers,
|
|
175
|
+
setParsedStyleLayers({ baseLayers, overlayLayers });
|
|
168
176
|
setMergedMapStyle(styleObj);
|
|
169
177
|
} catch (error) {
|
|
170
178
|
console.error('Failed to parse mapStyle:', error);
|
|
171
|
-
setParsedStyleLayers({ baseLayers: [],
|
|
179
|
+
setParsedStyleLayers({ baseLayers: [], overlayLayers: [] });
|
|
172
180
|
setMergedMapStyle(null);
|
|
173
181
|
setLayerExpansions({});
|
|
174
182
|
}
|
|
@@ -234,13 +242,13 @@ export function Map({
|
|
|
234
242
|
return null;
|
|
235
243
|
}, [geojson, csv, json, directus]);
|
|
236
244
|
|
|
237
|
-
// Combine explicit
|
|
238
|
-
const
|
|
239
|
-
let layers = [...
|
|
240
|
-
|
|
245
|
+
// Combine explicit overlayLayers with implicit source
|
|
246
|
+
const allOverlayLayers = useMemo(() => {
|
|
247
|
+
let layers = [...overlayLayers];
|
|
248
|
+
|
|
241
249
|
// If using mapStyle, add parsed vector layers from style JSON
|
|
242
|
-
if (mapStyle && parsedStyleLayers.
|
|
243
|
-
layers = [...parsedStyleLayers.
|
|
250
|
+
if (mapStyle && parsedStyleLayers.overlayLayers.length > 0) {
|
|
251
|
+
layers = [...parsedStyleLayers.overlayLayers, ...layers];
|
|
244
252
|
}
|
|
245
253
|
|
|
246
254
|
if (implicitSource) {
|
|
@@ -288,28 +296,28 @@ export function Map({
|
|
|
288
296
|
layers.push(layerConfig);
|
|
289
297
|
}
|
|
290
298
|
return layers;
|
|
291
|
-
}, [
|
|
299
|
+
}, [overlayLayers, implicitSource, geojson, csv, mapStyle, parsedStyleLayers.overlayLayers]);
|
|
292
300
|
|
|
293
301
|
// Initialize and update visibility when layers change
|
|
294
302
|
useEffect(() => {
|
|
295
|
-
|
|
303
|
+
setOverlayLayerVisibility(prev => {
|
|
296
304
|
const newVisibility: Record<string, boolean> = { ...prev };
|
|
297
305
|
let hasChanges = false;
|
|
298
|
-
|
|
299
|
-
|
|
306
|
+
|
|
307
|
+
allOverlayLayers.forEach(layer => {
|
|
300
308
|
const layerId = `layer-${layer.name.replace(/\s+/g, '-')}`;
|
|
301
|
-
|
|
309
|
+
|
|
302
310
|
if (newVisibility[layerId] === undefined) {
|
|
303
311
|
newVisibility[layerId] = layer.visible !== false;
|
|
304
312
|
hasChanges = true;
|
|
305
313
|
}
|
|
306
314
|
});
|
|
307
|
-
|
|
315
|
+
|
|
308
316
|
return hasChanges ? newVisibility : prev;
|
|
309
317
|
});
|
|
310
|
-
}, [
|
|
318
|
+
}, [allOverlayLayers]);
|
|
311
319
|
|
|
312
|
-
// Handle
|
|
320
|
+
// Handle overlay layer visibility toggle
|
|
313
321
|
//
|
|
314
322
|
// Wrapped in useCallback (both here and below) so its identity stays
|
|
315
323
|
// stable across renders — it's one of two functions passed into the
|
|
@@ -324,14 +332,14 @@ export function Map({
|
|
|
324
332
|
// created (correct) control was the one actually getting removed a tick
|
|
325
333
|
// later, leaving the stale first control (still showing the original
|
|
326
334
|
// basemap selected) as the only one left on the map.
|
|
327
|
-
const
|
|
328
|
-
|
|
335
|
+
const handleOverlayLayerToggle = useCallback((layerId: string) => {
|
|
336
|
+
setOverlayLayerVisibility(prev => ({
|
|
329
337
|
...prev,
|
|
330
338
|
[layerId]: !prev[layerId]
|
|
331
339
|
}));
|
|
332
340
|
}, []);
|
|
333
341
|
|
|
334
|
-
// Handle search for
|
|
342
|
+
// Handle search for overlay layers — see handleOverlayLayerToggle above.
|
|
335
343
|
const handleLayerSearch = useCallback((layerId: string, query: SearchQuery) => {
|
|
336
344
|
setLayerSearchQueries(prev => ({
|
|
337
345
|
...prev,
|
|
@@ -339,71 +347,68 @@ export function Map({
|
|
|
339
347
|
}));
|
|
340
348
|
}, []);
|
|
341
349
|
|
|
342
|
-
// Load data for all vector
|
|
350
|
+
// Load data for all fetchable overlay layers (vector-tile, xyz and wms
|
|
351
|
+
// sources are rendered natively/via RasterLayerLibre and never reach
|
|
352
|
+
// fetchData). Raw fetched+converted GeoJSON is cached per unique source
|
|
353
|
+
// (rawSourceCache, keyed by JSON.stringify(source)) so N layer entries
|
|
354
|
+
// sharing the same source — differing only in filter/style — fetch once;
|
|
355
|
+
// each layer's own filter is applied on top of the cached raw data.
|
|
356
|
+
// Loading no longer depends on `visible`: visibility only controls
|
|
357
|
+
// display (see the render below), not whether data is ever fetched —
|
|
358
|
+
// otherwise a layer started as visible:false could never be loaded even
|
|
359
|
+
// after the user later checks it on, since this effect only re-runs when
|
|
360
|
+
// allOverlayLayers itself changes, not on a visibility toggle.
|
|
343
361
|
useEffect(() => {
|
|
344
362
|
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;
|
|
363
|
+
const fetchableLayers = allOverlayLayers.filter(isFetchableLayer);
|
|
364
|
+
if (fetchableLayers.length === 0) return;
|
|
355
365
|
|
|
366
|
+
const seen = new Set<string>();
|
|
367
|
+
for (const layer of fetchableLayers) {
|
|
356
368
|
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) {
|
|
369
|
+
if (rawSourceCache.current.has(layerKey) || seen.has(layerKey)) continue;
|
|
370
|
+
seen.add(layerKey);
|
|
371
|
+
|
|
366
372
|
try {
|
|
367
373
|
const data = await fetchData(layer.source);
|
|
368
|
-
|
|
374
|
+
|
|
369
375
|
// Extract custom column names from CSV source if provided
|
|
370
376
|
const customLng = layer.source.type === 'csv' ? layer.source.lng : undefined;
|
|
371
377
|
const customLat = layer.source.type === 'csv' ? layer.source.lat : undefined;
|
|
372
|
-
|
|
378
|
+
|
|
373
379
|
// Extract geoField from Directus source if provided
|
|
374
380
|
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);
|
|
381
|
+
|
|
382
|
+
const geoJson = dataToGeoJson(data, customLng, customLat, geoField);
|
|
383
|
+
rawSourceCache.current.set(layerKey, geoJson);
|
|
391
384
|
} catch (err) {
|
|
392
385
|
console.error(`Failed to load layer "${layer.name}":`, err);
|
|
393
386
|
// Continue loading other layers even if one fails
|
|
394
387
|
}
|
|
395
388
|
}
|
|
396
|
-
|
|
397
|
-
//
|
|
389
|
+
|
|
390
|
+
// Project cached raw data into each layer's own (filtered) view —
|
|
391
|
+
// cheap client-side work, no network, safe to redo whenever
|
|
392
|
+
// allOverlayLayers changes (e.g. a filter edit).
|
|
393
|
+
const newLayersData: Record<string, FeatureCollection> = {};
|
|
394
|
+
for (const layer of fetchableLayers) {
|
|
395
|
+
const raw = rawSourceCache.current.get(JSON.stringify(layer.source));
|
|
396
|
+
if (!raw) continue; // fetch failed for this source
|
|
397
|
+
|
|
398
|
+
newLayersData[layer.name] = layer.filter && typeof layer.filter === 'function'
|
|
399
|
+
? { ...raw, features: raw.features.filter(layer.filter) }
|
|
400
|
+
: raw;
|
|
401
|
+
}
|
|
402
|
+
|
|
398
403
|
if (Object.keys(newLayersData).length > 0) {
|
|
399
404
|
setLayersData(prev => ({ ...prev, ...newLayersData }));
|
|
400
405
|
}
|
|
401
406
|
};
|
|
402
|
-
|
|
403
|
-
if (
|
|
407
|
+
|
|
408
|
+
if (allOverlayLayers.length > 0 && typeof window !== 'undefined') {
|
|
404
409
|
loadAllLayers();
|
|
405
410
|
}
|
|
406
|
-
}, [
|
|
411
|
+
}, [allOverlayLayers]);
|
|
407
412
|
|
|
408
413
|
// Handle fitToContent separately when map is loaded and data is ready
|
|
409
414
|
useEffect(() => {
|
|
@@ -411,7 +416,7 @@ export function Map({
|
|
|
411
416
|
if (!map || !mapLoaded || Object.keys(layersData).length === 0) return;
|
|
412
417
|
|
|
413
418
|
// Find first layer with fitToContent
|
|
414
|
-
const layerToFit =
|
|
419
|
+
const layerToFit = allOverlayLayers.find(layer =>
|
|
415
420
|
layer.fitToContent && layersData[layer.name] && layersData[layer.name].features.length > 0
|
|
416
421
|
);
|
|
417
422
|
|
|
@@ -449,30 +454,30 @@ export function Map({
|
|
|
449
454
|
map.fitBounds(bounds, { padding: 50, duration: 1000 });
|
|
450
455
|
}
|
|
451
456
|
}, 100);
|
|
452
|
-
}, [mapLoaded, layersData,
|
|
457
|
+
}, [mapLoaded, layersData, allOverlayLayers]);
|
|
453
458
|
|
|
454
459
|
// Add LayerControl as a proper IControl
|
|
455
460
|
useEffect(() => {
|
|
456
461
|
const map = mapRef.current?.getMap();
|
|
457
462
|
if (!map || !mapLoaded || !layerControl) return;
|
|
458
|
-
|
|
459
|
-
// Show layer control if we have base layers or
|
|
460
|
-
const hasLayers = resolvedBaseLayers.length > 0 ||
|
|
463
|
+
|
|
464
|
+
// Show layer control if we have base layers or overlay layers
|
|
465
|
+
const hasLayers = resolvedBaseLayers.length > 0 || allOverlayLayers.length > 0;
|
|
461
466
|
if (!hasLayers) return;
|
|
462
467
|
|
|
463
468
|
const position = typeof layerControl === 'string' ? layerControl : 'top-right';
|
|
464
|
-
|
|
469
|
+
|
|
465
470
|
// Create and add control
|
|
466
471
|
const control = new LayerControlIControl({
|
|
467
472
|
baseLayers: resolvedBaseLayers,
|
|
468
473
|
activeBaseLayer,
|
|
469
474
|
onBaseLayerChange: setActiveBaseLayer,
|
|
470
|
-
|
|
475
|
+
overlayLayers: allOverlayLayers.map(layer => ({
|
|
471
476
|
...layer,
|
|
472
477
|
id: `layer-${layer.name.replace(/\s+/g, '-')}`
|
|
473
478
|
})),
|
|
474
|
-
|
|
475
|
-
|
|
479
|
+
overlayLayerVisibility,
|
|
480
|
+
onOverlayLayerToggle: handleOverlayLayerToggle,
|
|
476
481
|
onLayerSearch: handleLayerSearch,
|
|
477
482
|
layerSearchQueries
|
|
478
483
|
});
|
|
@@ -490,7 +495,7 @@ export function Map({
|
|
|
490
495
|
map.removeControl(control);
|
|
491
496
|
}, 0);
|
|
492
497
|
};
|
|
493
|
-
}, [mapLoaded, layerControl, resolvedBaseLayers,
|
|
498
|
+
}, [mapLoaded, layerControl, resolvedBaseLayers, allOverlayLayers.length, handleLayerSearch]);
|
|
494
499
|
|
|
495
500
|
// Update layer control props when they change
|
|
496
501
|
useEffect(() => {
|
|
@@ -499,17 +504,17 @@ export function Map({
|
|
|
499
504
|
baseLayers: resolvedBaseLayers,
|
|
500
505
|
activeBaseLayer,
|
|
501
506
|
onBaseLayerChange: setActiveBaseLayer,
|
|
502
|
-
|
|
507
|
+
overlayLayers: allOverlayLayers.map(layer => ({
|
|
503
508
|
...layer,
|
|
504
509
|
id: `layer-${layer.name.replace(/\s+/g, '-')}`
|
|
505
510
|
})),
|
|
506
|
-
|
|
507
|
-
|
|
511
|
+
overlayLayerVisibility,
|
|
512
|
+
onOverlayLayerToggle: handleOverlayLayerToggle,
|
|
508
513
|
onLayerSearch: handleLayerSearch,
|
|
509
514
|
layerSearchQueries
|
|
510
515
|
});
|
|
511
516
|
}
|
|
512
|
-
}, [resolvedBaseLayers, activeBaseLayer,
|
|
517
|
+
}, [resolvedBaseLayers, activeBaseLayer, allOverlayLayers, overlayLayerVisibility, handleLayerSearch, layerSearchQueries]);
|
|
513
518
|
|
|
514
519
|
// Control visibility of layers from style JSON
|
|
515
520
|
useEffect(() => {
|
|
@@ -517,10 +522,10 @@ export function Map({
|
|
|
517
522
|
if (!map || !mapLoaded || !mapStyle) return;
|
|
518
523
|
|
|
519
524
|
// Update visibility for vector layers from parsed style
|
|
520
|
-
parsedStyleLayers.
|
|
525
|
+
parsedStyleLayers.overlayLayers.forEach(layer => {
|
|
521
526
|
const layerId = `layer-${layer.name.replace(/\s+/g, '-')}`;
|
|
522
|
-
const isVisible =
|
|
523
|
-
|
|
527
|
+
const isVisible = overlayLayerVisibility[layerId] !== false;
|
|
528
|
+
|
|
524
529
|
// The original layer ID from the style JSON
|
|
525
530
|
const styleLayerId = layer.style?.id;
|
|
526
531
|
if (styleLayerId && map.getLayer(styleLayerId)) {
|
|
@@ -531,7 +536,7 @@ export function Map({
|
|
|
531
536
|
);
|
|
532
537
|
}
|
|
533
538
|
});
|
|
534
|
-
}, [
|
|
539
|
+
}, [overlayLayerVisibility, mapLoaded, mapStyle, parsedStyleLayers.overlayLayers]);
|
|
535
540
|
|
|
536
541
|
// Handle fitToContent for layers with expansions
|
|
537
542
|
useEffect(() => {
|
|
@@ -604,8 +609,8 @@ export function Map({
|
|
|
604
609
|
if (e.features && e.features.length > 0) {
|
|
605
610
|
const clickedLayerId = e.features[0].layer?.id;
|
|
606
611
|
|
|
607
|
-
// Check for popup in
|
|
608
|
-
const layerConfig =
|
|
612
|
+
// Check for popup in allOverlayLayers (includes parsed style layers)
|
|
613
|
+
const layerConfig = allOverlayLayers.find(l => {
|
|
609
614
|
const layerId = `layer-${l.name.replace(/\s+/g, '-')}`;
|
|
610
615
|
return layerId === clickedLayerId;
|
|
611
616
|
});
|
|
@@ -624,30 +629,50 @@ export function Map({
|
|
|
624
629
|
}
|
|
625
630
|
}}
|
|
626
631
|
interactiveLayerIds={[
|
|
627
|
-
...
|
|
632
|
+
...allOverlayLayers.map(l => `layer-${l.name.replace(/\s+/g, '-')}`),
|
|
628
633
|
...Object.keys(layerExpansions) // Add layer IDs that have expansions
|
|
629
634
|
]}
|
|
630
635
|
>
|
|
631
636
|
{/* Base Layers (only if no full mapStyle is provided) - render ALL but control visibility */}
|
|
632
637
|
{!mapStyle && resolvedBaseLayers.map((layer, index) => (
|
|
633
|
-
<RasterLayerLibre
|
|
638
|
+
<RasterLayerLibre
|
|
634
639
|
key={`basemap-${index}`}
|
|
635
640
|
id={`base-layer-${index}`}
|
|
636
|
-
url={
|
|
641
|
+
url={expandXyzSubdomains(layer.url)}
|
|
637
642
|
attribution={layer.attribution}
|
|
643
|
+
tileSize={layer.tileSize}
|
|
638
644
|
visible={index === activeBaseLayer}
|
|
639
645
|
/>
|
|
640
646
|
))}
|
|
641
647
|
|
|
642
|
-
{/*
|
|
643
|
-
{
|
|
644
|
-
const geoJson = layersData[layer.name];
|
|
648
|
+
{/* Overlay Layers from prop - render AFTER base layer so they appear on top */}
|
|
649
|
+
{allOverlayLayers.map((layer) => {
|
|
645
650
|
const layerId = `layer-${layer.name.replace(/\s+/g, '-')}`;
|
|
651
|
+
const isVisible = overlayLayerVisibility[layerId] !== false;
|
|
652
|
+
|
|
653
|
+
// Raster overlays (XYZ/WMS) render natively via MapLibre tiles —
|
|
654
|
+
// they never go through fetchData/dataToGeoJson/layersData.
|
|
655
|
+
if (layer.source.type === 'xyz' || layer.source.type === 'wms') {
|
|
656
|
+
const urls = layer.source.type === 'xyz'
|
|
657
|
+
? expandXyzSubdomains(layer.source.url)
|
|
658
|
+
: [buildWmsUrl(layer.source)];
|
|
659
|
+
return (
|
|
660
|
+
<RasterLayerLibre
|
|
661
|
+
key={layerId}
|
|
662
|
+
id={layerId}
|
|
663
|
+
url={urls}
|
|
664
|
+
tileSize={layer.source.tileSize}
|
|
665
|
+
attribution={layer.source.attribution}
|
|
666
|
+
visible={isVisible}
|
|
667
|
+
/>
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const geoJson = layersData[layer.name];
|
|
646
672
|
const sourceId = `source-${layer.name}`;
|
|
647
|
-
|
|
648
|
-
|
|
673
|
+
|
|
649
674
|
if (!geoJson) return null;
|
|
650
|
-
|
|
675
|
+
|
|
651
676
|
// Apply search filter if present
|
|
652
677
|
const searchQuery = layerSearchQueries[layerId];
|
|
653
678
|
let filteredGeoJson = geoJson;
|
|
@@ -18,7 +18,7 @@ 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)]);
|
|
@@ -28,7 +28,7 @@ export function Map(props: MapProps) {
|
|
|
28
28
|
{...props}
|
|
29
29
|
center={stableCenter}
|
|
30
30
|
baseLayers={stableBaseLayers}
|
|
31
|
-
|
|
31
|
+
overlayLayers={stableOverlayLayers}
|
|
32
32
|
geojson={stableGeojson}
|
|
33
33
|
csv={stableCsv}
|
|
34
34
|
json={stableJson}
|
|
@@ -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
|
*/
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# RedirectPage Component
|
|
2
|
+
|
|
3
|
+
A static, client-side redirect stub for static hosts (GitHub Pages, S3, any
|
|
4
|
+
CDN without server-side redirect support). Renders a page that redirects via
|
|
5
|
+
`<meta http-equiv="refresh">`, a `<link rel="canonical">` for search engines,
|
|
6
|
+
and a `window.location.replace()` fallback for browsers that ignore
|
|
7
|
+
meta-refresh.
|
|
8
|
+
|
|
9
|
+
## When to use this vs. Astro's native `redirects`
|
|
10
|
+
|
|
11
|
+
Astro's [`redirects`](https://docs.astro.build/en/reference/configuration-reference/#redirects)
|
|
12
|
+
config option generates the same kind of stub automatically for `output:
|
|
13
|
+
'static'` sites, and should be preferred whenever it applies — it's zero
|
|
14
|
+
extra code. Reach for `RedirectPage` only when `redirects` can't express the
|
|
15
|
+
mapping.
|
|
16
|
+
|
|
17
|
+
**Works fine with native `redirects`:** a static source path redirecting to
|
|
18
|
+
a destination produced by a dynamic route, as long as the destination is
|
|
19
|
+
given as a concrete, already-resolved path (not a bracketed route pattern).
|
|
20
|
+
Astro only validates dynamic-parameter propagation when the *source* path
|
|
21
|
+
itself has dynamic segments — a static source skips that check entirely.
|
|
22
|
+
Confirmed empirically against Astro 7.2.4 (`output: 'static'`): a source
|
|
23
|
+
route with no params, e.g. `/chi-siamo`, redirecting to `/it/chi-siamo`
|
|
24
|
+
(itself generated by a `src/pages/[locale]/chi-siamo.astro` with
|
|
25
|
+
`getStaticPaths`), builds and emits the expected redirect stub with no
|
|
26
|
+
errors.
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
// astro.config.mjs
|
|
30
|
+
export default defineConfig({
|
|
31
|
+
redirects: {
|
|
32
|
+
'/chi-siamo': '/it/chi-siamo',
|
|
33
|
+
'/notizie': '/it/notizie',
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Needs `RedirectPage` instead:** the source path itself is dynamic — e.g.
|
|
39
|
+
one redirect per old article slug, `/notizie/<slug>` → `/it/notizie/<slug>`
|
|
40
|
+
— *and* the destination route has a dynamic parameter the source doesn't
|
|
41
|
+
carry (typically a locale prefix). Astro requires the destination's dynamic
|
|
42
|
+
parameters to already be present in the source; `[locale]` isn't, since the
|
|
43
|
+
old flat URL had no locale segment. Trying to force it fails at build time:
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
redirects: {
|
|
47
|
+
'/notizie/[...slug]': '/it/notizie/[...slug]', // does NOT match the
|
|
48
|
+
// registered `/[locale]/notizie/[...slug]` route — this throws
|
|
49
|
+
// InvalidRedirectDestination: "The destination ... does not match any
|
|
50
|
+
// existing route in your project."
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
There's no config-level way to say "always substitute `it` for `[locale]`
|
|
55
|
+
here" — the list of old slugs to redirect, and the mapping to each new URL,
|
|
56
|
+
has to come from actual code that knows about the content collection. That's
|
|
57
|
+
what the recipe below does.
|
|
58
|
+
|
|
59
|
+
## Basic usage — single static redirect
|
|
60
|
+
|
|
61
|
+
For a handful of one-off redirects where you'd rather not touch
|
|
62
|
+
`astro.config.mjs` (or where the source **is** dynamic but happens not to
|
|
63
|
+
need a config-level `redirects` entry), drop a page using `RedirectPage`
|
|
64
|
+
directly:
|
|
65
|
+
|
|
66
|
+
```astro
|
|
67
|
+
---
|
|
68
|
+
// src/pages/chi-siamo.astro
|
|
69
|
+
import { RedirectPage } from '@lad-sapienza/scms-core';
|
|
70
|
+
export const prerender = true;
|
|
71
|
+
---
|
|
72
|
+
<RedirectPage target="/it/chi-siamo" />
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Recipe — redirecting every entry of a content collection
|
|
76
|
+
|
|
77
|
+
The common case: a collection is moving from a flat URL (`/notizie/<slug>/`)
|
|
78
|
+
to a locale-prefixed one (`/it/notizie/<slug>/`), and every existing entry
|
|
79
|
+
needs its own redirect stub. Generate one page per old slug with
|
|
80
|
+
`getStaticPaths`, reading the slug list straight from the collection so the
|
|
81
|
+
redirect list can never drift out of sync with the content itself:
|
|
82
|
+
|
|
83
|
+
```astro
|
|
84
|
+
---
|
|
85
|
+
// src/pages/notizie/[...slug].astro
|
|
86
|
+
import { RedirectPage } from '@lad-sapienza/scms-core';
|
|
87
|
+
import { getCollection } from 'astro:content';
|
|
88
|
+
|
|
89
|
+
export const prerender = true;
|
|
90
|
+
|
|
91
|
+
export async function getStaticPaths() {
|
|
92
|
+
const articles = await getCollection('notizie');
|
|
93
|
+
// Adapt this to however your entry IDs encode locale/slug — e.g. if
|
|
94
|
+
// content lives under src/content/notizie/it/<slug>/, split the id
|
|
95
|
+
// yourself and keep only the default-locale entries (each locale has the
|
|
96
|
+
// same slug, so the redirect only needs to be generated once).
|
|
97
|
+
return articles
|
|
98
|
+
.filter((a) => a.id.startsWith('it/'))
|
|
99
|
+
.map((a) => ({ params: { slug: a.id.slice('it/'.length) } }));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const { slug } = Astro.params;
|
|
103
|
+
---
|
|
104
|
+
<RedirectPage target={`/it/notizie/${slug}/`} />
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Repeat one such `[...slug].astro` per collection that needs migrating
|
|
108
|
+
(`blog`, `ricerca`, `didattica`, ...) — the body only changes in the
|
|
109
|
+
collection name and the target prefix. A collection's own `index.astro`
|
|
110
|
+
(the old flat listing page) is a single static redirect and can either use
|
|
111
|
+
this same component directly or, per the section above, a plain
|
|
112
|
+
`redirects` config entry.
|
|
113
|
+
|
|
114
|
+
## Props
|
|
115
|
+
|
|
116
|
+
| Prop | Type | Default | Description |
|
|
117
|
+
|---|---|---|---|
|
|
118
|
+
| `target` | `string` | — | Destination URL to redirect to (required) |
|
|
119
|
+
| `title` | `string` | `"Redirecting…"` | Page `<title>`, shown briefly before the redirect fires |
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Static client-side redirect stub (meta-refresh + JS), for static hosts
|
|
4
|
+
* where server-side redirects aren't available (e.g. GitHub Pages). Render
|
|
5
|
+
* one of these at each old URL to send visitors/search engines/citations to
|
|
6
|
+
* a new location — typically after a URL scheme change (e.g. adding an
|
|
7
|
+
* i18n locale prefix) that Astro's own config-level `redirects` can't
|
|
8
|
+
* express. See `components/RedirectPage/README.md` for when to reach for
|
|
9
|
+
* this instead of `redirects`, and for the `getStaticPaths` recipe that
|
|
10
|
+
* generates one of these per entry of a content collection.
|
|
11
|
+
*/
|
|
12
|
+
interface Props {
|
|
13
|
+
/** Destination URL to redirect to */
|
|
14
|
+
target: string
|
|
15
|
+
/** Page title shown briefly before the redirect fires */
|
|
16
|
+
title?: string
|
|
17
|
+
}
|
|
18
|
+
const { target, title = "Redirecting…" } = Astro.props
|
|
19
|
+
---
|
|
20
|
+
<!doctype html>
|
|
21
|
+
<html lang="en">
|
|
22
|
+
<head>
|
|
23
|
+
<meta charset="UTF-8" />
|
|
24
|
+
<meta http-equiv="refresh" content={`0; url=${target}`} />
|
|
25
|
+
<link rel="canonical" href={target} />
|
|
26
|
+
<title>{title}</title>
|
|
27
|
+
</head>
|
|
28
|
+
<body>
|
|
29
|
+
<p>Redirecting to <a href={target}>{target}</a>…</p>
|
|
30
|
+
<script is:inline define:vars={{ target }}>
|
|
31
|
+
window.location.replace(target);
|
|
32
|
+
</script>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
|
@@ -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/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Core package exports
|
|
2
2
|
export { Gallery } from './components/Gallery';
|
|
3
3
|
export { default as TableOfContents } from './components/TableOfContents/TableOfContents.astro';
|
|
4
|
+
export { default as RedirectPage } from './components/RedirectPage/RedirectPage.astro';
|
|
4
5
|
export { default as ThemeToggle } from './components/ThemeToggle';
|
|
5
6
|
|
|
6
7
|
// DataTb (recommended table component)
|
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.8",
|
|
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",
|