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:
+20
@@ -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"
|
||||
? `${window.location.origin}/tiles`
|
||||
: "/tiles";
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user