fix(parcel-sync): fitBounds zoom + Martin config for enrichment tiles

- Map tab now uses fitBounds (not flyTo with fixed zoom) to show entire
  UAT extent when selected. Bounds are fetched and applied after map ready.
- Added gis_terenuri_status to martin.yaml so Martin serves enrichment
  tiles (has_enrichment, has_building, build_legal properties).
- Removed center/zoom props from MapViewer — use fitBounds via handle.
- Requires `docker restart martin` on server for Martin to reload config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-24 15:38:15 +02:00
parent 2b8d144924
commit 2848868263
2 changed files with 116 additions and 104 deletions
+20
View File
@@ -92,6 +92,26 @@ postgres:
area_value: float8
layer_id: text
# ── Terenuri cu status enrichment (ParcelSync Harta tab) ──
gis_terenuri_status:
schema: public
table: gis_terenuri_status
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 10
maxzoom: 18
properties:
object_id: text
siruta: text
cadastral_ref: text
area_value: float8
layer_id: text
has_enrichment: int4
has_building: int4
build_legal: int4
# ── Cladiri (buildings) — NO simplification ──
gis_cladiri:
@@ -56,7 +56,7 @@ type MapTabProps = {
};
/* ------------------------------------------------------------------ */
/* Helpers — typed map operations */
/* Typed map handle (avoids importing maplibregl types) */
/* ------------------------------------------------------------------ */
type MapLike = {
@@ -64,9 +64,14 @@ type MapLike = {
getSource(id: string): unknown;
addSource(id: string, source: Record<string, unknown>): void;
addLayer(layer: Record<string, unknown>, before?: string): void;
removeLayer(id: string): void;
removeSource(id: string): void;
setFilter(id: string, filter: unknown[] | null): void;
setLayoutProperty(id: string, prop: string, value: unknown): void;
fitBounds(bounds: [number, number, number, number], opts?: Record<string, unknown>): void;
fitBounds(
bounds: [number, number, number, number],
opts?: Record<string, unknown>,
): void;
isStyleLoaded(): boolean;
};
@@ -89,40 +94,17 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
[],
);
const [boundsLoading, setBoundsLoading] = useState(false);
const [flyTarget, setFlyTarget] = useState<
{ center: [number, number]; zoom?: number } | undefined
>();
const [mapReady, setMapReady] = useState(false);
const [viewsReady, setViewsReady] = useState<boolean | null>(null);
const appliedSirutaRef = useRef("");
const boundsRef = useRef<[number, number, number, number] | null>(null);
/* Layer visibility: show terenuri + cladiri, hide admin */
/* Layer visibility: show terenuri + cladiri, hide admin + UATs */
const [layerVisibility] = useState<LayerVisibility>({
terenuri: true,
cladiri: true,
administrativ: false,
});
/* ── Check if enrichment views exist, create if not ────────── */
useEffect(() => {
fetch("/api/geoportal/setup-enrichment-views")
.then((r) => r.json())
.then((data: { ready?: boolean }) => {
if (data.ready) {
setViewsReady(true);
} else {
// Auto-create views
fetch("/api/geoportal/setup-enrichment-views", { method: "POST" })
.then((r) => r.json())
.then((res: { status?: string }) => {
setViewsReady(res.status === "ok");
})
.catch(() => setViewsReady(false));
}
})
.catch(() => setViewsReady(false));
}, []);
/* ── Detect when map is ready ──────────────────────────────── */
useEffect(() => {
if (!sirutaValid) return;
@@ -136,7 +118,46 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
return () => clearInterval(check);
}, [sirutaValid]);
/* ── Apply siruta filter on base map layers ────────────────── */
/* ── Fetch UAT bounds ──────────────────────────────────────── */
const prevBoundsSirutaRef = useRef("");
useEffect(() => {
if (!sirutaValid || !siruta) return;
if (prevBoundsSirutaRef.current === siruta) return;
prevBoundsSirutaRef.current = siruta;
setBoundsLoading(true);
fetch(`/api/geoportal/uat-bounds?siruta=${siruta}`)
.then((r) => (r.ok ? r.json() : null))
.then(
(data: { bounds?: [[number, number], [number, number]] } | null) => {
if (data?.bounds) {
const [[minLng, minLat], [maxLng, maxLat]] = data.bounds;
boundsRef.current = [minLng, minLat, maxLng, maxLat];
// If map already ready, fitBounds immediately
const map = asMap(mapHandleRef.current);
if (map) {
map.fitBounds([minLng, minLat, maxLng, maxLat], {
padding: 40,
duration: 1500,
});
}
}
},
)
.catch(() => {})
.finally(() => setBoundsLoading(false));
}, [siruta, sirutaValid]);
/* ── When map becomes ready, fitBounds if we have bounds ───── */
useEffect(() => {
if (!mapReady || !boundsRef.current) return;
const map = asMap(mapHandleRef.current);
if (!map) return;
map.fitBounds(boundsRef.current, { padding: 40, duration: 1500 });
}, [mapReady]);
/* ── Apply siruta filter + enrichment overlay ──────────────── */
useEffect(() => {
if (!mapReady || !sirutaValid || !siruta) return;
if (appliedSirutaRef.current === siruta) return;
@@ -147,28 +168,29 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
appliedSirutaRef.current = siruta;
const filter = ["==", ["get", "siruta"], siruta];
// Filter base layers by siruta
for (const layerId of BASE_LAYERS) {
try {
if (!map.getLayer(layerId)) continue;
map.setFilter(layerId, filter);
if (map.getLayer(layerId)) map.setFilter(layerId, filter);
} catch {
/* layer may not exist */
/* noop */
}
}
}, [mapReady, siruta, sirutaValid]);
/* ── Add enrichment overlay source + layers ────────────────── */
useEffect(() => {
if (!mapReady || !viewsReady || !sirutaValid || !siruta) return;
// Hide base terenuri fill — we'll add enrichment overlay instead
try {
if (map.getLayer("l-terenuri-fill"))
map.setLayoutProperty("l-terenuri-fill", "visibility", "none");
} catch {
/* noop */
}
const map = asMap(mapHandleRef.current);
if (!map) return;
const martinBase = typeof window !== "undefined"
const martinBase =
typeof window !== "undefined"
? `${window.location.origin}/tiles`
: "/tiles";
// Add gis_terenuri_status source (only once)
// Add enrichment source + layers (or update filter if already added)
if (!map.getSource("gis_terenuri_status")) {
map.addSource("gis_terenuri_status", {
type: "vector",
@@ -185,23 +207,21 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
source: "gis_terenuri_status",
"source-layer": "gis_terenuri_status",
minzoom: 13,
filter: ["==", ["get", "siruta"], siruta],
filter,
paint: {
"fill-color": [
"case",
// Enriched parcels: darker green
["==", ["get", "has_enrichment"], 1],
"#15803d",
// No enrichment: lighter green
"#86efac",
"#15803d", // dark green: enriched
"#86efac", // light green: no enrichment
],
"fill-opacity": 0.25,
},
},
"l-terenuri-line", // insert before line layer
"l-terenuri-line", // insert before outline
);
// Data-driven outline
// Data-driven outline: color by building status
map.addLayer(
{
id: "l-ps-terenuri-line",
@@ -209,7 +229,7 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
source: "gis_terenuri_status",
"source-layer": "gis_terenuri_status",
minzoom: 13,
filter: ["==", ["get", "siruta"], siruta],
filter,
paint: {
"line-color": [
"case",
@@ -237,61 +257,17 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
"l-cladiri-fill",
);
} else {
// Source already exists — just update filters for new siruta
const sirutaFilter = ["==", ["get", "siruta"], siruta];
// Source already exists — update filters for new siruta
try {
if (map.getLayer("l-ps-terenuri-fill"))
map.setFilter("l-ps-terenuri-fill", sirutaFilter);
map.setFilter("l-ps-terenuri-fill", filter);
if (map.getLayer("l-ps-terenuri-line"))
map.setFilter("l-ps-terenuri-line", sirutaFilter);
map.setFilter("l-ps-terenuri-line", filter);
} catch {
/* noop */
}
}
// Hide the base terenuri-fill (we replaced it with enrichment-aware version)
try {
if (map.getLayer("l-terenuri-fill"))
map.setLayoutProperty("l-terenuri-fill", "visibility", "none");
} catch {
/* noop */
}
}, [mapReady, viewsReady, siruta, sirutaValid]);
/* ── Fetch UAT bounds and zoom ─────────────────────────────── */
const prevBoundsSirutaRef = useRef("");
useEffect(() => {
if (!sirutaValid || !siruta) return;
if (prevBoundsSirutaRef.current === siruta) return;
prevBoundsSirutaRef.current = siruta;
setBoundsLoading(true);
fetch(`/api/geoportal/uat-bounds?siruta=${siruta}`)
.then((r) => (r.ok ? r.json() : null))
.then(
(data: {
bounds?: [[number, number], [number, number]];
} | null) => {
if (data?.bounds) {
const [[minLng, minLat], [maxLng, maxLat]] = data.bounds;
const centerLng = (minLng + maxLng) / 2;
const centerLat = (minLat + maxLat) / 2;
setFlyTarget({ center: [centerLng, centerLat], zoom: 13 });
// Fit bounds if map is already ready
const map = asMap(mapHandleRef.current);
if (map) {
map.fitBounds([minLng, minLat, maxLng, maxLat], {
padding: 40,
duration: 1500,
});
}
}
},
)
.catch(() => {})
.finally(() => setBoundsLoading(false));
}, [siruta, sirutaValid]);
}, [mapReady, siruta, sirutaValid]);
/* ── Feature click handler ─────────────────────────────────── */
const handleFeatureClick = useCallback(
@@ -344,8 +320,6 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
onFeatureClick={handleFeatureClick}
onSelectionChange={setSelectedFeatures}
layerVisibility={layerVisibility}
center={flyTarget?.center}
zoom={flyTarget?.zoom}
/>
{/* Top-right: basemap switcher + feature panel */}
@@ -375,19 +349,37 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
{/* Bottom-right: legend */}
<div className="absolute bottom-3 right-3 z-10 rounded-lg bg-background/90 border p-2 text-[10px] space-y-1">
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm border" style={{ backgroundColor: "rgba(134,239,172,0.25)", borderColor: "#15803d" }} />
<span
className="inline-block h-3 w-3 rounded-sm border"
style={{
backgroundColor: "rgba(134,239,172,0.25)",
borderColor: "#15803d",
}}
/>
Fără enrichment
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm border" style={{ backgroundColor: "rgba(21,128,61,0.25)", borderColor: "#15803d" }} />
<span
className="inline-block h-3 w-3 rounded-sm border"
style={{
backgroundColor: "rgba(21,128,61,0.25)",
borderColor: "#15803d",
}}
/>
Cu enrichment
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm" style={{ border: "2px solid #3b82f6" }} />
<span
className="inline-block h-3 w-3 rounded-sm"
style={{ border: "2px solid #3b82f6" }}
/>
Cu clădire
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm" style={{ border: "2px solid #ef4444" }} />
<span
className="inline-block h-3 w-3 rounded-sm"
style={{ border: "2px solid #ef4444" }}
/>
Clădire fără acte
</div>
</div>