feat(geoportal): add search, basemap switcher, feature info panel, selection + export
Major geoportal enhancements: - Basemap switcher (OSM/Satellite/Terrain) with ESRI + OpenTopoMap tiles - Search bar with debounced lookup (UATs by name, parcels by cadastral ref, owners by name) - Feature info panel showing enrichment data from ParcelSync (cadastru, proprietari, suprafata, folosinta) - Parcel selection mode with amber highlight + export (GeoJSON/DXF/GPKG via ogr2ogr) - Next.js /tiles rewrite proxying to Martin (fixes dev + avoids mixed content) - Fixed MapLibre web worker relative URL resolution (window.location.origin) API routes: /api/geoportal/search, /api/geoportal/feature, /api/geoportal/export Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,17 +4,17 @@ import { useRef, useEffect, useState, useCallback, useImperativeHandle, forwardR
|
||||
import maplibregl from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import type { ClickedFeature, LayerVisibility } from "../types";
|
||||
import type { BasemapId, ClickedFeature, LayerVisibility, SelectedFeature } from "../types";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Martin tile URL — use relative /tiles path (proxied by Traefik).
|
||||
* This works both in production (HTTPS) and avoids mixed-content issues.
|
||||
* Martin tile URL — relative /tiles is proxied by Next.js rewrite (dev)
|
||||
* or Traefik (production). Falls back to env var if set.
|
||||
*/
|
||||
const DEFAULT_MARTIN_URL = "/tiles";
|
||||
const DEFAULT_MARTIN_URL = process.env.NEXT_PUBLIC_MARTIN_URL || "/tiles";
|
||||
|
||||
/** Default center: Romania roughly centered */
|
||||
const DEFAULT_CENTER: [number, number] = [23.8, 46.1];
|
||||
@@ -36,8 +36,40 @@ const LAYER_IDS = {
|
||||
terenuriLine: "layer-terenuri-line",
|
||||
cladiriFill: "layer-cladiri-fill",
|
||||
cladiriLine: "layer-cladiri-line",
|
||||
selectionFill: "layer-selection-fill",
|
||||
selectionLine: "layer-selection-line",
|
||||
} as const;
|
||||
|
||||
/** Basemap tile definitions */
|
||||
const BASEMAP_TILES: Record<BasemapId, { tiles: string[]; attribution: string; tileSize: number }> = {
|
||||
osm: {
|
||||
tiles: [
|
||||
"https://a.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
"https://b.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
"https://c.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
],
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
tileSize: 256,
|
||||
},
|
||||
satellite: {
|
||||
tiles: [
|
||||
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
||||
],
|
||||
attribution: '© <a href="https://www.esri.com">Esri</a>, Maxar, Earthstar Geographics',
|
||||
tileSize: 256,
|
||||
},
|
||||
topo: {
|
||||
tiles: [
|
||||
"https://a.tile.opentopomap.org/{z}/{x}/{y}.png",
|
||||
"https://b.tile.opentopomap.org/{z}/{x}/{y}.png",
|
||||
"https://c.tile.opentopomap.org/{z}/{x}/{y}.png",
|
||||
],
|
||||
attribution:
|
||||
'© <a href="https://opentopomap.org">OpenTopoMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)',
|
||||
tileSize: 256,
|
||||
},
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Props */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -46,6 +78,7 @@ export type MapViewerHandle = {
|
||||
getMap: () => maplibregl.Map | null;
|
||||
setLayerVisibility: (visibility: LayerVisibility) => void;
|
||||
flyTo: (center: [number, number], zoom?: number) => void;
|
||||
clearSelection: () => void;
|
||||
};
|
||||
|
||||
type MapViewerProps = {
|
||||
@@ -53,7 +86,10 @@ type MapViewerProps = {
|
||||
zoom?: number;
|
||||
martinUrl?: string;
|
||||
className?: string;
|
||||
basemap?: BasemapId;
|
||||
selectionMode?: boolean;
|
||||
onFeatureClick?: (feature: ClickedFeature) => void;
|
||||
onSelectionChange?: (features: SelectedFeature[]) => void;
|
||||
/** External layer visibility control */
|
||||
layerVisibility?: LayerVisibility;
|
||||
};
|
||||
@@ -86,7 +122,10 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
||||
zoom,
|
||||
martinUrl,
|
||||
className,
|
||||
basemap = "osm",
|
||||
selectionMode = false,
|
||||
onFeatureClick,
|
||||
onSelectionChange,
|
||||
layerVisibility,
|
||||
},
|
||||
ref
|
||||
@@ -94,9 +133,49 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
const popupRef = useRef<maplibregl.Popup | null>(null);
|
||||
const selectedRef = useRef<Map<string, SelectedFeature>>(new Map());
|
||||
const selectionModeRef = useRef(selectionMode);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
|
||||
const resolvedMartinUrl = martinUrl ?? DEFAULT_MARTIN_URL;
|
||||
// Keep ref in sync
|
||||
selectionModeRef.current = selectionMode;
|
||||
|
||||
// MapLibre web workers can't resolve relative URLs — need absolute
|
||||
const resolvedMartinUrl = (() => {
|
||||
const raw = martinUrl ?? DEFAULT_MARTIN_URL;
|
||||
if (raw.startsWith("http")) return raw;
|
||||
if (typeof window !== "undefined") return `${window.location.origin}${raw}`;
|
||||
return raw;
|
||||
})();
|
||||
|
||||
/* ---- Selection helpers ---- */
|
||||
const updateSelectionFilter = useCallback(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const ids = Array.from(selectedRef.current.keys());
|
||||
// Use objectId matching for the selection highlight layer
|
||||
const filter: maplibregl.FilterSpecification =
|
||||
ids.length > 0
|
||||
? ["in", ["to-string", ["get", "object_id"]], ["literal", ids]]
|
||||
: ["==", "object_id", "__NONE__"];
|
||||
|
||||
try {
|
||||
if (map.getLayer(LAYER_IDS.selectionFill)) {
|
||||
map.setFilter(LAYER_IDS.selectionFill, filter);
|
||||
}
|
||||
if (map.getLayer(LAYER_IDS.selectionLine)) {
|
||||
map.setFilter(LAYER_IDS.selectionLine, filter);
|
||||
}
|
||||
} catch {
|
||||
// layers might not exist yet
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
selectedRef.current.clear();
|
||||
updateSelectionFilter();
|
||||
onSelectionChange?.([]);
|
||||
}, [updateSelectionFilter, onSelectionChange]);
|
||||
|
||||
/* ---- Imperative handle ---- */
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -107,6 +186,7 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
||||
flyTo: (c: [number, number], z?: number) => {
|
||||
mapRef.current?.flyTo({ center: c, zoom: z ?? 14, duration: 1500 });
|
||||
},
|
||||
clearSelection,
|
||||
}));
|
||||
|
||||
/* ---- Apply layer visibility ---- */
|
||||
@@ -139,32 +219,69 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
||||
}
|
||||
}, [mapReady, layerVisibility, applyLayerVisibility]);
|
||||
|
||||
/* ---- Basemap switching ---- */
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !mapReady) return;
|
||||
|
||||
const source = map.getSource("basemap") as maplibregl.RasterTileSource | undefined;
|
||||
if (!source) return;
|
||||
|
||||
const def = BASEMAP_TILES[basemap];
|
||||
// Update tiles by re-adding the source
|
||||
// MapLibre doesn't support changing tiles on existing source, so we rebuild
|
||||
try {
|
||||
// Remove all layers that depend on basemap source, then remove source
|
||||
if (map.getLayer("basemap-tiles")) map.removeLayer("basemap-tiles");
|
||||
map.removeSource("basemap");
|
||||
|
||||
map.addSource("basemap", {
|
||||
type: "raster",
|
||||
tiles: def.tiles,
|
||||
tileSize: def.tileSize,
|
||||
attribution: def.attribution,
|
||||
});
|
||||
|
||||
// Re-add basemap layer at bottom
|
||||
const firstLayerId = map.getStyle().layers[0]?.id;
|
||||
map.addLayer(
|
||||
{
|
||||
id: "basemap-tiles",
|
||||
type: "raster",
|
||||
source: "basemap",
|
||||
minzoom: 0,
|
||||
maxzoom: 19,
|
||||
},
|
||||
firstLayerId // insert before first existing layer
|
||||
);
|
||||
} catch {
|
||||
// Fallback: if anything fails, the map still works
|
||||
}
|
||||
}, [basemap, mapReady]);
|
||||
|
||||
/* ---- Map initialization ---- */
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const initialBasemap = BASEMAP_TILES[basemap];
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {
|
||||
osm: {
|
||||
basemap: {
|
||||
type: "raster",
|
||||
tiles: [
|
||||
"https://a.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
"https://b.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
"https://c.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
],
|
||||
tileSize: 256,
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
tiles: initialBasemap.tiles,
|
||||
tileSize: initialBasemap.tileSize,
|
||||
attribution: initialBasemap.attribution,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: "osm-tiles",
|
||||
id: "basemap-tiles",
|
||||
type: "raster",
|
||||
source: "osm",
|
||||
source: "basemap",
|
||||
minzoom: 0,
|
||||
maxzoom: 19,
|
||||
},
|
||||
@@ -180,13 +297,6 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
||||
/* ---- Controls ---- */
|
||||
map.addControl(new maplibregl.NavigationControl(), "top-right");
|
||||
map.addControl(new maplibregl.ScaleControl({ unit: "metric" }), "bottom-left");
|
||||
map.addControl(
|
||||
new maplibregl.GeolocateControl({
|
||||
positionOptions: { enableHighAccuracy: true },
|
||||
trackUserLocation: false,
|
||||
}),
|
||||
"top-right"
|
||||
);
|
||||
|
||||
/* ---- Add Martin sources + layers on load ---- */
|
||||
map.on("load", () => {
|
||||
@@ -254,7 +364,7 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
||||
minzoom: 13,
|
||||
paint: {
|
||||
"fill-color": "#22c55e",
|
||||
"fill-opacity": 0.4,
|
||||
"fill-opacity": 0.15,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -265,7 +375,7 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
||||
"source-layer": SOURCES.terenuri,
|
||||
minzoom: 13,
|
||||
paint: {
|
||||
"line-color": "#1a1a1a",
|
||||
"line-color": "#15803d",
|
||||
"line-width": 0.8,
|
||||
},
|
||||
});
|
||||
@@ -302,6 +412,34 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
||||
},
|
||||
});
|
||||
|
||||
// --- Selection highlight layer (uses same sources) ---
|
||||
// We add a highlight layer on top for terenuri (primary selection target)
|
||||
map.addLayer({
|
||||
id: LAYER_IDS.selectionFill,
|
||||
type: "fill",
|
||||
source: SOURCES.terenuri,
|
||||
"source-layer": SOURCES.terenuri,
|
||||
minzoom: 13,
|
||||
filter: ["==", "object_id", "__NONE__"], // empty initially
|
||||
paint: {
|
||||
"fill-color": "#f59e0b",
|
||||
"fill-opacity": 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: LAYER_IDS.selectionLine,
|
||||
type: "line",
|
||||
source: SOURCES.terenuri,
|
||||
"source-layer": SOURCES.terenuri,
|
||||
minzoom: 13,
|
||||
filter: ["==", "object_id", "__NONE__"],
|
||||
paint: {
|
||||
"line-color": "#d97706",
|
||||
"line-width": 2.5,
|
||||
},
|
||||
});
|
||||
|
||||
// Apply initial visibility if provided
|
||||
if (layerVisibility) {
|
||||
applyLayerVisibility(layerVisibility);
|
||||
@@ -319,7 +457,13 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
||||
|
||||
map.on("click", (e) => {
|
||||
const features = map.queryRenderedFeatures(e.point, {
|
||||
layers: clickableLayers,
|
||||
layers: clickableLayers.filter((l) => {
|
||||
try {
|
||||
return !!map.getLayer(l);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
// Close existing popup
|
||||
@@ -336,6 +480,25 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
||||
const props = (first.properties ?? {}) as Record<string, unknown>;
|
||||
const sourceLayer = first.sourceLayer ?? first.source ?? "";
|
||||
|
||||
// Selection mode: toggle feature in selection
|
||||
if (selectionModeRef.current && sourceLayer === SOURCES.terenuri) {
|
||||
const objectId = String(props.object_id ?? props.objectId ?? "");
|
||||
if (!objectId) return;
|
||||
|
||||
if (selectedRef.current.has(objectId)) {
|
||||
selectedRef.current.delete(objectId);
|
||||
} else {
|
||||
selectedRef.current.set(objectId, {
|
||||
id: objectId,
|
||||
sourceLayer,
|
||||
properties: props,
|
||||
});
|
||||
}
|
||||
updateSelectionFilter();
|
||||
onSelectionChange?.(Array.from(selectedRef.current.values()));
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify parent
|
||||
if (onFeatureClick) {
|
||||
onFeatureClick({
|
||||
@@ -346,7 +509,7 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
||||
});
|
||||
}
|
||||
|
||||
// Show popup
|
||||
// Show popup (only in non-selection mode)
|
||||
const popup = new maplibregl.Popup({
|
||||
maxWidth: "360px",
|
||||
closeButton: true,
|
||||
|
||||
Reference in New Issue
Block a user