From 4f694d44587961b093dce21f4e1d23b691675d29 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Mon, 23 Mar 2026 20:34:15 +0200 Subject: [PATCH] perf(geoportal): 4-level UAT simplification + intravilan layer + preserve view on basemap switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UAT zoom-dependent views (read-only, original geom NEVER modified): - gis_uats_z0 (z0-5): 2000m simplification — country outlines - gis_uats_z5 (z5-8): 500m — regional overview - gis_uats_z8 (z8-12): 50m — county/city level with labels - gis_uats_z12 (z12+): 10m — near-original precision New layers: - gis_administrativ (intravilan, arii speciale) — orange dashed, no simplification - Toggle in layer panel (off by default) Basemap switching: - Now preserves current center + zoom when switching between basemaps Parcels + buildings: NO simplification (exact geometry needed) Co-Authored-By: Claude Opus 4.6 (1M context) --- martin.yaml | 64 ++++- prisma/gisuat-postgis-setup.sql | 58 +++-- .../geoportal/components/layer-panel.tsx | 7 + .../geoportal/components/map-viewer.tsx | 230 ++++++++++-------- 4 files changed, 231 insertions(+), 128 deletions(-) diff --git a/martin.yaml b/martin.yaml index 265719c..a7e789f 100644 --- a/martin.yaml +++ b/martin.yaml @@ -1,36 +1,82 @@ # Martin v0.15 configuration — optimized tile sources for ArchiTools Geoportal # All geometries are EPSG:3844 (Stereo70). Bounds are approximate Romania extent. +# Original table data is NEVER modified — views compute simplification on-the-fly. -# Disable auto-discovery — only serve explicitly configured sources postgres: connection_string: ${DATABASE_URL} default_srid: 3844 auto_publish: false tables: - gis_uats: + # ── UAT boundaries: 4 zoom-dependent simplification levels ── + + gis_uats_z0: schema: public - table: gis_uats + table: gis_uats_z0 geometry_column: geom srid: 3844 bounds: [20.2, 43.5, 30.0, 48.3] minzoom: 0 - maxzoom: 14 + maxzoom: 5 properties: name: text siruta: text - gis_uats_simple: + gis_uats_z5: schema: public - table: gis_uats_simple + table: gis_uats_z5 geometry_column: geom srid: 3844 bounds: [20.2, 43.5, 30.0, 48.3] - minzoom: 0 - maxzoom: 9 + minzoom: 5 + maxzoom: 8 properties: name: text siruta: text + gis_uats_z8: + schema: public + table: gis_uats_z8 + geometry_column: geom + srid: 3844 + bounds: [20.2, 43.5, 30.0, 48.3] + minzoom: 8 + maxzoom: 12 + properties: + name: text + siruta: text + county: text + + gis_uats_z12: + schema: public + table: gis_uats_z12 + geometry_column: geom + srid: 3844 + bounds: [20.2, 43.5, 30.0, 48.3] + minzoom: 12 + maxzoom: 16 + properties: + name: text + siruta: text + county: text + + # ── Administrativ (intravilan, arii speciale) — NO simplification ── + + gis_administrativ: + schema: public + table: gis_administrativ + geometry_column: geom + srid: 3844 + bounds: [20.2, 43.5, 30.0, 48.3] + minzoom: 10 + maxzoom: 16 + properties: + object_id: text + siruta: text + layer_id: text + cadastral_ref: text + + # ── Terenuri (parcels) — NO simplification ── + gis_terenuri: schema: public table: gis_terenuri @@ -46,6 +92,8 @@ postgres: area_value: float8 layer_id: text + # ── Cladiri (buildings) — NO simplification ── + gis_cladiri: schema: public table: gis_cladiri diff --git a/prisma/gisuat-postgis-setup.sql b/prisma/gisuat-postgis-setup.sql index d76d862..9fa9b18 100644 --- a/prisma/gisuat-postgis-setup.sql +++ b/prisma/gisuat-postgis-setup.sql @@ -158,29 +158,49 @@ WHERE geometry IS NOT NULL AND geom IS NULL; CREATE INDEX IF NOT EXISTS gis_uat_geom_idx ON "GisUat" USING GIST (geom); --- 8. Martin/QGIS-friendly view (moderate simplification — 50m tolerance) -CREATE OR REPLACE VIEW gis_uats AS -SELECT - siruta, - name, - county, - ST_SimplifyPreserveTopology(geom, 50) AS geom -FROM "GisUat" -WHERE geom IS NOT NULL; +-- ============================================================================= +-- 8. Zoom-dependent views for Martin vector tiles +-- 4 levels of geometry simplification for progressive loading. +-- SAFE: these are read-only views — original geom column is NEVER modified. +-- ============================================================================= --- 9. Simplified view for low zoom levels (z5-z9) — 500m tolerance -CREATE OR REPLACE VIEW gis_uats_simple AS -SELECT - siruta, - name, +-- z0-5: Very coarse overview (2000m tolerance) — country-level outlines +CREATE OR REPLACE VIEW gis_uats_z0 AS +SELECT siruta, name, + ST_SimplifyPreserveTopology(geom, 2000) AS geom +FROM "GisUat" WHERE geom IS NOT NULL; + +-- z5-8: Coarse (500m tolerance) — regional overview +CREATE OR REPLACE VIEW gis_uats_z5 AS +SELECT siruta, name, ST_SimplifyPreserveTopology(geom, 500) AS geom -FROM "GisUat" -WHERE geom IS NOT NULL; +FROM "GisUat" WHERE geom IS NOT NULL; + +-- z8-12: Moderate (50m tolerance) — county/city level +CREATE OR REPLACE VIEW gis_uats_z8 AS +SELECT siruta, name, county, + ST_SimplifyPreserveTopology(geom, 50) AS geom +FROM "GisUat" WHERE geom IS NOT NULL; + +-- z12+: Fine (10m tolerance) — near-original precision +CREATE OR REPLACE VIEW gis_uats_z12 AS +SELECT siruta, name, county, + ST_SimplifyPreserveTopology(geom, 10) AS geom +FROM "GisUat" WHERE geom IS NOT NULL; + +-- Keep the legacy gis_uats view for QGIS compatibility +CREATE OR REPLACE VIEW gis_uats AS +SELECT siruta, name, county, + ST_SimplifyPreserveTopology(geom, 50) AS geom +FROM "GisUat" WHERE geom IS NOT NULL; -- ============================================================================= -- Done! Martin serves these views as vector tiles: --- - gis_uats (moderate detail, z9+) --- - gis_uats_simple (coarse overview, z5-z9) --- QGIS: PostgreSQL -> 10.10.10.166:5432 / architools_db -> gis_uats view +-- - gis_uats_z0 (z0-5, 2000m simplification) +-- - gis_uats_z5 (z5-8, 500m) +-- - gis_uats_z8 (z8-12, 50m) +-- - gis_uats_z12 (z12+, 10m near-original) +-- - gis_uats (legacy for QGIS, 50m) +-- Original geometry in GisUat.geom is NEVER modified. -- SRID: 3844 (Stereo70) -- ============================================================================= diff --git a/src/modules/geoportal/components/layer-panel.tsx b/src/modules/geoportal/components/layer-panel.tsx index dfff856..3689f09 100644 --- a/src/modules/geoportal/components/layer-panel.tsx +++ b/src/modules/geoportal/components/layer-panel.tsx @@ -42,6 +42,13 @@ const LAYER_GROUPS: LayerGroupDef[] = [ color: "#3b82f6", defaultVisible: true, }, + { + id: "administrativ", + label: "Intravilan", + description: "Limite intravilan, arii speciale (zoom >= 10)", + color: "#ea580c", + defaultVisible: false, + }, ]; /* ------------------------------------------------------------------ */ diff --git a/src/modules/geoportal/components/map-viewer.tsx b/src/modules/geoportal/components/map-viewer.tsx index a4626c3..fc8faed 100644 --- a/src/modules/geoportal/components/map-viewer.tsx +++ b/src/modules/geoportal/components/map-viewer.tsx @@ -34,19 +34,28 @@ const DEFAULT_ZOOM = 7; /** Source/layer IDs used on the map */ const SOURCES = { - uatsSimple: "gis_uats_simple", - uats: "gis_uats", + uatsZ0: "gis_uats_z0", // z0-5: 2000m simplification + uatsZ5: "gis_uats_z5", // z5-8: 500m + uatsZ8: "gis_uats_z8", // z8-12: 50m + uatsZ12: "gis_uats_z12", // z12+: 10m (near-original) terenuri: "gis_terenuri", cladiri: "gis_cladiri", + administrativ: "gis_administrativ", } as const; /** Map layer IDs (prefixed to avoid collisions) */ const LAYER_IDS = { - uatsSimpleFill: "layer-uats-simple-fill", - uatsSimpleLine: "layer-uats-simple-line", - uatsFill: "layer-uats-fill", - uatsLine: "layer-uats-line", - uatsLabel: "layer-uats-label", + uatsZ0Line: "layer-uats-z0-line", + uatsZ5Fill: "layer-uats-z5-fill", + uatsZ5Line: "layer-uats-z5-line", + uatsZ8Fill: "layer-uats-z8-fill", + uatsZ8Line: "layer-uats-z8-line", + uatsZ8Label: "layer-uats-z8-label", + uatsZ12Fill: "layer-uats-z12-fill", + uatsZ12Line: "layer-uats-z12-line", + uatsZ12Label: "layer-uats-z12-label", + adminFill: "layer-admin-fill", + adminLine: "layer-admin-line", terenuriFill: "layer-terenuri-fill", terenuriLine: "layer-terenuri-line", cladiriFill: "layer-cladiri-fill", @@ -235,7 +244,13 @@ export const MapViewer = forwardRef( if (!map || !map.isStyleLoaded()) return; const mapping: Record = { - uats: [LAYER_IDS.uatsSimpleFill, LAYER_IDS.uatsSimpleLine, LAYER_IDS.uatsFill, LAYER_IDS.uatsLine, LAYER_IDS.uatsLabel], + uats: [ + LAYER_IDS.uatsZ0Line, + LAYER_IDS.uatsZ5Fill, LAYER_IDS.uatsZ5Line, + LAYER_IDS.uatsZ8Fill, LAYER_IDS.uatsZ8Line, LAYER_IDS.uatsZ8Label, + LAYER_IDS.uatsZ12Fill, LAYER_IDS.uatsZ12Line, LAYER_IDS.uatsZ12Label, + ], + administrativ: [LAYER_IDS.adminFill, LAYER_IDS.adminLine], terenuri: [LAYER_IDS.terenuriFill, LAYER_IDS.terenuriLine], cladiri: [LAYER_IDS.cladiriFill, LAYER_IDS.cladiriLine], }; @@ -263,13 +278,18 @@ export const MapViewer = forwardRef( useEffect(() => { if (!containerRef.current) return; + // Preserve current view when switching basemaps + const prevMap = mapRef.current; + const currentCenter = prevMap ? prevMap.getCenter().toArray() as [number, number] : (center ?? DEFAULT_CENTER); + const currentZoom = prevMap ? prevMap.getZoom() : (zoom ?? DEFAULT_ZOOM); + const basemapDef = BASEMAPS[basemap]; const map = new maplibregl.Map({ container: containerRef.current, style: buildStyle(basemapDef), - center: center ?? DEFAULT_CENTER, - zoom: zoom ?? DEFAULT_ZOOM, + center: currentCenter, + zoom: currentZoom, maxZoom: basemapDef.maxzoom ?? 20, }); @@ -281,111 +301,118 @@ export const MapViewer = forwardRef( /* ---- Add Martin sources + layers on load ---- */ map.on("load", () => { - // --- UAT boundaries (simplified, low zoom z0-z9) --- - map.addSource(SOURCES.uatsSimple, { + const m = resolvedMartinUrl; + + // === UAT z0-5: very coarse (2000m) — lines only === + map.addSource(SOURCES.uatsZ0, { type: "vector", - tiles: [`${resolvedMartinUrl}/${SOURCES.uatsSimple}/{z}/{x}/{y}`], - minzoom: 0, - maxzoom: 9, + tiles: [`${m}/${SOURCES.uatsZ0}/{z}/{x}/{y}`], + minzoom: 0, maxzoom: 5, }); - map.addLayer({ - id: LAYER_IDS.uatsSimpleFill, - type: "fill", - source: SOURCES.uatsSimple, - "source-layer": SOURCES.uatsSimple, - maxzoom: 9, - paint: { - "fill-color": "#8b5cf6", - "fill-opacity": [ - "interpolate", ["linear"], ["zoom"], - 5, 0.03, - 8, 0.05, - ], - }, + id: LAYER_IDS.uatsZ0Line, type: "line", + source: SOURCES.uatsZ0, "source-layer": SOURCES.uatsZ0, + maxzoom: 5, + paint: { "line-color": "#7c3aed", "line-width": 0.3 }, }); - map.addLayer({ - id: LAYER_IDS.uatsSimpleLine, - type: "line", - source: SOURCES.uatsSimple, - "source-layer": SOURCES.uatsSimple, - maxzoom: 9, - paint: { - "line-color": "#7c3aed", - "line-width": [ - "interpolate", ["linear"], ["zoom"], - 5, 0.5, - 8, 1, - ], - }, - }); - - // --- UAT boundaries (detailed, high zoom z9+) --- - map.addSource(SOURCES.uats, { + // === UAT z5-8: coarse (500m) — lines + faint fill === + map.addSource(SOURCES.uatsZ5, { type: "vector", - tiles: [`${resolvedMartinUrl}/${SOURCES.uats}/{z}/{x}/{y}`], - minzoom: 9, - maxzoom: 16, + tiles: [`${m}/${SOURCES.uatsZ5}/{z}/{x}/{y}`], + minzoom: 5, maxzoom: 8, + }); + map.addLayer({ + id: LAYER_IDS.uatsZ5Fill, type: "fill", + source: SOURCES.uatsZ5, "source-layer": SOURCES.uatsZ5, + minzoom: 5, maxzoom: 8, + paint: { "fill-color": "#8b5cf6", "fill-opacity": 0.03 }, + }); + map.addLayer({ + id: LAYER_IDS.uatsZ5Line, type: "line", + source: SOURCES.uatsZ5, "source-layer": SOURCES.uatsZ5, + minzoom: 5, maxzoom: 8, + paint: { "line-color": "#7c3aed", "line-width": 0.6 }, }); - map.addLayer({ - id: LAYER_IDS.uatsFill, - type: "fill", - source: SOURCES.uats, - "source-layer": SOURCES.uats, - minzoom: 8, - paint: { - "fill-color": "#8b5cf6", - "fill-opacity": [ - "interpolate", ["linear"], ["zoom"], - 8, 0.03, - 12, 0.08, - ], - }, + // === UAT z8-12: moderate (50m) — lines + fill + labels === + map.addSource(SOURCES.uatsZ8, { + type: "vector", + tiles: [`${m}/${SOURCES.uatsZ8}/{z}/{x}/{y}`], + minzoom: 8, maxzoom: 12, }); - map.addLayer({ - id: LAYER_IDS.uatsLine, - type: "line", - source: SOURCES.uats, - "source-layer": SOURCES.uats, - minzoom: 9, - paint: { - "line-color": "#7c3aed", - "line-width": [ - "interpolate", ["linear"], ["zoom"], - 5, 0.5, - 8, 1, - 12, 2, - ], - }, + id: LAYER_IDS.uatsZ8Fill, type: "fill", + source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, + minzoom: 8, maxzoom: 12, + paint: { "fill-color": "#8b5cf6", "fill-opacity": 0.05 }, }); - map.addLayer({ - id: LAYER_IDS.uatsLabel, - type: "symbol", - source: SOURCES.uats, - "source-layer": SOURCES.uats, - minzoom: 9, + id: LAYER_IDS.uatsZ8Line, type: "line", + source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, + minzoom: 8, maxzoom: 12, + paint: { "line-color": "#7c3aed", "line-width": 1 }, + }); + map.addLayer({ + id: LAYER_IDS.uatsZ8Label, type: "symbol", + source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, + minzoom: 9, maxzoom: 12, layout: { - "text-field": ["coalesce", ["get", "name"], ["get", "uat_name"], ""], - "text-size": [ - "interpolate", ["linear"], ["zoom"], - 9, 10, - 14, 14, - ], - "text-anchor": "center", - "text-allow-overlap": false, - }, - paint: { - "text-color": "#5b21b6", - "text-halo-color": "#ffffff", - "text-halo-width": 1.5, + "text-field": ["coalesce", ["get", "name"], ""], + "text-size": 10, "text-anchor": "center", "text-allow-overlap": false, }, + paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 }, }); - // --- Terenuri (parcels) --- + // === UAT z12+: fine (10m) — full detail === + map.addSource(SOURCES.uatsZ12, { + type: "vector", + tiles: [`${m}/${SOURCES.uatsZ12}/{z}/{x}/{y}`], + minzoom: 12, maxzoom: 16, + }); + map.addLayer({ + id: LAYER_IDS.uatsZ12Fill, type: "fill", + source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, + minzoom: 12, + paint: { "fill-color": "#8b5cf6", "fill-opacity": 0.08 }, + }); + map.addLayer({ + id: LAYER_IDS.uatsZ12Line, type: "line", + source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, + minzoom: 12, + paint: { "line-color": "#7c3aed", "line-width": 2 }, + }); + map.addLayer({ + id: LAYER_IDS.uatsZ12Label, type: "symbol", + source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, + minzoom: 12, + layout: { + "text-field": ["coalesce", ["get", "name"], ""], + "text-size": 13, "text-anchor": "center", "text-allow-overlap": false, + }, + paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 }, + }); + + // === Administrativ (intravilan, arii speciale) === + map.addSource(SOURCES.administrativ, { + type: "vector", + tiles: [`${m}/${SOURCES.administrativ}/{z}/{x}/{y}`], + minzoom: 10, maxzoom: 16, + }); + map.addLayer({ + id: LAYER_IDS.adminFill, type: "fill", + source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, + minzoom: 11, + paint: { "fill-color": "#f97316", "fill-opacity": 0.06 }, + }); + map.addLayer({ + id: LAYER_IDS.adminLine, type: "line", + source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, + minzoom: 10, + paint: { "line-color": "#ea580c", "line-width": 1.2, "line-dasharray": [4, 2] }, + }); + + // --- Terenuri (parcels) — NO simplification --- map.addSource(SOURCES.terenuri, { type: "vector", tiles: [`${resolvedMartinUrl}/${SOURCES.terenuri}/{z}/{x}/{y}`], @@ -489,8 +516,9 @@ export const MapViewer = forwardRef( const clickableLayers = [ LAYER_IDS.terenuriFill, LAYER_IDS.cladiriFill, - LAYER_IDS.uatsFill, - LAYER_IDS.uatsSimpleFill, + LAYER_IDS.uatsZ5Fill, + LAYER_IDS.uatsZ8Fill, + LAYER_IDS.uatsZ12Fill, ]; map.on("click", (e) => {