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:
AI Assistant
2026-03-23 16:43:01 +02:00
parent 4ea7c6dbd6
commit 1b5876524a
11 changed files with 1427 additions and 33 deletions
@@ -0,0 +1,44 @@
"use client";
import { Map, Mountain, Satellite } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { cn } from "@/shared/lib/utils";
import type { BasemapId } from "../types";
const BASEMAPS: { id: BasemapId; label: string; icon: typeof Map }[] = [
{ id: "osm", label: "Harta", icon: Map },
{ id: "satellite", label: "Satelit", icon: Satellite },
{ id: "topo", label: "Teren", icon: Mountain },
];
type BasemapSwitcherProps = {
value: BasemapId;
onChange: (id: BasemapId) => void;
};
export function BasemapSwitcher({ value, onChange }: BasemapSwitcherProps) {
return (
<div className="bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg flex p-0.5 gap-0.5">
{BASEMAPS.map((b) => {
const Icon = b.icon;
const active = value === b.id;
return (
<Button
key={b.id}
variant="ghost"
size="sm"
className={cn(
"px-2 py-1 h-7 text-xs gap-1 rounded-md",
active && "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
)}
onClick={() => onChange(b.id)}
title={b.label}
>
<Icon className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{b.label}</span>
</Button>
);
})}
</div>
);
}
@@ -0,0 +1,250 @@
"use client";
import { useEffect, useState } from "react";
import {
X,
MapPin,
User,
Ruler,
Building2,
FileText,
Loader2,
TreePine,
} from "lucide-react";
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";
type FeatureInfoPanelProps = {
feature: ClickedFeature | null;
onClose: () => void;
className?: string;
};
export function FeatureInfoPanel({
feature,
onClose,
className,
}: FeatureInfoPanelProps) {
const [detail, setDetail] = useState<FeatureDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!feature) {
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 siruta = feature.properties.siruta;
if (!objectId || !siruta) {
setDetail(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
fetch(`/api/geoportal/feature?objectId=${objectId}&siruta=${siruta}&sourceLayer=${feature.sourceLayer}`)
.then((r) => {
if (!r.ok) throw new Error(`${r.status}`);
return r.json();
})
.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 () => {
cancelled = true;
};
}, [feature]);
if (!feature) return null;
const enrichment = detail?.enrichment;
return (
<div
className={cn(
"bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg w-80 max-h-[calc(100vh-16rem)] overflow-auto",
className
)}
>
{/* Header */}
<div className="flex items-center justify-between p-3 border-b sticky top-0 bg-background/95 backdrop-blur-sm z-10">
<h3 className="text-sm font-semibold truncate flex-1">
{enrichment?.NR_CAD
? `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" />
</Button>
</div>
{/* Content */}
<div className="p-3 space-y-3">
{loading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4 justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
Se incarca...
</div>
)}
{error && (
<p className="text-xs text-destructive">Nu s-au putut incarca detaliile ({error})</p>
)}
{/* Basic props from vector tile */}
{!loading && !enrichment && (
<PropsTable properties={feature.properties} />
)}
{/* 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>
);
}
/* ------------------------------------------------------------------ */
/* 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;
return (
<div className="flex justify-between text-xs gap-2">
<span className="text-muted-foreground shrink-0">{label}</span>
<span className="text-right font-medium truncate">{typeof value === "string" ? value : value}</span>
</div>
);
}
function PropsTable({ properties }: { properties: Record<string, unknown> }) {
const entries = Object.entries(properties).filter(
([, v]) => v != null && 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);
return `${n.toLocaleString("ro-RO")} mp`;
}
@@ -4,8 +4,18 @@ import { useState, useRef, useCallback } from "react";
import dynamic from "next/dynamic";
import { Globe } from "lucide-react";
import { LayerPanel, getDefaultVisibility } from "./layer-panel";
import { BasemapSwitcher } from "./basemap-switcher";
import { SearchBar } from "./search-bar";
import { SelectionToolbar } from "./selection-toolbar";
import { FeatureInfoPanel } from "./feature-info-panel";
import type { MapViewerHandle } from "./map-viewer";
import type { ClickedFeature, LayerVisibility } from "../types";
import type {
BasemapId,
ClickedFeature,
LayerVisibility,
SearchResult,
SelectedFeature,
} from "../types";
/* MapLibre uses WebGL — must disable SSR */
const MapViewer = dynamic(
@@ -29,20 +39,60 @@ const MapViewer = dynamic(
export function GeoportalModule() {
const mapHandleRef = useRef<MapViewerHandle>(null);
// Map state
const [basemap, setBasemap] = useState<BasemapId>("osm");
const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>(
getDefaultVisibility
);
// Feature info
const [clickedFeature, setClickedFeature] = useState<ClickedFeature | null>(null);
// Selection
const [selectionMode, setSelectionMode] = useState(false);
const [selectedFeatures, setSelectedFeatures] = useState<SelectedFeature[]>([]);
// Fly-to target (from search)
const [flyTarget, setFlyTarget] = useState<{ center: [number, number]; zoom?: number } | undefined>();
const handleFeatureClick = useCallback((feature: ClickedFeature) => {
// Feature click is handled by the MapViewer popup internally.
// This callback is available for future integration (e.g., detail panel).
void feature;
setClickedFeature(feature);
}, []);
const handleVisibilityChange = useCallback((vis: LayerVisibility) => {
setLayerVisibility(vis);
}, []);
const handleSearchResult = useCallback((result: SearchResult) => {
if (result.coordinates) {
setFlyTarget({
center: result.coordinates,
zoom: result.type === "uat" ? 12 : 17,
});
}
}, []);
const handleSelectionChange = useCallback((features: SelectedFeature[]) => {
setSelectedFeatures(features);
}, []);
const handleToggleSelectionMode = useCallback(() => {
setSelectionMode((prev) => {
if (prev) {
// Turning off: clear selection
mapHandleRef.current?.clearSelection();
setSelectedFeatures([]);
}
return !prev;
});
}, []);
const handleClearSelection = useCallback(() => {
mapHandleRef.current?.clearSelection();
setSelectedFeatures([]);
}, []);
return (
<div className="space-y-4">
{/* Header */}
@@ -61,17 +111,48 @@ export function GeoportalModule() {
<MapViewer
ref={mapHandleRef}
className="h-full w-full"
basemap={basemap}
selectionMode={selectionMode}
onFeatureClick={handleFeatureClick}
onSelectionChange={handleSelectionChange}
layerVisibility={layerVisibility}
center={flyTarget?.center}
zoom={flyTarget?.zoom}
/>
{/* Layer panel overlay */}
<div className="absolute top-3 left-3 z-10">
{/* Top-left controls: search + layers */}
<div className="absolute top-3 left-3 z-10 flex flex-col gap-2 max-w-xs">
<SearchBar onResultSelect={handleSearchResult} />
<LayerPanel
visibility={layerVisibility}
onVisibilityChange={handleVisibilityChange}
/>
</div>
{/* Top-right: basemap switcher */}
<div className="absolute top-3 right-14 z-10">
<BasemapSwitcher value={basemap} onChange={setBasemap} />
</div>
{/* Bottom-left: selection toolbar */}
<div className="absolute bottom-8 left-3 z-10">
<SelectionToolbar
selectedFeatures={selectedFeatures}
selectionMode={selectionMode}
onToggleSelectionMode={handleToggleSelectionMode}
onClearSelection={handleClearSelection}
/>
</div>
{/* Right side: feature info panel */}
{clickedFeature && !selectionMode && (
<div className="absolute top-3 right-3 z-10 mt-12">
<FeatureInfoPanel
feature={clickedFeature}
onClose={() => setClickedFeature(null)}
/>
</div>
)}
</div>
</div>
);
+190 -27
View File
@@ -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: '&copy; <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: '&copy; <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:
'&copy; <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:
'&copy; <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,
@@ -0,0 +1,156 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { Search, MapPin, LandPlot, Building2, X, Loader2 } from "lucide-react";
import { Input } from "@/shared/components/ui/input";
import { Button } from "@/shared/components/ui/button";
import { cn } from "@/shared/lib/utils";
import type { SearchResult } from "../types";
type SearchBarProps = {
onResultSelect: (result: SearchResult) => void;
className?: string;
};
export function SearchBar({ onResultSelect, className }: SearchBarProps) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [selectedIdx, setSelectedIdx] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Debounced search
const doSearch = useCallback((q: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (q.length < 2) {
setResults([]);
setOpen(false);
return;
}
debounceRef.current = setTimeout(() => {
setLoading(true);
fetch(`/api/geoportal/search?q=${encodeURIComponent(q)}&limit=15`)
.then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
.then((data: { results: SearchResult[] }) => {
setResults(data.results);
setOpen(data.results.length > 0);
setSelectedIdx(-1);
})
.catch(() => {
setResults([]);
setOpen(false);
})
.finally(() => setLoading(false));
}, 300);
}, []);
// Click outside to close
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const handleSelect = (result: SearchResult) => {
setOpen(false);
setQuery(result.label);
onResultSelect(result);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!open || results.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIdx((i) => Math.min(i + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIdx((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && selectedIdx >= 0) {
e.preventDefault();
const sel = results[selectedIdx];
if (sel) handleSelect(sel);
} else if (e.key === "Escape") {
setOpen(false);
}
};
const typeIcon = (type: SearchResult["type"]) => {
switch (type) {
case "uat":
return <MapPin className="h-3.5 w-3.5 text-violet-500" />;
case "parcel":
return <LandPlot className="h-3.5 w-3.5 text-green-500" />;
case "building":
return <Building2 className="h-3.5 w-3.5 text-blue-500" />;
}
};
return (
<div ref={containerRef} className={cn("relative", className)}>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Cauta parcela, UAT, proprietar..."
value={query}
onChange={(e) => {
setQuery(e.target.value);
doSearch(e.target.value);
}}
onFocus={() => {
if (results.length > 0) setOpen(true);
}}
onKeyDown={handleKeyDown}
className="pl-8 pr-8 h-8 text-sm bg-background/95 backdrop-blur-sm"
/>
{loading && (
<Loader2 className="absolute right-8 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin text-muted-foreground" />
)}
{query && (
<Button
variant="ghost"
size="sm"
className="absolute right-0.5 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
onClick={() => {
setQuery("");
setResults([]);
setOpen(false);
}}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
{/* Results dropdown */}
{open && results.length > 0 && (
<div className="absolute top-full mt-1 w-full bg-background border rounded-lg shadow-lg overflow-hidden z-50 max-h-80 overflow-y-auto">
{results.map((r, i) => (
<button
key={r.id}
className={cn(
"w-full flex items-start gap-2.5 px-3 py-2 text-left hover:bg-muted/50 transition-colors",
i === selectedIdx && "bg-muted"
)}
onClick={() => handleSelect(r)}
onMouseEnter={() => setSelectedIdx(i)}
>
<div className="mt-0.5 shrink-0">{typeIcon(r.type)}</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{r.label}</p>
{r.sublabel && (
<p className="text-xs text-muted-foreground truncate">{r.sublabel}</p>
)}
</div>
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { Download, Trash2, MousePointerClick, Loader2 } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import { Badge } from "@/shared/components/ui/badge";
import { cn } from "@/shared/lib/utils";
import type { SelectedFeature, ExportFormat } from "../types";
type SelectionToolbarProps = {
selectedFeatures: SelectedFeature[];
selectionMode: boolean;
onToggleSelectionMode: () => void;
onClearSelection: () => void;
className?: string;
};
const EXPORT_FORMATS: { id: ExportFormat; label: string; ext: string }[] = [
{ id: "geojson", label: "GeoJSON (.geojson)", ext: "geojson" },
{ id: "dxf", label: "AutoCAD DXF (.dxf)", ext: "dxf" },
{ id: "gpkg", label: "GeoPackage (.gpkg)", ext: "gpkg" },
];
export function SelectionToolbar({
selectedFeatures,
selectionMode,
onToggleSelectionMode,
onClearSelection,
className,
}: SelectionToolbarProps) {
const [exporting, setExporting] = useState(false);
const handleExport = async (format: ExportFormat) => {
if (selectedFeatures.length === 0) return;
setExporting(true);
try {
const ids = selectedFeatures.map((f) => f.id);
const resp = await fetch("/api/geoportal/export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids, format }),
});
if (!resp.ok) {
const errData = await resp.json().catch(() => null);
const msg = (errData && typeof errData === "object" && "error" in errData)
? String((errData as { error: string }).error)
: `Export esuat (${resp.status})`;
alert(msg);
return;
}
// Download the blob
const blob = await resp.blob();
const disposition = resp.headers.get("Content-Disposition");
let filename = `export.${format}`;
if (disposition) {
const match = disposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)/i);
if (match?.[1]) filename = decodeURIComponent(match[1]);
}
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch {
alert("Eroare la export");
} finally {
setExporting(false);
}
};
return (
<div
className={cn(
"bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg flex items-center gap-1.5 p-1.5",
className
)}
>
{/* Toggle selection mode */}
<Button
variant={selectionMode ? "default" : "ghost"}
size="sm"
className="h-7 px-2 text-xs gap-1"
onClick={onToggleSelectionMode}
title={selectionMode ? "Dezactiveaza selectia" : "Activeaza selectia"}
>
<MousePointerClick className="h-3.5 w-3.5" />
Selectie
</Button>
{selectedFeatures.length > 0 && (
<>
<Badge variant="secondary" className="text-xs h-5 px-1.5">
{selectedFeatures.length}
</Badge>
{/* Export dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1"
disabled={exporting}
>
{exporting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{EXPORT_FORMATS.map((fmt) => (
<DropdownMenuItem
key={fmt.id}
onClick={() => handleExport(fmt.id)}
className="text-xs"
>
{fmt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Clear selection */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={onClearSelection}
title="Sterge selectia"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
);
}
+77
View File
@@ -41,3 +41,80 @@ export type MapViewState = {
center: [number, number];
zoom: number;
};
/* ------------------------------------------------------------------ */
/* Basemap */
/* ------------------------------------------------------------------ */
export type BasemapId = "osm" | "satellite" | "topo";
export type BasemapDef = {
id: BasemapId;
label: string;
tiles: string[];
attribution: string;
tileSize?: number;
maxzoom?: number;
};
/* ------------------------------------------------------------------ */
/* Selection */
/* ------------------------------------------------------------------ */
export type SelectedFeature = {
id: string; // objectId or composite key
sourceLayer: string;
properties: Record<string, unknown>;
};
/* ------------------------------------------------------------------ */
/* Search */
/* ------------------------------------------------------------------ */
export type SearchResult = {
id: string;
type: "parcel" | "uat" | "building";
label: string;
sublabel?: string;
coordinates?: [number, number]; // lng, lat for flyTo
bbox?: [number, number, number, number];
properties?: Record<string, unknown>;
};
/* ------------------------------------------------------------------ */
/* Feature info (enrichment from ParcelSync) */
/* ------------------------------------------------------------------ */
export type FeatureDetail = {
id: string;
layerId: string;
siruta: string;
objectId: number;
cadastralRef: string | null;
areaValue: number | null;
enrichment: FeatureEnrichmentData | null;
enrichedAt: string | null;
};
export type FeatureEnrichmentData = {
NR_CAD: string;
NR_CF: string;
NR_CF_VECHI: string;
NR_TOPO: string;
ADRESA: string;
PROPRIETARI: string;
PROPRIETARI_VECHI: string;
SUPRAFATA_2D: number | string;
SUPRAFATA_R: number | string;
SOLICITANT: string;
INTRAVILAN: string;
CATEGORIE_FOLOSINTA: string;
HAS_BUILDING: number;
BUILD_LEGAL: number;
};
/* ------------------------------------------------------------------ */
/* Export */
/* ------------------------------------------------------------------ */
export type ExportFormat = "dxf" | "gpkg" | "geojson";