fix(geoportal): simplified info panel, preserve basemap zoom, DXF export, intravilan outline

- Feature panel: simplified (NR_CAD/NR_CF/SIRUTA/Suprafata/Proprietari),
  aligned top-right under basemap switcher, click empty space to close
- Basemap switch: preserves zoom+center via viewStateRef + moveend listener
- DXF export: use -s_srs + -t_srs (not -a_srs + -t_srs which ogr2ogr rejects)
- Intravilan: double line (black outer + orange inner), z13+, no fill
- Parcel labels: cadastral_ref shown at z16+
- UAT z12: original geometry (no simplification)
- Removed MapLibre popup (only side panel)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-24 07:43:11 +02:00
parent b38916229e
commit 78625d6415
5 changed files with 215 additions and 660 deletions
+2 -3
View File
@@ -182,10 +182,9 @@ SELECT siruta, name, county,
ST_SimplifyPreserveTopology(geom, 50) AS geom ST_SimplifyPreserveTopology(geom, 50) AS geom
FROM "GisUat" WHERE geom IS NOT NULL; FROM "GisUat" WHERE geom IS NOT NULL;
-- z12+: Fine (10m tolerance) — near-original precision -- z12+: Original geometry — full precision, no simplification
CREATE OR REPLACE VIEW gis_uats_z12 AS CREATE OR REPLACE VIEW gis_uats_z12 AS
SELECT siruta, name, county, SELECT siruta, name, county, geom
ST_SimplifyPreserveTopology(geom, 10) AS geom
FROM "GisUat" WHERE geom IS NOT NULL; FROM "GisUat" WHERE geom IS NOT NULL;
-- Keep the legacy gis_uats view for QGIS compatibility -- Keep the legacy gis_uats view for QGIS compatibility
+4 -11
View File
@@ -133,17 +133,10 @@ export async function POST(req: Request) {
const outputPath = join(tmpDir, `output.${ext}`); const outputPath = join(tmpDir, `output.${ext}`);
const ogrFormat = format === "dxf" ? "DXF" : "GPKG"; const ogrFormat = format === "dxf" ? "DXF" : "GPKG";
// For DXF, convert to WGS84 (architects expect it). For GPKG keep native CRS. // DXF: reproject to WGS84 (-s_srs + -t_srs). GPKG: assign CRS only (-a_srs).
const ogrArgs = [ const ogrArgs = format === "dxf"
"-f", ogrFormat, ? ["-f", ogrFormat, outputPath, inputPath, "-s_srs", "EPSG:3844", "-t_srs", "EPSG:4326"]
outputPath, : ["-f", ogrFormat, outputPath, inputPath, "-a_srs", "EPSG:3844"];
inputPath,
"-a_srs", "EPSG:3844",
];
if (format === "dxf") {
ogrArgs.push("-t_srs", "EPSG:4326");
}
try { try {
await execFileAsync("ogr2ogr", ogrArgs, { timeout: 30_000 }); await execFileAsync("ogr2ogr", ogrArgs, { timeout: 30_000 });
@@ -1,250 +1,109 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import { X, Loader2 } from "lucide-react";
X,
MapPin,
User,
Ruler,
Building2,
FileText,
Loader2,
TreePine,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
import { cn } from "@/shared/lib/utils";
import type { ClickedFeature, FeatureDetail, FeatureEnrichmentData } from "../types"; import type { ClickedFeature, FeatureDetail, FeatureEnrichmentData } from "../types";
type FeatureInfoPanelProps = { type FeatureInfoPanelProps = {
feature: ClickedFeature | null; feature: ClickedFeature | null;
onClose: () => void; onClose: () => void;
className?: string;
}; };
export function FeatureInfoPanel({ export function FeatureInfoPanel({ feature, onClose }: FeatureInfoPanelProps) {
feature,
onClose,
className,
}: FeatureInfoPanelProps) {
const [detail, setDetail] = useState<FeatureDetail | null>(null); const [detail, setDetail] = useState<FeatureDetail | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!feature) { if (!feature) { setDetail(null); return; }
setDetail(null);
return;
}
// Try to load enrichment from API using the object_id from vector tile props
const objectId = feature.properties.object_id ?? feature.properties.objectId; const objectId = feature.properties.object_id ?? feature.properties.objectId;
const siruta = feature.properties.siruta; const siruta = feature.properties.siruta;
if (!objectId || !siruta) { if (!objectId || !siruta) { setDetail(null); return; }
setDetail(null);
return;
}
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
setError(null);
fetch(`/api/geoportal/feature?objectId=${objectId}&siruta=${siruta}&sourceLayer=${feature.sourceLayer}`) fetch(`/api/geoportal/feature?objectId=${objectId}&siruta=${siruta}&sourceLayer=${feature.sourceLayer}`)
.then((r) => { .then((r) => r.ok ? r.json() : Promise.reject())
if (!r.ok) throw new Error(`${r.status}`); .then((data: { feature: FeatureDetail }) => { if (!cancelled) setDetail(data.feature); })
return r.json(); .catch(() => { if (!cancelled) setDetail(null); })
}) .finally(() => { if (!cancelled) setLoading(false); });
.then((data: { feature: FeatureDetail }) => {
if (!cancelled) setDetail(data.feature);
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : "Eroare la incarcarea detaliilor");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { return () => { cancelled = true; };
cancelled = true;
};
}, [feature]); }, [feature]);
if (!feature) return null; if (!feature) return null;
const enrichment = detail?.enrichment; const e = detail?.enrichment as FeatureEnrichmentData | null | undefined;
const isUat = feature.sourceLayer?.includes("uat");
const cadRef = e?.NR_CAD ?? feature.properties.cadastral_ref ?? "";
const title = isUat
? String(feature.properties.name ?? "UAT")
: cadRef ? `Parcela ${cadRef}` : `#${feature.properties.object_id ?? "?"}`;
return ( return (
<div <div className="bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg w-72 overflow-hidden">
className={cn(
"bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg w-80 max-h-[calc(100vh-16rem)] overflow-auto",
className
)}
>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-3 border-b sticky top-0 bg-background/95 backdrop-blur-sm z-10"> <div className="flex items-center justify-between px-3 py-2 border-b">
<h3 className="text-sm font-semibold truncate flex-1"> <h3 className="text-sm font-semibold truncate">{title}</h3>
{enrichment?.NR_CAD <Button variant="ghost" size="sm" className="h-6 w-6 p-0 shrink-0 ml-2" onClick={onClose}>
? `Parcela ${enrichment.NR_CAD}`
: feature.sourceLayer === "gis_uats"
? `UAT ${feature.properties.name ?? ""}`
: `Obiect #${feature.properties.object_id ?? feature.properties.objectId ?? "?"}`}
</h3>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 ml-2" onClick={onClose}>
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
{/* Content */} {/* Content */}
<div className="p-3 space-y-3"> <div className="px-3 py-2 text-xs space-y-1">
{loading && ( {loading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4 justify-center"> <div className="flex items-center gap-2 text-muted-foreground py-2 justify-center">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" /> Se incarca...
Se incarca...
</div> </div>
)} )}
{error && ( {!loading && isUat && (
<p className="text-xs text-destructive">Nu s-au putut incarca detaliile ({error})</p> <>
<Row label="UAT" value={feature.properties.name} />
<Row label="SIRUTA" value={feature.properties.siruta} />
<Row label="Judet" value={feature.properties.county} />
</>
)} )}
{/* Basic props from vector tile */} {!loading && !isUat && (
{!loading && !enrichment && ( <>
<PropsTable properties={feature.properties} /> <Row label="UAT" value={feature.properties.siruta} />
<Row label="SIRUTA" value={feature.properties.siruta} />
<Row label="Nr. cadastral" value={e?.NR_CAD ?? cadRef} />
<Row label="Nr. CF" value={e?.NR_CF} />
<Row label="Suprafata" value={formatArea(e?.SUPRAFATA_2D ?? feature.properties.area_value)} />
{e?.PROPRIETARI && e.PROPRIETARI !== "-" && (
<Row label="Proprietari" value={e.PROPRIETARI} />
)}
{e?.INTRAVILAN && e.INTRAVILAN !== "-" && (
<Row label="Intravilan" value={e.INTRAVILAN} />
)}
{e?.CATEGORIE_FOLOSINTA && e.CATEGORIE_FOLOSINTA !== "-" && (
<Row label="Categorie" value={e.CATEGORIE_FOLOSINTA} />
)}
</>
)} )}
{/* Enrichment data */}
{enrichment && <EnrichmentView data={enrichment} />}
{/* Coordinates */}
<div className="text-xs text-muted-foreground pt-1 border-t">
<MapPin className="h-3 w-3 inline mr-1" />
{feature.coordinates[1].toFixed(5)}, {feature.coordinates[0].toFixed(5)}
</div>
</div> </div>
</div> </div>
); );
} }
/* ------------------------------------------------------------------ */ function Row({ label, value }: { label: string; value: unknown }) {
/* Enrichment view */
/* ------------------------------------------------------------------ */
function EnrichmentView({ data }: { data: FeatureEnrichmentData }) {
return (
<div className="space-y-2.5">
{/* Cadastral info */}
<Section icon={FileText} label="Cadastru">
<Row label="Nr. cadastral" value={data.NR_CAD} />
<Row label="Nr. CF" value={data.NR_CF} />
{data.NR_CF_VECHI && data.NR_CF_VECHI !== "-" && (
<Row label="CF vechi" value={data.NR_CF_VECHI} />
)}
{data.NR_TOPO && data.NR_TOPO !== "-" && (
<Row label="Nr. topo" value={data.NR_TOPO} />
)}
</Section>
{/* Owners */}
{data.PROPRIETARI && data.PROPRIETARI !== "-" && (
<Section icon={User} label="Proprietari">
<p className="text-xs leading-relaxed">{data.PROPRIETARI}</p>
{data.PROPRIETARI_VECHI && data.PROPRIETARI_VECHI !== "-" && (
<p className="text-xs text-muted-foreground mt-1">
Anterior: {data.PROPRIETARI_VECHI}
</p>
)}
</Section>
)}
{/* Area */}
<Section icon={Ruler} label="Suprafata">
<Row label="Masurata" value={formatArea(data.SUPRAFATA_2D)} />
<Row label="Rotunjita" value={formatArea(data.SUPRAFATA_R)} />
</Section>
{/* Land use */}
<Section icon={TreePine} label="Folosinta">
<Row label="Categorie" value={data.CATEGORIE_FOLOSINTA} />
<Row
label="Intravilan"
value={
<Badge variant={data.INTRAVILAN === "DA" ? "default" : "secondary"} className="text-xs h-5">
{data.INTRAVILAN || "-"}
</Badge>
}
/>
</Section>
{/* Building */}
{data.HAS_BUILDING === 1 && (
<Section icon={Building2} label="Constructie">
<Row label="Autorizata" value={data.BUILD_LEGAL ? "Da" : "Nu"} />
</Section>
)}
{/* Address */}
{data.ADRESA && data.ADRESA !== "-" && (
<Section icon={MapPin} label="Adresa">
<p className="text-xs">{data.ADRESA}</p>
</Section>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function Section({
icon: Icon,
label,
children,
}: {
icon: typeof FileText;
label: string;
children: React.ReactNode;
}) {
return (
<div>
<div className="flex items-center gap-1.5 mb-1">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{label}
</span>
</div>
<div className="ml-5 space-y-0.5">{children}</div>
</div>
);
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
if (!value || value === "-" || value === "") return null; if (!value || value === "-" || value === "") return null;
return ( return (
<div className="flex justify-between text-xs gap-2"> <div className="flex justify-between gap-2">
<span className="text-muted-foreground shrink-0">{label}</span> <span className="text-muted-foreground shrink-0">{label}</span>
<span className="text-right font-medium truncate">{typeof value === "string" ? value : value}</span> <span className="text-right font-medium truncate">{String(value)}</span>
</div> </div>
); );
} }
function PropsTable({ properties }: { properties: Record<string, unknown> }) { function formatArea(v: unknown): string {
const entries = Object.entries(properties).filter( if (!v || v === "") return "";
([, v]) => v != null && v !== "" const n = typeof v === "number" ? v : parseFloat(String(v));
);
if (entries.length === 0) return <p className="text-xs text-muted-foreground">Fara atribute</p>;
return (
<div className="space-y-0.5">
{entries.map(([key, value]) => (
<Row key={key} label={key.replace(/_/g, " ")} value={String(value)} />
))}
</div>
);
}
function formatArea(v: number | string): string {
if (v === "" || v == null) return "-";
const n = typeof v === "string" ? parseFloat(v) : v;
if (isNaN(n)) return String(v); if (isNaN(n)) return String(v);
return `${n.toLocaleString("ro-RO")} mp`; return `${n.toLocaleString("ro-RO")} mp`;
} }
@@ -9,19 +9,11 @@ import { SelectionToolbar } from "./selection-toolbar";
import { FeatureInfoPanel } from "./feature-info-panel"; import { FeatureInfoPanel } from "./feature-info-panel";
import type { MapViewerHandle } from "./map-viewer"; import type { MapViewerHandle } from "./map-viewer";
import type { import type {
BasemapId, BasemapId, ClickedFeature, LayerVisibility, SearchResult, SelectedFeature,
ClickedFeature,
LayerVisibility,
SearchResult,
SelectedFeature,
} from "../types"; } from "../types";
/* MapLibre uses WebGL — must disable SSR */
const MapViewer = dynamic( const MapViewer = dynamic(
() => () => import("./map-viewer").then((m) => ({ default: m.MapViewer })),
import("./map-viewer").then((m) => ({
default: m.MapViewer,
})),
{ {
ssr: false, ssr: false,
loading: () => ( loading: () => (
@@ -32,54 +24,33 @@ const MapViewer = dynamic(
} }
); );
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function GeoportalModule() { export function GeoportalModule() {
const mapHandleRef = useRef<MapViewerHandle>(null); const mapHandleRef = useRef<MapViewerHandle>(null);
// Map state
const [basemap, setBasemap] = useState<BasemapId>("liberty"); const [basemap, setBasemap] = useState<BasemapId>("liberty");
const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>( const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>(getDefaultVisibility);
getDefaultVisibility
);
// Feature info
const [clickedFeature, setClickedFeature] = useState<ClickedFeature | null>(null); const [clickedFeature, setClickedFeature] = useState<ClickedFeature | null>(null);
// Selection
const [selectionMode, setSelectionMode] = useState(false); const [selectionMode, setSelectionMode] = useState(false);
const [selectedFeatures, setSelectedFeatures] = useState<SelectedFeature[]>([]); const [selectedFeatures, setSelectedFeatures] = useState<SelectedFeature[]>([]);
// Fly-to target (from search)
const [flyTarget, setFlyTarget] = useState<{ center: [number, number]; zoom?: number } | undefined>(); const [flyTarget, setFlyTarget] = useState<{ center: [number, number]; zoom?: number } | undefined>();
const handleFeatureClick = useCallback((feature: ClickedFeature) => { const handleFeatureClick = useCallback((feature: ClickedFeature | null) => {
// null = clicked on empty space, close panel
if (!feature || !feature.properties) {
setClickedFeature(null);
return;
}
setClickedFeature(feature); setClickedFeature(feature);
}, []); }, []);
const handleVisibilityChange = useCallback((vis: LayerVisibility) => {
setLayerVisibility(vis);
}, []);
const handleSearchResult = useCallback((result: SearchResult) => { const handleSearchResult = useCallback((result: SearchResult) => {
if (result.coordinates) { if (result.coordinates) {
setFlyTarget({ setFlyTarget({ center: result.coordinates, zoom: result.type === "uat" ? 12 : 17 });
center: result.coordinates,
zoom: result.type === "uat" ? 12 : 17,
});
} }
}, []); }, []);
const handleSelectionChange = useCallback((features: SelectedFeature[]) => {
setSelectedFeatures(features);
}, []);
const handleToggleSelectionMode = useCallback(() => { const handleToggleSelectionMode = useCallback(() => {
setSelectionMode((prev) => { setSelectionMode((prev) => {
if (prev) { if (prev) {
// Turning off: clear selection
mapHandleRef.current?.clearSelection(); mapHandleRef.current?.clearSelection();
setSelectedFeatures([]); setSelectedFeatures([]);
} }
@@ -87,38 +58,32 @@ export function GeoportalModule() {
}); });
}, []); }, []);
const handleClearSelection = useCallback(() => {
mapHandleRef.current?.clearSelection();
setSelectedFeatures([]);
}, []);
return ( return (
<div className="absolute inset-0"> <div className="absolute inset-0">
{/* Full-bleed map */}
<MapViewer <MapViewer
ref={mapHandleRef} ref={mapHandleRef}
className="h-full w-full" className="h-full w-full"
basemap={basemap} basemap={basemap}
selectionMode={selectionMode} selectionMode={selectionMode}
onFeatureClick={handleFeatureClick} onFeatureClick={handleFeatureClick}
onSelectionChange={handleSelectionChange} onSelectionChange={setSelectedFeatures}
layerVisibility={layerVisibility} layerVisibility={layerVisibility}
center={flyTarget?.center} center={flyTarget?.center}
zoom={flyTarget?.zoom} zoom={flyTarget?.zoom}
/> />
{/* Top-left controls: search + layers */} {/* Top-left: search + layers */}
<div className="absolute top-3 left-3 z-10 flex flex-col gap-2 max-w-xs"> <div className="absolute top-3 left-3 z-10 flex flex-col gap-2 max-w-xs">
<SearchBar onResultSelect={handleSearchResult} /> <SearchBar onResultSelect={handleSearchResult} />
<LayerPanel <LayerPanel visibility={layerVisibility} onVisibilityChange={setLayerVisibility} />
visibility={layerVisibility}
onVisibilityChange={handleVisibilityChange}
/>
</div> </div>
{/* Top-right: basemap switcher (offset to avoid nav controls) */} {/* Top-right: basemap switcher + feature panel (aligned) */}
<div className="absolute top-3 right-14 z-10"> <div className="absolute top-3 right-14 z-10 flex flex-col items-end gap-2">
<BasemapSwitcher value={basemap} onChange={setBasemap} /> <BasemapSwitcher value={basemap} onChange={setBasemap} />
{clickedFeature && !selectionMode && (
<FeatureInfoPanel feature={clickedFeature} onClose={() => setClickedFeature(null)} />
)}
</div> </div>
{/* Bottom-left: selection toolbar */} {/* Bottom-left: selection toolbar */}
@@ -127,19 +92,9 @@ export function GeoportalModule() {
selectedFeatures={selectedFeatures} selectedFeatures={selectedFeatures}
selectionMode={selectionMode} selectionMode={selectionMode}
onToggleSelectionMode={handleToggleSelectionMode} onToggleSelectionMode={handleToggleSelectionMode}
onClearSelection={handleClearSelection} onClearSelection={() => { mapHandleRef.current?.clearSelection(); setSelectedFeatures([]); }}
/> />
</div> </div>
{/* Right side: feature info panel */}
{clickedFeature && !selectionMode && (
<div className="absolute top-16 right-3 z-10">
<FeatureInfoPanel
feature={clickedFeature}
onClose={() => setClickedFeature(null)}
/>
</div>
)}
</div> </div>
); );
} }
+139 -390
View File
@@ -4,8 +4,7 @@ import { useRef, useEffect, useState, useCallback, useImperativeHandle, forwardR
import maplibregl from "maplibre-gl"; import maplibregl from "maplibre-gl";
import { cn } from "@/shared/lib/utils"; import { cn } from "@/shared/lib/utils";
/* Ensure MapLibre CSS is loaded — static import fails with next/dynamic + standalone. /* Ensure MapLibre CSS is loaded — static import fails with next/dynamic + standalone */
Load from /public to avoid CDN/CSP issues. */
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
const LINK_ID = "maplibre-gl-css"; const LINK_ID = "maplibre-gl-css";
if (!document.getElementById(LINK_ID)) { if (!document.getElementById(LINK_ID)) {
@@ -22,68 +21,53 @@ import type { BasemapId, ClickedFeature, LayerVisibility, SelectedFeature } from
/* Constants */ /* 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"; 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_CENTER: [number, number] = [23.8, 46.1];
const DEFAULT_ZOOM = 7; const DEFAULT_ZOOM = 7;
/** Source/layer IDs used on the map */
const SOURCES = { const SOURCES = {
uatsZ0: "gis_uats_z0", // z0-5: 2000m simplification uatsZ0: "gis_uats_z0",
uatsZ5: "gis_uats_z5", // z5-8: 500m uatsZ5: "gis_uats_z5",
uatsZ8: "gis_uats_z8", // z8-12: 50m uatsZ8: "gis_uats_z8",
uatsZ12: "gis_uats_z12", // z12+: 10m (near-original) uatsZ12: "gis_uats_z12",
terenuri: "gis_terenuri", terenuri: "gis_terenuri",
cladiri: "gis_cladiri", cladiri: "gis_cladiri",
administrativ: "gis_administrativ", administrativ: "gis_administrativ",
} as const; } as const;
/** Map layer IDs (prefixed to avoid collisions) */
const LAYER_IDS = { const LAYER_IDS = {
uatsZ0Line: "layer-uats-z0-line", uatsZ0Line: "l-uats-z0-line",
uatsZ5Fill: "layer-uats-z5-fill", uatsZ5Fill: "l-uats-z5-fill",
uatsZ5Line: "layer-uats-z5-line", uatsZ5Line: "l-uats-z5-line",
uatsZ8Fill: "layer-uats-z8-fill", uatsZ8Fill: "l-uats-z8-fill",
uatsZ8Line: "layer-uats-z8-line", uatsZ8Line: "l-uats-z8-line",
uatsZ8Label: "layer-uats-z8-label", uatsZ8Label: "l-uats-z8-label",
uatsZ12Fill: "layer-uats-z12-fill", uatsZ12Fill: "l-uats-z12-fill",
uatsZ12Line: "layer-uats-z12-line", uatsZ12Line: "l-uats-z12-line",
uatsZ12Label: "layer-uats-z12-label", uatsZ12Label: "l-uats-z12-label",
adminFill: "layer-admin-fill", adminLineOuter: "l-admin-line-outer",
adminLine: "layer-admin-line", adminLineInner: "l-admin-line-inner",
terenuriFill: "layer-terenuri-fill", terenuriFill: "l-terenuri-fill",
terenuriLine: "layer-terenuri-line", terenuriLine: "l-terenuri-line",
cladiriFill: "layer-cladiri-fill", terenuriLabel: "l-terenuri-label",
cladiriLine: "layer-cladiri-line", cladiriFill: "l-cladiri-fill",
selectionFill: "layer-selection-fill", cladiriLine: "l-cladiri-line",
selectionLine: "layer-selection-line", selectionFill: "l-selection-fill",
selectionLine: "l-selection-line",
} as const; } as const;
/** Basemap definitions — vector style URL or inline raster config */ /* ---- Basemap definitions ---- */
type BasemapDef = type BasemapDef =
| { type: "style"; url: string; maxzoom?: number } | { type: "style"; url: string; maxzoom?: number }
| { type: "raster"; tiles: string[]; attribution: string; tileSize: number; maxzoom?: number }; | { type: "raster"; tiles: string[]; attribution: string; tileSize: number; maxzoom?: number };
const BASEMAPS: Record<BasemapId, BasemapDef> = { const BASEMAPS: Record<BasemapId, BasemapDef> = {
liberty: { liberty: { type: "style", url: "https://tiles.openfreemap.org/styles/liberty" },
type: "style", dark: { type: "style", url: "https://tiles.openfreemap.org/styles/dark" },
url: "https://tiles.openfreemap.org/styles/liberty",
},
dark: {
type: "style",
url: "https://tiles.openfreemap.org/styles/dark",
},
satellite: { satellite: {
type: "raster", type: "raster",
tiles: [ tiles: ["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"],
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", attribution: '&copy; <a href="https://www.esri.com">Esri</a>, Maxar',
],
attribution: '&copy; <a href="https://www.esri.com">Esri</a>, Maxar, Earthstar Geographics',
tileSize: 256, tileSize: 256,
}, },
orto: { orto: {
@@ -100,27 +84,14 @@ function buildStyle(def: BasemapDef): string | maplibregl.StyleSpecification {
return { return {
version: 8 as const, version: 8 as const,
sources: { sources: {
basemap: { basemap: { type: "raster" as const, tiles: def.tiles, tileSize: def.tileSize, attribution: def.attribution },
type: "raster" as const,
tiles: def.tiles,
tileSize: def.tileSize,
attribution: def.attribution,
},
}, },
layers: [ layers: [{ id: "basemap-tiles", type: "raster" as const, source: "basemap", minzoom: 0, maxzoom: def.maxzoom ?? 19 }],
{
id: "basemap-tiles",
type: "raster" as const,
source: "basemap",
minzoom: 0,
maxzoom: def.maxzoom ?? 19,
},
],
}; };
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Props */ /* Props & Handle */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
export type MapViewerHandle = { export type MapViewerHandle = {
@@ -139,27 +110,9 @@ type MapViewerProps = {
selectionMode?: boolean; selectionMode?: boolean;
onFeatureClick?: (feature: ClickedFeature) => void; onFeatureClick?: (feature: ClickedFeature) => void;
onSelectionChange?: (features: SelectedFeature[]) => void; onSelectionChange?: (features: SelectedFeature[]) => void;
/** External layer visibility control */
layerVisibility?: LayerVisibility; 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 */ /* Component */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -167,29 +120,24 @@ function formatPopupContent(properties: Record<string, unknown>): string {
export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>( export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
function MapViewer( function MapViewer(
{ {
center, center, zoom, martinUrl, className,
zoom,
martinUrl,
className,
basemap = "liberty", basemap = "liberty",
selectionMode = false, selectionMode = false,
onFeatureClick, onFeatureClick, onSelectionChange, layerVisibility,
onSelectionChange,
layerVisibility,
}, },
ref ref
) { ) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null); const mapRef = useRef<maplibregl.Map | null>(null);
const popupRef = useRef<maplibregl.Popup | null>(null);
const selectedRef = useRef<Map<string, SelectedFeature>>(new Map()); const selectedRef = useRef<Map<string, SelectedFeature>>(new Map());
const selectionModeRef = useRef(selectionMode); const selectionModeRef = useRef(selectionMode);
const [mapReady, setMapReady] = useState(false); const [mapReady, setMapReady] = useState(false);
// Keep ref in sync // Persist view state across basemap switches
const viewStateRef = useRef({ center: center ?? DEFAULT_CENTER, zoom: zoom ?? DEFAULT_ZOOM });
selectionModeRef.current = selectionMode; selectionModeRef.current = selectionMode;
// MapLibre web workers can't resolve relative URLs — need absolute
const resolvedMartinUrl = (() => { const resolvedMartinUrl = (() => {
const raw = martinUrl ?? DEFAULT_MARTIN_URL; const raw = martinUrl ?? DEFAULT_MARTIN_URL;
if (raw.startsWith("http")) return raw; if (raw.startsWith("http")) return raw;
@@ -202,22 +150,14 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
const ids = Array.from(selectedRef.current.keys()); const ids = Array.from(selectedRef.current.keys());
// Use objectId matching for the selection highlight layer
const filter: maplibregl.FilterSpecification = const filter: maplibregl.FilterSpecification =
ids.length > 0 ids.length > 0
? ["in", ["to-string", ["get", "object_id"]], ["literal", ids]] ? ["in", ["to-string", ["get", "object_id"]], ["literal", ids]]
: ["==", "object_id", "__NONE__"]; : ["==", "object_id", "__NONE__"];
try { try {
if (map.getLayer(LAYER_IDS.selectionFill)) { if (map.getLayer(LAYER_IDS.selectionFill)) map.setFilter(LAYER_IDS.selectionFill, filter);
map.setFilter(LAYER_IDS.selectionFill, filter); if (map.getLayer(LAYER_IDS.selectionLine)) map.setFilter(LAYER_IDS.selectionLine, filter);
} } catch { /* noop */ }
if (map.getLayer(LAYER_IDS.selectionLine)) {
map.setFilter(LAYER_IDS.selectionLine, filter);
}
} catch {
// layers might not exist yet
}
}, []); }, []);
const clearSelection = useCallback(() => { const clearSelection = useCallback(() => {
@@ -229,9 +169,7 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
/* ---- Imperative handle ---- */ /* ---- Imperative handle ---- */
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
getMap: () => mapRef.current, getMap: () => mapRef.current,
setLayerVisibility: (vis: LayerVisibility) => { setLayerVisibility: (vis: LayerVisibility) => applyLayerVisibility(vis),
applyLayerVisibility(vis);
},
flyTo: (c: [number, number], z?: number) => { flyTo: (c: [number, number], z?: number) => {
mapRef.current?.flyTo({ center: c, zoom: z ?? 14, duration: 1500 }); mapRef.current?.flyTo({ center: c, zoom: z ?? 14, duration: 1500 });
}, },
@@ -242,7 +180,6 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
const applyLayerVisibility = useCallback((vis: LayerVisibility) => { const applyLayerVisibility = useCallback((vis: LayerVisibility) => {
const map = mapRef.current; const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return; if (!map || !map.isStyleLoaded()) return;
const mapping: Record<string, string[]> = { const mapping: Record<string, string[]> = {
uats: [ uats: [
LAYER_IDS.uatsZ0Line, LAYER_IDS.uatsZ0Line,
@@ -250,52 +187,45 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
LAYER_IDS.uatsZ8Fill, LAYER_IDS.uatsZ8Line, LAYER_IDS.uatsZ8Label, LAYER_IDS.uatsZ8Fill, LAYER_IDS.uatsZ8Line, LAYER_IDS.uatsZ8Label,
LAYER_IDS.uatsZ12Fill, LAYER_IDS.uatsZ12Line, LAYER_IDS.uatsZ12Label, LAYER_IDS.uatsZ12Fill, LAYER_IDS.uatsZ12Line, LAYER_IDS.uatsZ12Label,
], ],
administrativ: [LAYER_IDS.adminFill, LAYER_IDS.adminLine], administrativ: [LAYER_IDS.adminLineOuter, LAYER_IDS.adminLineInner],
terenuri: [LAYER_IDS.terenuriFill, LAYER_IDS.terenuriLine], terenuri: [LAYER_IDS.terenuriFill, LAYER_IDS.terenuriLine, LAYER_IDS.terenuriLabel],
cladiri: [LAYER_IDS.cladiriFill, LAYER_IDS.cladiriLine], cladiri: [LAYER_IDS.cladiriFill, LAYER_IDS.cladiriLine],
}; };
for (const [group, layerIds] of Object.entries(mapping)) { for (const [group, layerIds] of Object.entries(mapping)) {
const visible = vis[group] !== false; // default visible const visible = vis[group] !== false;
for (const lid of layerIds) { for (const lid of layerIds) {
try { try { map.setLayoutProperty(lid, "visibility", visible ? "visible" : "none"); } catch { /* noop */ }
map.setLayoutProperty(lid, "visibility", visible ? "visible" : "none");
} catch {
// layer might not exist yet
}
} }
} }
}, []); }, []);
/* ---- Sync external visibility prop ---- */
useEffect(() => { useEffect(() => {
if (mapReady && layerVisibility) { if (mapReady && layerVisibility) applyLayerVisibility(layerVisibility);
applyLayerVisibility(layerVisibility);
}
}, [mapReady, layerVisibility, applyLayerVisibility]); }, [mapReady, layerVisibility, applyLayerVisibility]);
/* ---- Map initialization (recreates on basemap change) ---- */ /* ---- Map initialization (recreates on basemap change) ---- */
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
// Preserve current view when switching basemaps
const prevMap = mapRef.current;
const currentCenter = prevMap ? prevMap.getCenter().toArray() as [number, number] : (center ?? DEFAULT_CENTER);
const currentZoom = prevMap ? prevMap.getZoom() : (zoom ?? DEFAULT_ZOOM);
const basemapDef = BASEMAPS[basemap]; const basemapDef = BASEMAPS[basemap];
const map = new maplibregl.Map({ const map = new maplibregl.Map({
container: containerRef.current, container: containerRef.current,
style: buildStyle(basemapDef), style: buildStyle(basemapDef),
center: currentCenter, center: viewStateRef.current.center,
zoom: currentZoom, zoom: viewStateRef.current.zoom,
maxZoom: basemapDef.maxzoom ?? 20, maxZoom: basemapDef.maxzoom ?? 20,
}); });
mapRef.current = map; mapRef.current = map;
/* ---- Controls ---- */ // Save view state on every move (for basemap switch preservation)
map.on("moveend", () => {
viewStateRef.current = {
center: map.getCenter().toArray() as [number, number],
zoom: map.getZoom(),
};
});
map.addControl(new maplibregl.NavigationControl(), "top-right"); map.addControl(new maplibregl.NavigationControl(), "top-right");
map.addControl(new maplibregl.ScaleControl({ unit: "metric" }), "bottom-left"); map.addControl(new maplibregl.ScaleControl({ unit: "metric" }), "bottom-left");
@@ -303,307 +233,132 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
map.on("load", () => { map.on("load", () => {
const m = resolvedMartinUrl; const m = resolvedMartinUrl;
// === UAT z0-5: very coarse (2000m) — lines only === // === UAT z0-5: very coarse — lines only ===
map.addSource(SOURCES.uatsZ0, { map.addSource(SOURCES.uatsZ0, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ0}/{z}/{x}/{y}`], minzoom: 0, maxzoom: 5 });
type: "vector", map.addLayer({ id: LAYER_IDS.uatsZ0Line, type: "line", source: SOURCES.uatsZ0, "source-layer": SOURCES.uatsZ0, maxzoom: 5,
tiles: [`${m}/${SOURCES.uatsZ0}/{z}/{x}/{y}`], paint: { "line-color": "#7c3aed", "line-width": 0.3 } });
minzoom: 0, maxzoom: 5,
});
map.addLayer({
id: LAYER_IDS.uatsZ0Line, type: "line",
source: SOURCES.uatsZ0, "source-layer": SOURCES.uatsZ0,
maxzoom: 5,
paint: { "line-color": "#7c3aed", "line-width": 0.3 },
});
// === UAT z5-8: coarse (500m) — lines + faint fill === // === UAT z5-8: coarse ===
map.addSource(SOURCES.uatsZ5, { map.addSource(SOURCES.uatsZ5, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ5}/{z}/{x}/{y}`], minzoom: 5, maxzoom: 8 });
type: "vector", map.addLayer({ id: LAYER_IDS.uatsZ5Fill, type: "fill", source: SOURCES.uatsZ5, "source-layer": SOURCES.uatsZ5, minzoom: 5, maxzoom: 8,
tiles: [`${m}/${SOURCES.uatsZ5}/{z}/{x}/{y}`], paint: { "fill-color": "#8b5cf6", "fill-opacity": 0.03 } });
minzoom: 5, maxzoom: 8, map.addLayer({ id: LAYER_IDS.uatsZ5Line, type: "line", source: SOURCES.uatsZ5, "source-layer": SOURCES.uatsZ5, minzoom: 5, maxzoom: 8,
}); paint: { "line-color": "#7c3aed", "line-width": 0.6 } });
map.addLayer({
id: LAYER_IDS.uatsZ5Fill, type: "fill",
source: SOURCES.uatsZ5, "source-layer": SOURCES.uatsZ5,
minzoom: 5, maxzoom: 8,
paint: { "fill-color": "#8b5cf6", "fill-opacity": 0.03 },
});
map.addLayer({
id: LAYER_IDS.uatsZ5Line, type: "line",
source: SOURCES.uatsZ5, "source-layer": SOURCES.uatsZ5,
minzoom: 5, maxzoom: 8,
paint: { "line-color": "#7c3aed", "line-width": 0.6 },
});
// === UAT z8-12: moderate (50m) — lines + fill + labels === // === UAT z8-12: moderate ===
map.addSource(SOURCES.uatsZ8, { map.addSource(SOURCES.uatsZ8, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ8}/{z}/{x}/{y}`], minzoom: 8, maxzoom: 12 });
type: "vector", map.addLayer({ id: LAYER_IDS.uatsZ8Fill, type: "fill", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 8, maxzoom: 12,
tiles: [`${m}/${SOURCES.uatsZ8}/{z}/{x}/{y}`], paint: { "fill-color": "#8b5cf6", "fill-opacity": 0.05 } });
minzoom: 8, maxzoom: 12, map.addLayer({ id: LAYER_IDS.uatsZ8Line, type: "line", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 8, maxzoom: 12,
}); paint: { "line-color": "#7c3aed", "line-width": 1 } });
map.addLayer({ map.addLayer({ id: LAYER_IDS.uatsZ8Label, type: "symbol", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 9, maxzoom: 12,
id: LAYER_IDS.uatsZ8Fill, type: "fill", layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-size": 10, "text-anchor": "center", "text-allow-overlap": false },
source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
minzoom: 8, maxzoom: 12,
paint: { "fill-color": "#8b5cf6", "fill-opacity": 0.05 }, // === UAT z12+: full detail (no simplification) ===
}); map.addSource(SOURCES.uatsZ12, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ12}/{z}/{x}/{y}`], minzoom: 12, maxzoom: 16 });
map.addLayer({ map.addLayer({ id: LAYER_IDS.uatsZ12Fill, type: "fill", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
id: LAYER_IDS.uatsZ8Line, type: "line", paint: { "fill-color": "#8b5cf6", "fill-opacity": 0.08 } });
source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, map.addLayer({ id: LAYER_IDS.uatsZ12Line, type: "line", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
minzoom: 8, maxzoom: 12, paint: { "line-color": "#7c3aed", "line-width": 2 } });
paint: { "line-color": "#7c3aed", "line-width": 1 }, map.addLayer({ id: LAYER_IDS.uatsZ12Label, type: "symbol", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
}); layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-size": 13, "text-anchor": "center", "text-allow-overlap": false },
map.addLayer({ paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
id: LAYER_IDS.uatsZ8Label, type: "symbol",
source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, // === Intravilan — double line (black outer + orange inner), no fill, z13+ ===
minzoom: 9, maxzoom: 12, map.addSource(SOURCES.administrativ, { type: "vector", tiles: [`${m}/${SOURCES.administrativ}/{z}/{x}/{y}`], minzoom: 10, maxzoom: 16 });
map.addLayer({ id: LAYER_IDS.adminLineOuter, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13,
paint: { "line-color": "#000000", "line-width": 3 } });
map.addLayer({ id: LAYER_IDS.adminLineInner, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13,
paint: { "line-color": "#f97316", "line-width": 1.5 } });
// === Terenuri (parcels) — no simplification ===
map.addSource(SOURCES.terenuri, { type: "vector", tiles: [`${m}/${SOURCES.terenuri}/{z}/{x}/{y}`], 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 } });
// Parcel cadastral number label
map.addLayer({ id: LAYER_IDS.terenuriLabel, type: "symbol", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 16,
layout: { layout: {
"text-field": ["coalesce", ["get", "name"], ""], "text-field": ["coalesce", ["get", "cadastral_ref"], ""],
"text-size": 10, "text-anchor": "center", "text-allow-overlap": false, "text-size": 10, "text-anchor": "center", "text-allow-overlap": false,
"text-max-width": 8,
}, },
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 }, paint: { "text-color": "#166534", "text-halo-color": "#fff", "text-halo-width": 1 } });
});
// === UAT z12+: fine (10m) — full detail === // === Cladiri (buildings) — no simplification ===
map.addSource(SOURCES.uatsZ12, { map.addSource(SOURCES.cladiri, { type: "vector", tiles: [`${m}/${SOURCES.cladiri}/{z}/{x}/{y}`], minzoom: 12, maxzoom: 18 });
type: "vector", map.addLayer({ id: LAYER_IDS.cladiriFill, type: "fill", source: SOURCES.cladiri, "source-layer": SOURCES.cladiri, minzoom: 14,
tiles: [`${m}/${SOURCES.uatsZ12}/{z}/{x}/{y}`], paint: { "fill-color": "#3b82f6", "fill-opacity": 0.5 } });
minzoom: 12, maxzoom: 16, 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 } });
map.addLayer({
id: LAYER_IDS.uatsZ12Fill, type: "fill",
source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12,
minzoom: 12,
paint: { "fill-color": "#8b5cf6", "fill-opacity": 0.08 },
});
map.addLayer({
id: LAYER_IDS.uatsZ12Line, type: "line",
source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12,
minzoom: 12,
paint: { "line-color": "#7c3aed", "line-width": 2 },
});
map.addLayer({
id: LAYER_IDS.uatsZ12Label, type: "symbol",
source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12,
minzoom: 12,
layout: {
"text-field": ["coalesce", ["get", "name"], ""],
"text-size": 13, "text-anchor": "center", "text-allow-overlap": false,
},
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 },
});
// === Administrativ (intravilan, arii speciale) === // === Selection highlight ===
map.addSource(SOURCES.administrativ, { map.addLayer({ id: LAYER_IDS.selectionFill, type: "fill", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
type: "vector",
tiles: [`${m}/${SOURCES.administrativ}/{z}/{x}/{y}`],
minzoom: 10, maxzoom: 16,
});
map.addLayer({
id: LAYER_IDS.adminFill, type: "fill",
source: SOURCES.administrativ, "source-layer": SOURCES.administrativ,
minzoom: 11,
paint: { "fill-color": "#f97316", "fill-opacity": 0.06 },
});
map.addLayer({
id: LAYER_IDS.adminLine, type: "line",
source: SOURCES.administrativ, "source-layer": SOURCES.administrativ,
minzoom: 10,
paint: { "line-color": "#ea580c", "line-width": 1.2, "line-dasharray": [4, 2] },
});
// --- Terenuri (parcels) — NO simplification ---
map.addSource(SOURCES.terenuri, {
type: "vector",
tiles: [`${resolvedMartinUrl}/${SOURCES.terenuri}/{z}/{x}/{y}`],
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}`],
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__"], filter: ["==", "object_id", "__NONE__"],
paint: { paint: { "fill-color": "#f59e0b", "fill-opacity": 0.5 } });
"line-color": "#d97706", map.addLayer({ id: LAYER_IDS.selectionLine, type: "line", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
"line-width": 2.5, filter: ["==", "object_id", "__NONE__"],
}, paint: { "line-color": "#d97706", "line-width": 2.5 } });
});
// Apply initial visibility if provided
if (layerVisibility) {
applyLayerVisibility(layerVisibility);
}
if (layerVisibility) applyLayerVisibility(layerVisibility);
setMapReady(true); setMapReady(true);
}); });
/* ---- Click handler ---- */ /* ---- Click handler — NO popup, only callback ---- */
const clickableLayers = [ const clickableLayers = [
LAYER_IDS.terenuriFill, LAYER_IDS.terenuriFill, LAYER_IDS.cladiriFill,
LAYER_IDS.cladiriFill, LAYER_IDS.uatsZ5Fill, LAYER_IDS.uatsZ8Fill, LAYER_IDS.uatsZ12Fill,
LAYER_IDS.uatsZ5Fill,
LAYER_IDS.uatsZ8Fill,
LAYER_IDS.uatsZ12Fill,
]; ];
map.on("click", (e) => { map.on("click", (e) => {
const features = map.queryRenderedFeatures(e.point, { const features = map.queryRenderedFeatures(e.point, {
layers: clickableLayers.filter((l) => { layers: clickableLayers.filter((l) => { try { return !!map.getLayer(l); } catch { return false; } }),
try {
return !!map.getLayer(l);
} catch {
return false;
}
}),
}); });
// Close existing popup if (features.length === 0) {
if (popupRef.current) { onFeatureClick?.(null as unknown as ClickedFeature); // close panel
popupRef.current.remove(); return;
popupRef.current = null;
} }
if (features.length === 0) return;
const first = features[0]; const first = features[0];
if (!first) return; if (!first) return;
const props = (first.properties ?? {}) as Record<string, unknown>; const props = (first.properties ?? {}) as Record<string, unknown>;
const sourceLayer = first.sourceLayer ?? first.source ?? ""; const sourceLayer = first.sourceLayer ?? first.source ?? "";
// Selection mode: toggle feature in selection // Selection mode
if (selectionModeRef.current && sourceLayer === SOURCES.terenuri) { if (selectionModeRef.current && sourceLayer === SOURCES.terenuri) {
const objectId = String(props.object_id ?? props.objectId ?? ""); const objectId = String(props.object_id ?? "");
if (!objectId) return; if (!objectId) return;
if (selectedRef.current.has(objectId)) { if (selectedRef.current.has(objectId)) {
selectedRef.current.delete(objectId); selectedRef.current.delete(objectId);
} else { } else {
selectedRef.current.set(objectId, { selectedRef.current.set(objectId, { id: objectId, sourceLayer, properties: props });
id: objectId,
sourceLayer,
properties: props,
});
} }
updateSelectionFilter(); updateSelectionFilter();
onSelectionChange?.(Array.from(selectedRef.current.values())); onSelectionChange?.(Array.from(selectedRef.current.values()));
return; return;
} }
// Notify parent // Feature click — notify parent (no popup)
if (onFeatureClick) { onFeatureClick?.({
onFeatureClick({ layerId: first.layer?.id ?? "",
layerId: first.layer?.id ?? "", sourceLayer,
sourceLayer, properties: props,
properties: props, coordinates: [e.lngLat.lng, e.lngLat.lat],
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 ---- */ /* ---- Cursor change ---- */
for (const lid of clickableLayers) { for (const lid of clickableLayers) {
map.on("mouseenter", lid, () => { map.on("mouseenter", lid, () => { map.getCanvas().style.cursor = "pointer"; });
map.getCanvas().style.cursor = "pointer"; map.on("mouseleave", lid, () => { map.getCanvas().style.cursor = ""; });
});
map.on("mouseleave", lid, () => {
map.getCanvas().style.cursor = "";
});
} }
/* ---- Cleanup ---- */ /* ---- Cleanup ---- */
return () => { return () => {
if (popupRef.current) {
popupRef.current.remove();
popupRef.current = null;
}
map.remove(); map.remove();
mapRef.current = null; mapRef.current = null;
setMapReady(false); setMapReady(false);
@@ -611,16 +366,10 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [resolvedMartinUrl, basemap]); }, [resolvedMartinUrl, basemap]);
/* ---- Sync center/zoom prop changes ---- */ /* ---- Sync center/zoom prop changes (from search flyTo) ---- */
useEffect(() => { useEffect(() => {
if (!mapReady || !mapRef.current) return; if (!mapReady || !mapRef.current || !center) return;
if (center) { mapRef.current.flyTo({ center, zoom: zoom ?? mapRef.current.getZoom(), duration: 1500 });
mapRef.current.flyTo({
center,
zoom: zoom ?? mapRef.current.getZoom(),
duration: 1500,
});
}
}, [center, zoom, mapReady]); }, [center, zoom, mapReady]);
return ( return (