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
|
area_value: float8
|
||||||
layer_id: text
|
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 ──
|
# ── Cladiri (buildings) — NO simplification ──
|
||||||
|
|
||||||
gis_cladiri:
|
gis_cladiri:
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ type MapTabProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Helpers — typed map operations */
|
/* Typed map handle (avoids importing maplibregl types) */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
type MapLike = {
|
type MapLike = {
|
||||||
@@ -64,9 +64,14 @@ type MapLike = {
|
|||||||
getSource(id: string): unknown;
|
getSource(id: string): unknown;
|
||||||
addSource(id: string, source: Record<string, unknown>): void;
|
addSource(id: string, source: Record<string, unknown>): void;
|
||||||
addLayer(layer: Record<string, unknown>, before?: string): void;
|
addLayer(layer: Record<string, unknown>, before?: string): void;
|
||||||
|
removeLayer(id: string): void;
|
||||||
|
removeSource(id: string): void;
|
||||||
setFilter(id: string, filter: unknown[] | null): void;
|
setFilter(id: string, filter: unknown[] | null): void;
|
||||||
setLayoutProperty(id: string, prop: string, value: unknown): 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;
|
isStyleLoaded(): boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,40 +94,17 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const [boundsLoading, setBoundsLoading] = useState(false);
|
const [boundsLoading, setBoundsLoading] = useState(false);
|
||||||
const [flyTarget, setFlyTarget] = useState<
|
|
||||||
{ center: [number, number]; zoom?: number } | undefined
|
|
||||||
>();
|
|
||||||
const [mapReady, setMapReady] = useState(false);
|
const [mapReady, setMapReady] = useState(false);
|
||||||
const [viewsReady, setViewsReady] = useState<boolean | null>(null);
|
|
||||||
const appliedSirutaRef = useRef("");
|
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>({
|
const [layerVisibility] = useState<LayerVisibility>({
|
||||||
terenuri: true,
|
terenuri: true,
|
||||||
cladiri: true,
|
cladiri: true,
|
||||||
administrativ: false,
|
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 ──────────────────────────────── */
|
/* ── Detect when map is ready ──────────────────────────────── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sirutaValid) return;
|
if (!sirutaValid) return;
|
||||||
@@ -136,7 +118,46 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
|
|||||||
return () => clearInterval(check);
|
return () => clearInterval(check);
|
||||||
}, [sirutaValid]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!mapReady || !sirutaValid || !siruta) return;
|
if (!mapReady || !sirutaValid || !siruta) return;
|
||||||
if (appliedSirutaRef.current === siruta) return;
|
if (appliedSirutaRef.current === siruta) return;
|
||||||
@@ -147,28 +168,29 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
|
|||||||
appliedSirutaRef.current = siruta;
|
appliedSirutaRef.current = siruta;
|
||||||
const filter = ["==", ["get", "siruta"], siruta];
|
const filter = ["==", ["get", "siruta"], siruta];
|
||||||
|
|
||||||
|
// Filter base layers by siruta
|
||||||
for (const layerId of BASE_LAYERS) {
|
for (const layerId of BASE_LAYERS) {
|
||||||
try {
|
try {
|
||||||
if (!map.getLayer(layerId)) continue;
|
if (map.getLayer(layerId)) map.setFilter(layerId, filter);
|
||||||
map.setFilter(layerId, filter);
|
|
||||||
} catch {
|
} catch {
|
||||||
/* layer may not exist */
|
/* noop */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [mapReady, siruta, sirutaValid]);
|
|
||||||
|
|
||||||
/* ── Add enrichment overlay source + layers ────────────────── */
|
// Hide base terenuri fill — we'll add enrichment overlay instead
|
||||||
useEffect(() => {
|
try {
|
||||||
if (!mapReady || !viewsReady || !sirutaValid || !siruta) return;
|
if (map.getLayer("l-terenuri-fill"))
|
||||||
|
map.setLayoutProperty("l-terenuri-fill", "visibility", "none");
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
|
||||||
const map = asMap(mapHandleRef.current);
|
const martinBase =
|
||||||
if (!map) return;
|
typeof window !== "undefined"
|
||||||
|
? `${window.location.origin}/tiles`
|
||||||
|
: "/tiles";
|
||||||
|
|
||||||
const martinBase = typeof window !== "undefined"
|
// Add enrichment source + layers (or update filter if already added)
|
||||||
? `${window.location.origin}/tiles`
|
|
||||||
: "/tiles";
|
|
||||||
|
|
||||||
// Add gis_terenuri_status source (only once)
|
|
||||||
if (!map.getSource("gis_terenuri_status")) {
|
if (!map.getSource("gis_terenuri_status")) {
|
||||||
map.addSource("gis_terenuri_status", {
|
map.addSource("gis_terenuri_status", {
|
||||||
type: "vector",
|
type: "vector",
|
||||||
@@ -185,23 +207,21 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
|
|||||||
source: "gis_terenuri_status",
|
source: "gis_terenuri_status",
|
||||||
"source-layer": "gis_terenuri_status",
|
"source-layer": "gis_terenuri_status",
|
||||||
minzoom: 13,
|
minzoom: 13,
|
||||||
filter: ["==", ["get", "siruta"], siruta],
|
filter,
|
||||||
paint: {
|
paint: {
|
||||||
"fill-color": [
|
"fill-color": [
|
||||||
"case",
|
"case",
|
||||||
// Enriched parcels: darker green
|
|
||||||
["==", ["get", "has_enrichment"], 1],
|
["==", ["get", "has_enrichment"], 1],
|
||||||
"#15803d",
|
"#15803d", // dark green: enriched
|
||||||
// No enrichment: lighter green
|
"#86efac", // light green: no enrichment
|
||||||
"#86efac",
|
|
||||||
],
|
],
|
||||||
"fill-opacity": 0.25,
|
"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(
|
map.addLayer(
|
||||||
{
|
{
|
||||||
id: "l-ps-terenuri-line",
|
id: "l-ps-terenuri-line",
|
||||||
@@ -209,7 +229,7 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
|
|||||||
source: "gis_terenuri_status",
|
source: "gis_terenuri_status",
|
||||||
"source-layer": "gis_terenuri_status",
|
"source-layer": "gis_terenuri_status",
|
||||||
minzoom: 13,
|
minzoom: 13,
|
||||||
filter: ["==", ["get", "siruta"], siruta],
|
filter,
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": [
|
"line-color": [
|
||||||
"case",
|
"case",
|
||||||
@@ -237,61 +257,17 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
|
|||||||
"l-cladiri-fill",
|
"l-cladiri-fill",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Source already exists — just update filters for new siruta
|
// Source already exists — update filters for new siruta
|
||||||
const sirutaFilter = ["==", ["get", "siruta"], siruta];
|
|
||||||
try {
|
try {
|
||||||
if (map.getLayer("l-ps-terenuri-fill"))
|
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"))
|
if (map.getLayer("l-ps-terenuri-line"))
|
||||||
map.setFilter("l-ps-terenuri-line", sirutaFilter);
|
map.setFilter("l-ps-terenuri-line", filter);
|
||||||
} catch {
|
} catch {
|
||||||
/* noop */
|
/* noop */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, [mapReady, siruta, sirutaValid]);
|
||||||
// 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]);
|
|
||||||
|
|
||||||
/* ── Feature click handler ─────────────────────────────────── */
|
/* ── Feature click handler ─────────────────────────────────── */
|
||||||
const handleFeatureClick = useCallback(
|
const handleFeatureClick = useCallback(
|
||||||
@@ -344,8 +320,6 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
|
|||||||
onFeatureClick={handleFeatureClick}
|
onFeatureClick={handleFeatureClick}
|
||||||
onSelectionChange={setSelectedFeatures}
|
onSelectionChange={setSelectedFeatures}
|
||||||
layerVisibility={layerVisibility}
|
layerVisibility={layerVisibility}
|
||||||
center={flyTarget?.center}
|
|
||||||
zoom={flyTarget?.zoom}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Top-right: basemap switcher + feature panel */}
|
{/* Top-right: basemap switcher + feature panel */}
|
||||||
@@ -375,19 +349,37 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
|
|||||||
{/* Bottom-right: legend */}
|
{/* 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="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">
|
<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
|
Fără enrichment
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<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
|
Cu enrichment
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<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
|
Cu clădire
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<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
|
Clădire fără acte
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user