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:
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { FeatureGate } from "@/core/feature-flags";
|
||||
import { useI18n } from "@/core/i18n";
|
||||
import { GeoportalModule } from "@/modules/geoportal";
|
||||
|
||||
export default function GeoportalPage() {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<FeatureGate flag="module.geoportal" fallback={<ModuleDisabled />}>
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("geoportal.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t("geoportal.description")}
|
||||
</p>
|
||||
</div>
|
||||
<GeoportalModule />
|
||||
</div>
|
||||
</FeatureGate>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleDisabled() {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl py-12 text-center text-muted-foreground">
|
||||
<p>Modulul Geoportal este dezactivat.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -122,6 +122,14 @@ export const DEFAULT_FLAGS: FeatureFlag[] = [
|
||||
category: "module",
|
||||
overridable: true,
|
||||
},
|
||||
{
|
||||
key: "module.geoportal",
|
||||
enabled: true,
|
||||
label: "Geoportal",
|
||||
description: "Harta interactiva cu parcele, cladiri si limite UAT",
|
||||
category: "module",
|
||||
overridable: true,
|
||||
},
|
||||
|
||||
// System flags
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ import { aiChatConfig } from "@/modules/ai-chat/config";
|
||||
import { hotDeskConfig } from "@/modules/hot-desk/config";
|
||||
import { visualCopilotConfig } from "@/modules/visual-copilot/config";
|
||||
import { parcelSyncConfig } from "@/modules/parcel-sync/config";
|
||||
import { geoportalConfig } from "@/modules/geoportal/config";
|
||||
|
||||
/**
|
||||
* Toate configurările modulelor ArchiTools, ordonate după navOrder.
|
||||
@@ -34,6 +35,7 @@ export const MODULE_CONFIGS: ModuleConfig[] = [
|
||||
tagManagerConfig, // navOrder: 40 | tools
|
||||
miniUtilitiesConfig, // navOrder: 41 | tools
|
||||
parcelSyncConfig, // navOrder: 42 | tools
|
||||
geoportalConfig, // navOrder: 43 | tools
|
||||
promptGeneratorConfig, // navOrder: 50 | ai
|
||||
aiChatConfig, // navOrder: 51 | ai
|
||||
visualCopilotConfig, // navOrder: 52 | ai
|
||||
|
||||
@@ -116,4 +116,9 @@ export const ro: Labels = {
|
||||
description:
|
||||
"Sincronizare parcele cadastrale ANCPI cu bază de date GIS locală",
|
||||
},
|
||||
geoportal: {
|
||||
title: "Geoportal",
|
||||
description:
|
||||
"Harta interactiva cu parcele cadastrale, cladiri si limite UAT",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Layers, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { Switch } from "@/shared/components/ui/switch";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import type { LayerVisibility } from "../types";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Layer definitions */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type LayerGroupDef = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
color: string;
|
||||
defaultVisible: boolean;
|
||||
};
|
||||
|
||||
const LAYER_GROUPS: LayerGroupDef[] = [
|
||||
{
|
||||
id: "uats",
|
||||
label: "Limite UAT",
|
||||
description: "Unitati administrativ-teritoriale",
|
||||
color: "#7c3aed",
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
id: "terenuri",
|
||||
label: "Terenuri",
|
||||
description: "Parcele cadastrale (zoom >= 13)",
|
||||
color: "#22c55e",
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
id: "cladiri",
|
||||
label: "Cladiri",
|
||||
description: "Constructii (zoom >= 14)",
|
||||
color: "#3b82f6",
|
||||
defaultVisible: true,
|
||||
},
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Props */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type LayerPanelProps = {
|
||||
visibility: LayerVisibility;
|
||||
onVisibilityChange: (visibility: LayerVisibility) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function LayerPanel({
|
||||
visibility,
|
||||
onVisibilityChange,
|
||||
className,
|
||||
}: LayerPanelProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(groupId: string, checked: boolean) => {
|
||||
onVisibilityChange({ ...visibility, [groupId]: checked });
|
||||
},
|
||||
[visibility, onVisibilityChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg",
|
||||
"transition-all duration-200",
|
||||
collapsed ? "w-10" : "w-64",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2.5 py-2 h-auto",
|
||||
collapsed ? "justify-center" : "justify-start"
|
||||
)}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
title={collapsed ? "Afiseaza layere" : "Ascunde layere"}
|
||||
>
|
||||
<Layers className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="text-sm font-medium flex-1 text-left">
|
||||
Straturi
|
||||
</span>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Layer list */}
|
||||
{!collapsed && (
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
{LAYER_GROUPS.map((group) => {
|
||||
const isVisible = visibility[group.id] !== false;
|
||||
return (
|
||||
<div key={group.id} className="flex items-start gap-3">
|
||||
{/* Color swatch */}
|
||||
<div
|
||||
className="mt-0.5 h-4 w-4 rounded-sm border shrink-0"
|
||||
style={{
|
||||
backgroundColor: isVisible ? group.color : "transparent",
|
||||
borderColor: group.color,
|
||||
opacity: isVisible ? 1 : 0.4,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Label + description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Label
|
||||
htmlFor={`layer-${group.id}`}
|
||||
className="text-sm font-medium cursor-pointer leading-tight"
|
||||
>
|
||||
{group.label}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground leading-tight mt-0.5">
|
||||
{group.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<Switch
|
||||
id={`layer-${group.id}`}
|
||||
checked={isVisible}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggle(group.id, checked)
|
||||
}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the default visibility state */
|
||||
export function getDefaultVisibility(): LayerVisibility {
|
||||
const vis: LayerVisibility = {};
|
||||
for (const g of LAYER_GROUPS) {
|
||||
vis[g.id] = g.defaultVisible;
|
||||
}
|
||||
return vis;
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState, useCallback, useImperativeHandle, forwardRef } from "react";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import type { ClickedFeature, LayerVisibility } from "../types";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Martin tile URL.
|
||||
* Env var NEXT_PUBLIC_MARTIN_URL should be set in docker-compose.yml:
|
||||
* - NEXT_PUBLIC_MARTIN_URL=http://10.10.10.166:3010
|
||||
* For production via Traefik, use https://tools.beletage.ro/tiles
|
||||
*/
|
||||
const DEFAULT_MARTIN_URL = process.env.NEXT_PUBLIC_MARTIN_URL ?? "http://10.10.10.166:3010";
|
||||
|
||||
/** 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",
|
||||
} as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Props */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type MapViewerHandle = {
|
||||
getMap: () => maplibregl.Map | null;
|
||||
setLayerVisibility: (visibility: LayerVisibility) => void;
|
||||
flyTo: (center: [number, number], zoom?: number) => void;
|
||||
};
|
||||
|
||||
type MapViewerProps = {
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
martinUrl?: string;
|
||||
className?: string;
|
||||
onFeatureClick?: (feature: ClickedFeature) => 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,
|
||||
onFeatureClick,
|
||||
layerVisibility,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
const popupRef = useRef<maplibregl.Popup | null>(null);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
|
||||
const resolvedMartinUrl = martinUrl ?? DEFAULT_MARTIN_URL;
|
||||
|
||||
/* ---- 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 });
|
||||
},
|
||||
}));
|
||||
|
||||
/* ---- 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]);
|
||||
|
||||
/* ---- Map initialization ---- */
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {
|
||||
osm: {
|
||||
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:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: "osm-tiles",
|
||||
type: "raster",
|
||||
source: "osm",
|
||||
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");
|
||||
map.addControl(
|
||||
new maplibregl.GeolocateControl({
|
||||
positionOptions: { enableHighAccuracy: true },
|
||||
trackUserLocation: false,
|
||||
}),
|
||||
"top-right"
|
||||
);
|
||||
|
||||
/* ---- 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.4,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: LAYER_IDS.terenuriLine,
|
||||
type: "line",
|
||||
source: SOURCES.terenuri,
|
||||
"source-layer": SOURCES.terenuri,
|
||||
minzoom: 13,
|
||||
paint: {
|
||||
"line-color": "#1a1a1a",
|
||||
"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,
|
||||
},
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
// 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 ?? "";
|
||||
|
||||
// Notify parent
|
||||
if (onFeatureClick) {
|
||||
onFeatureClick({
|
||||
layerId: first.layer?.id ?? "",
|
||||
sourceLayer,
|
||||
properties: props,
|
||||
coordinates: [e.lngLat.lng, e.lngLat.lat],
|
||||
});
|
||||
}
|
||||
|
||||
// Show popup
|
||||
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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ModuleConfig } from "@/core/module-registry/types";
|
||||
|
||||
export const geoportalConfig: ModuleConfig = {
|
||||
id: "geoportal",
|
||||
name: "Geoportal",
|
||||
description: "Harta interactiva cu parcele cadastrale, cladiri si limite UAT",
|
||||
icon: "globe",
|
||||
route: "/geoportal",
|
||||
category: "tools",
|
||||
featureFlag: "module.geoportal",
|
||||
visibility: "all",
|
||||
version: "0.1.0",
|
||||
dependencies: ["parcel-sync"],
|
||||
storageNamespace: "geoportal",
|
||||
navOrder: 43,
|
||||
tags: ["gis", "harta", "parcele", "cladiri", "uat", "maplibre"],
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { GeoportalModule } from "./components/geoportal-module";
|
||||
export { MapViewer } from "./components/map-viewer";
|
||||
export { geoportalConfig } from "./config";
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Geoportal module types.
|
||||
*/
|
||||
|
||||
/** Martin vector tile source definition */
|
||||
export type MartinSource = {
|
||||
id: string;
|
||||
/** Human-readable label (Romanian) */
|
||||
label: string;
|
||||
/** Martin source name (matches PostGIS table/view) */
|
||||
sourceName: string;
|
||||
/** Minimum zoom level for visibility */
|
||||
minZoom: number;
|
||||
/** Maximum zoom level for visibility */
|
||||
maxZoom: number;
|
||||
/** Whether layer is visible by default */
|
||||
defaultVisible: boolean;
|
||||
};
|
||||
|
||||
/** Layer style configuration */
|
||||
export type LayerStyle = {
|
||||
fillColor: string;
|
||||
fillOpacity: number;
|
||||
lineColor: string;
|
||||
lineWidth: number;
|
||||
};
|
||||
|
||||
/** Feature attributes from a clicked map feature */
|
||||
export type ClickedFeature = {
|
||||
layerId: string;
|
||||
sourceLayer: string;
|
||||
properties: Record<string, unknown>;
|
||||
coordinates: [number, number];
|
||||
};
|
||||
|
||||
/** Layer visibility state */
|
||||
export type LayerVisibility = Record<string, boolean>;
|
||||
|
||||
/** Map view state */
|
||||
export type MapViewState = {
|
||||
center: [number, number];
|
||||
zoom: number;
|
||||
};
|
||||
@@ -61,12 +61,29 @@ import {
|
||||
} from "../services/eterra-layers";
|
||||
import type { ParcelDetail } from "@/app/api/eterra/search/route";
|
||||
import type { OwnerSearchResult } from "@/app/api/eterra/search-owner/route";
|
||||
import { User, FileText, Archive } from "lucide-react";
|
||||
import { User, FileText, Archive, Map as MapIcon } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { UatDashboard } from "./uat-dashboard";
|
||||
import { EpayConnect, type EpaySessionStatus } from "./epay-connect";
|
||||
import { EpayOrderButton } from "./epay-order-button";
|
||||
import { EpayTab } from "./epay-tab";
|
||||
|
||||
/* MapLibre uses WebGL — must disable SSR */
|
||||
const MapViewer = dynamic(
|
||||
() =>
|
||||
import("@/modules/geoportal/components/map-viewer").then((m) => ({
|
||||
default: m.MapViewer,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex items-center justify-center h-64 bg-muted/30 rounded-lg">
|
||||
<p className="text-sm text-muted-foreground">Se incarca harta...</p>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -2070,6 +2087,10 @@ export function ParcelSyncModule() {
|
||||
<FileText className="h-4 w-4" />
|
||||
Extrase CF
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="map" className="gap-1.5">
|
||||
<MapIcon className="h-4 w-4" />
|
||||
Harta
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -4765,6 +4786,15 @@ export function ParcelSyncModule() {
|
||||
<TabsContent value="extracts" className="space-y-4">
|
||||
<EpayTab />
|
||||
</TabsContent>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
{/* Tab 6: Harta (MapLibre GL) */}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
<TabsContent value="map" className="space-y-4">
|
||||
<div className="relative h-[600px] rounded-lg border overflow-hidden">
|
||||
<MapViewer className="h-full w-full" />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user