feat: add Geoportal module with MapLibre GL JS + Martin vector tiles

Phase 1 of the geoportal implementation:

Infrastructure:
- Martin vector tile server in docker-compose (port 3010)
- PostGIS setup SQL for GisUat: native geom column, Esri→PostGIS
  trigger, GiST index, gis_uats view for Martin auto-discovery

Geoportal module (src/modules/geoportal/):
- map-viewer.tsx: MapLibre GL JS canvas with OSM base, Martin MVT
  sources (gis_uats, gis_terenuri, gis_cladiri), click-to-inspect,
  zoom-level-aware layer visibility, layer styling
- layer-panel.tsx: collapsible sidebar with layer toggles
- geoportal-module.tsx: standalone page wrapper
- Module registered in config/modules.ts, flags.ts, i18n

ParcelSync integration:
- 6th tab "Harta" with lazy-loaded MapViewer (ssr: false)
- Centered on selected UAT

Dependencies: maplibre-gl v5.21.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-23 14:21:37 +02:00
parent 53595fdf94
commit c297a2c5f7
15 changed files with 1227 additions and 2 deletions
@@ -0,0 +1,78 @@
"use client";
import { useState, useRef, useCallback } from "react";
import dynamic from "next/dynamic";
import { Globe } from "lucide-react";
import { LayerPanel, getDefaultVisibility } from "./layer-panel";
import type { MapViewerHandle } from "./map-viewer";
import type { ClickedFeature, LayerVisibility } from "../types";
/* MapLibre uses WebGL — must disable SSR */
const MapViewer = dynamic(
() =>
import("./map-viewer").then((m) => ({
default: m.MapViewer,
})),
{
ssr: false,
loading: () => (
<div className="flex items-center justify-center h-full bg-muted/30">
<p className="text-sm text-muted-foreground">Se incarca harta...</p>
</div>
),
}
);
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function GeoportalModule() {
const mapHandleRef = useRef<MapViewerHandle>(null);
const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>(
getDefaultVisibility
);
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;
}, []);
const handleVisibilityChange = useCallback((vis: LayerVisibility) => {
setLayerVisibility(vis);
}, []);
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<Globe className="h-6 w-6 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold">Geoportal</h2>
<p className="text-sm text-muted-foreground">
Harta interactiva cu parcele cadastrale, cladiri si limite UAT
</p>
</div>
</div>
{/* Map container */}
<div className="relative h-[calc(100vh-12rem)] min-h-[500px] rounded-lg border overflow-hidden">
<MapViewer
ref={mapHandleRef}
className="h-full w-full"
onFeatureClick={handleFeatureClick}
layerVisibility={layerVisibility}
/>
{/* Layer panel overlay */}
<div className="absolute top-3 left-3 z-10">
<LayerPanel
visibility={layerVisibility}
onVisibilityChange={handleVisibilityChange}
/>
</div>
</div>
</div>
);
}