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
@@ -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>
);