437d734df6
- Static CSS import doesn't work with next/dynamic + standalone output. Now injects a <link> tag to unpkg CDN at module load time (bulletproof). - Geoportal is now fullscreen: map fills entire viewport below the header, no duplicate title/description, negative margins bleed to edges. - Removed page-level CSS imports (no longer needed). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
583 lines
18 KiB
TypeScript
583 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useEffect, useState, useCallback, useImperativeHandle, forwardRef } from "react";
|
|
import maplibregl from "maplibre-gl";
|
|
import { cn } from "@/shared/lib/utils";
|
|
|
|
/* Ensure MapLibre CSS is loaded — static import fails with next/dynamic + standalone */
|
|
if (typeof document !== "undefined") {
|
|
const LINK_ID = "maplibre-gl-css";
|
|
if (!document.getElementById(LINK_ID)) {
|
|
const link = document.createElement("link");
|
|
link.id = LINK_ID;
|
|
link.rel = "stylesheet";
|
|
link.href = "https://unpkg.com/maplibre-gl@5.21.0/dist/maplibre-gl.css";
|
|
document.head.appendChild(link);
|
|
}
|
|
}
|
|
import type { BasemapId, ClickedFeature, LayerVisibility, SelectedFeature } from "../types";
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Constants */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/**
|
|
* 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 = process.env.NEXT_PUBLIC_MARTIN_URL || "/tiles";
|
|
|
|
/** Default center: Romania roughly centered */
|
|
const DEFAULT_CENTER: [number, number] = [23.8, 46.1];
|
|
const DEFAULT_ZOOM = 7;
|
|
|
|
/** Source/layer IDs used on the map */
|
|
const SOURCES = {
|
|
uats: "gis_uats",
|
|
terenuri: "gis_terenuri",
|
|
cladiri: "gis_cladiri",
|
|
} as const;
|
|
|
|
/** Map layer IDs (prefixed to avoid collisions) */
|
|
const LAYER_IDS = {
|
|
uatsFill: "layer-uats-fill",
|
|
uatsLine: "layer-uats-line",
|
|
uatsLabel: "layer-uats-label",
|
|
terenuriFill: "layer-terenuri-fill",
|
|
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 */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export type MapViewerHandle = {
|
|
getMap: () => maplibregl.Map | null;
|
|
setLayerVisibility: (visibility: LayerVisibility) => void;
|
|
flyTo: (center: [number, number], zoom?: number) => void;
|
|
clearSelection: () => void;
|
|
};
|
|
|
|
type MapViewerProps = {
|
|
center?: [number, number];
|
|
zoom?: number;
|
|
martinUrl?: string;
|
|
className?: string;
|
|
basemap?: BasemapId;
|
|
selectionMode?: boolean;
|
|
onFeatureClick?: (feature: ClickedFeature) => void;
|
|
onSelectionChange?: (features: SelectedFeature[]) => void;
|
|
/** External layer visibility control */
|
|
layerVisibility?: LayerVisibility;
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Helpers */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function formatPopupContent(properties: Record<string, unknown>): string {
|
|
const rows: string[] = [];
|
|
for (const [key, value] of Object.entries(properties)) {
|
|
if (value == null || value === "") continue;
|
|
const displayKey = key.replace(/_/g, " ");
|
|
rows.push(
|
|
`<tr><td style="font-weight:600;padding:2px 8px 2px 0;vertical-align:top;white-space:nowrap;color:#64748b">${displayKey}</td><td style="padding:2px 0">${String(value)}</td></tr>`
|
|
);
|
|
}
|
|
if (rows.length === 0) return "<p style='color:#94a3b8'>Fara atribute</p>";
|
|
return `<table style="font-size:13px;line-height:1.4">${rows.join("")}</table>`;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Component */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|
function MapViewer(
|
|
{
|
|
center,
|
|
zoom,
|
|
martinUrl,
|
|
className,
|
|
basemap = "osm",
|
|
selectionMode = false,
|
|
onFeatureClick,
|
|
onSelectionChange,
|
|
layerVisibility,
|
|
},
|
|
ref
|
|
) {
|
|
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);
|
|
|
|
// 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, () => ({
|
|
getMap: () => mapRef.current,
|
|
setLayerVisibility: (vis: LayerVisibility) => {
|
|
applyLayerVisibility(vis);
|
|
},
|
|
flyTo: (c: [number, number], z?: number) => {
|
|
mapRef.current?.flyTo({ center: c, zoom: z ?? 14, duration: 1500 });
|
|
},
|
|
clearSelection,
|
|
}));
|
|
|
|
/* ---- Apply layer visibility ---- */
|
|
const applyLayerVisibility = useCallback((vis: LayerVisibility) => {
|
|
const map = mapRef.current;
|
|
if (!map || !map.isStyleLoaded()) return;
|
|
|
|
const mapping: Record<string, string[]> = {
|
|
uats: [LAYER_IDS.uatsFill, LAYER_IDS.uatsLine, LAYER_IDS.uatsLabel],
|
|
terenuri: [LAYER_IDS.terenuriFill, LAYER_IDS.terenuriLine],
|
|
cladiri: [LAYER_IDS.cladiriFill, LAYER_IDS.cladiriLine],
|
|
};
|
|
|
|
for (const [group, layerIds] of Object.entries(mapping)) {
|
|
const visible = vis[group] !== false; // default visible
|
|
for (const lid of layerIds) {
|
|
try {
|
|
map.setLayoutProperty(lid, "visibility", visible ? "visible" : "none");
|
|
} catch {
|
|
// layer might not exist yet
|
|
}
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
/* ---- Sync external visibility prop ---- */
|
|
useEffect(() => {
|
|
if (mapReady && layerVisibility) {
|
|
applyLayerVisibility(layerVisibility);
|
|
}
|
|
}, [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: {
|
|
basemap: {
|
|
type: "raster",
|
|
tiles: initialBasemap.tiles,
|
|
tileSize: initialBasemap.tileSize,
|
|
attribution: initialBasemap.attribution,
|
|
},
|
|
},
|
|
layers: [
|
|
{
|
|
id: "basemap-tiles",
|
|
type: "raster",
|
|
source: "basemap",
|
|
minzoom: 0,
|
|
maxzoom: 19,
|
|
},
|
|
],
|
|
},
|
|
center: center ?? DEFAULT_CENTER,
|
|
zoom: zoom ?? DEFAULT_ZOOM,
|
|
maxZoom: 20,
|
|
});
|
|
|
|
mapRef.current = map;
|
|
|
|
/* ---- Controls ---- */
|
|
map.addControl(new maplibregl.NavigationControl(), "top-right");
|
|
map.addControl(new maplibregl.ScaleControl({ unit: "metric" }), "bottom-left");
|
|
|
|
/* ---- Add Martin sources + layers on load ---- */
|
|
map.on("load", () => {
|
|
// --- UAT boundaries ---
|
|
map.addSource(SOURCES.uats, {
|
|
type: "vector",
|
|
tiles: [`${resolvedMartinUrl}/${SOURCES.uats}/{z}/{x}/{y}.pbf`],
|
|
minzoom: 0,
|
|
maxzoom: 16,
|
|
});
|
|
|
|
map.addLayer({
|
|
id: LAYER_IDS.uatsFill,
|
|
type: "fill",
|
|
source: SOURCES.uats,
|
|
"source-layer": SOURCES.uats,
|
|
paint: {
|
|
"fill-color": "#8b5cf6",
|
|
"fill-opacity": 0.05,
|
|
},
|
|
});
|
|
|
|
map.addLayer({
|
|
id: LAYER_IDS.uatsLine,
|
|
type: "line",
|
|
source: SOURCES.uats,
|
|
"source-layer": SOURCES.uats,
|
|
paint: {
|
|
"line-color": "#7c3aed",
|
|
"line-width": 1.5,
|
|
},
|
|
});
|
|
|
|
map.addLayer({
|
|
id: LAYER_IDS.uatsLabel,
|
|
type: "symbol",
|
|
source: SOURCES.uats,
|
|
"source-layer": SOURCES.uats,
|
|
layout: {
|
|
"text-field": ["coalesce", ["get", "name"], ["get", "uat_name"], ""],
|
|
"text-size": 12,
|
|
"text-anchor": "center",
|
|
"text-allow-overlap": false,
|
|
},
|
|
paint: {
|
|
"text-color": "#5b21b6",
|
|
"text-halo-color": "#ffffff",
|
|
"text-halo-width": 1.5,
|
|
},
|
|
});
|
|
|
|
// --- Terenuri (parcels) ---
|
|
map.addSource(SOURCES.terenuri, {
|
|
type: "vector",
|
|
tiles: [`${resolvedMartinUrl}/${SOURCES.terenuri}/{z}/{x}/{y}.pbf`],
|
|
minzoom: 10,
|
|
maxzoom: 18,
|
|
});
|
|
|
|
map.addLayer({
|
|
id: LAYER_IDS.terenuriFill,
|
|
type: "fill",
|
|
source: SOURCES.terenuri,
|
|
"source-layer": SOURCES.terenuri,
|
|
minzoom: 13,
|
|
paint: {
|
|
"fill-color": "#22c55e",
|
|
"fill-opacity": 0.15,
|
|
},
|
|
});
|
|
|
|
map.addLayer({
|
|
id: LAYER_IDS.terenuriLine,
|
|
type: "line",
|
|
source: SOURCES.terenuri,
|
|
"source-layer": SOURCES.terenuri,
|
|
minzoom: 13,
|
|
paint: {
|
|
"line-color": "#15803d",
|
|
"line-width": 0.8,
|
|
},
|
|
});
|
|
|
|
// --- Cladiri (buildings) ---
|
|
map.addSource(SOURCES.cladiri, {
|
|
type: "vector",
|
|
tiles: [`${resolvedMartinUrl}/${SOURCES.cladiri}/{z}/{x}/{y}.pbf`],
|
|
minzoom: 12,
|
|
maxzoom: 18,
|
|
});
|
|
|
|
map.addLayer({
|
|
id: LAYER_IDS.cladiriFill,
|
|
type: "fill",
|
|
source: SOURCES.cladiri,
|
|
"source-layer": SOURCES.cladiri,
|
|
minzoom: 14,
|
|
paint: {
|
|
"fill-color": "#3b82f6",
|
|
"fill-opacity": 0.5,
|
|
},
|
|
});
|
|
|
|
map.addLayer({
|
|
id: LAYER_IDS.cladiriLine,
|
|
type: "line",
|
|
source: SOURCES.cladiri,
|
|
"source-layer": SOURCES.cladiri,
|
|
minzoom: 14,
|
|
paint: {
|
|
"line-color": "#1e3a5f",
|
|
"line-width": 0.6,
|
|
},
|
|
});
|
|
|
|
// --- 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);
|
|
}
|
|
|
|
setMapReady(true);
|
|
});
|
|
|
|
/* ---- Click handler ---- */
|
|
const clickableLayers = [
|
|
LAYER_IDS.terenuriFill,
|
|
LAYER_IDS.cladiriFill,
|
|
LAYER_IDS.uatsFill,
|
|
];
|
|
|
|
map.on("click", (e) => {
|
|
const features = map.queryRenderedFeatures(e.point, {
|
|
layers: clickableLayers.filter((l) => {
|
|
try {
|
|
return !!map.getLayer(l);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}),
|
|
});
|
|
|
|
// Close existing popup
|
|
if (popupRef.current) {
|
|
popupRef.current.remove();
|
|
popupRef.current = null;
|
|
}
|
|
|
|
if (features.length === 0) return;
|
|
|
|
const first = features[0];
|
|
if (!first) return;
|
|
|
|
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({
|
|
layerId: first.layer?.id ?? "",
|
|
sourceLayer,
|
|
properties: props,
|
|
coordinates: [e.lngLat.lng, e.lngLat.lat],
|
|
});
|
|
}
|
|
|
|
// Show popup (only in non-selection mode)
|
|
const popup = new maplibregl.Popup({
|
|
maxWidth: "360px",
|
|
closeButton: true,
|
|
closeOnClick: true,
|
|
})
|
|
.setLngLat(e.lngLat)
|
|
.setHTML(formatPopupContent(props))
|
|
.addTo(map);
|
|
|
|
popupRef.current = popup;
|
|
});
|
|
|
|
/* ---- Cursor change on hover ---- */
|
|
for (const lid of clickableLayers) {
|
|
map.on("mouseenter", lid, () => {
|
|
map.getCanvas().style.cursor = "pointer";
|
|
});
|
|
map.on("mouseleave", lid, () => {
|
|
map.getCanvas().style.cursor = "";
|
|
});
|
|
}
|
|
|
|
/* ---- Cleanup ---- */
|
|
return () => {
|
|
if (popupRef.current) {
|
|
popupRef.current.remove();
|
|
popupRef.current = null;
|
|
}
|
|
map.remove();
|
|
mapRef.current = null;
|
|
setMapReady(false);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [resolvedMartinUrl]);
|
|
|
|
/* ---- Sync center/zoom prop changes ---- */
|
|
useEffect(() => {
|
|
if (!mapReady || !mapRef.current) return;
|
|
if (center) {
|
|
mapRef.current.flyTo({
|
|
center,
|
|
zoom: zoom ?? mapRef.current.getZoom(),
|
|
duration: 1500,
|
|
});
|
|
}
|
|
}, [center, zoom, mapReady]);
|
|
|
|
return (
|
|
<div className={cn("relative w-full h-full min-h-[400px]", className)}>
|
|
<div ref={containerRef} className="absolute inset-0" />
|
|
{!mapReady && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-muted/50">
|
|
<p className="text-sm text-muted-foreground">Se incarca harta...</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
);
|