diff --git a/.env.example b/.env.example index fd125b6e..e17919cf 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,7 @@ REACT_APP_GEOSERVER_URL=https://geoserver.core-stack.org:8443/geoserver/ # No GeoLibre variables are required for the official rolling deployment. # Its tested fallback version and URL live in src/config/geolibre.config.js. # Uncomment these only to override that source configuration at build time: -# REACT_APP_GEOLIBRE_VERSION=2.2.0 +# REACT_APP_GEOLIBRE_VERSION=2.6.0 # REACT_APP_GEOLIBRE_URL=https://web.geolibre.app/ # REACT_APP_GEOLIBRE_STRICT_VERSION=false # Optional MapLibre style URL. The default uses the same Google Satellite diff --git a/src/App.test.jsx b/src/App.test.jsx new file mode 100644 index 00000000..b15f3b5a --- /dev/null +++ b/src/App.test.jsx @@ -0,0 +1,26 @@ +import fs from "fs"; +import path from "path"; + +describe("application routes", () => { + it("keeps /download_layers wired to the GeoLibre implementation", () => { + const appSource = fs.readFileSync(path.join(__dirname, "App.jsx"), "utf8"); + const downloadPageSource = fs.readFileSync( + path.join(__dirname, "pages/LandscapeExplorer.jsx"), + "utf8" + ); + + expect(appSource).toContain( + '} />' + ); + expect(downloadPageSource).toContain( + 'import GeoLibreFrame from "../components/geolibre/GeoLibreFrame";' + ); + expect(downloadPageSource).toContain(" ), ].join("\n"); +// Visibility and opacity are live viewer state. They must not cause a full +// project replacement, because GeoLibre would tear down and recreate native +// raster sources that are already resident in the map. Keep only fields that +// describe project structure, data, or styling in the load signature. +export const geoLibreProjectLoadSignature = (project) => + project + ? JSON.stringify({ + version: project.version, + name: project.name, + scope: project.metadata?.scope, + bbox: project.mapView?.bbox, + layers: (project.layers || []).map((layer) => ({ + id: layer.id, + name: layer.name, + type: layer.type, + source: layer.source, + sourcePath: layer.sourcePath, + groupId: layer.groupId, + style: layer.style, + loadState: layer.metadata?.loadState, + featureCount: + layer.metadata?.featureCount ?? layer.geojson?.features?.length, + })), + }) + : ""; + const GeoLibreFrame = ({ project, preparationMessage, preparationError, warning, + legends, onRetry, onProjectState, }) => { const frameRef = useRef(null); const fitTimerRef = useRef(null); const fittedScopeRef = useRef(""); - const sentProjectRef = useRef(null); + const sentProjectSignatureRef = useRef(""); const sequenceRef = useRef(0); const technicalLogRef = useRef([]); const [viewerState, setViewerState] = useState("loading"); @@ -138,7 +166,7 @@ const GeoLibreFrame = ({ actualVersion: event.data.version, }); setViewerVersion(String(event.data.version)); - sentProjectRef.current = null; + sentProjectSignatureRef.current = ""; setViewerIssue(""); setViewerState("ready"); setReadyGeneration((generation) => generation + 1); @@ -181,11 +209,12 @@ const GeoLibreFrame = ({ useEffect(() => { const target = frameRef.current?.contentWindow; + const projectSignature = geoLibreProjectLoadSignature(project); if ( !target || !project || !["ready", "loaded"].includes(viewerState) || - sentProjectRef.current === project + sentProjectSignatureRef.current === projectSignature ) { return; } @@ -205,7 +234,7 @@ const GeoLibreFrame = ({ projectName: project.name, layerCount: project.layers?.length || 0, }); - sentProjectRef.current = project; + sentProjectSignatureRef.current = projectSignature; const bounds = project.mapView?.bbox; const scope = project.metadata?.scope; const scopeKey = scope @@ -283,9 +312,13 @@ const GeoLibreFrame = ({ /> )} + {!userIssue && viewerState === "loaded" && ( + + )} + {!userIssue && viewerVersion && (
diff --git a/src/components/geolibre/GeoLibreFrame.test.jsx b/src/components/geolibre/GeoLibreFrame.test.jsx index de057008..8354caf1 100644 --- a/src/components/geolibre/GeoLibreFrame.test.jsx +++ b/src/components/geolibre/GeoLibreFrame.test.jsx @@ -17,7 +17,7 @@ const project = { layers: [], }; -const announceReady = (frame, version = "2.2.0") => { +const announceReady = (frame, version = "2.6.0") => { window.dispatchEvent( new MessageEvent("message", { origin: "https://web.geolibre.app", @@ -31,14 +31,14 @@ describe("GeoLibre iframe bridge", () => { beforeEach(() => jest.useFakeTimers()); afterEach(() => jest.useRealTimers()); - it("loads the project and fits its bbox after a compatible v2.1 handshake", () => { + it("loads the project and fits its bbox after a compatible v2.6 handshake", () => { render(); const frame = screen.getByTitle("GeoLibre GIS workspace"); const postMessage = jest.spyOn(frame.contentWindow, "postMessage"); - act(() => announceReady(frame, "2.1.0")); + act(() => announceReady(frame, "2.6.0")); - expect(screen.getByText(/GeoLibre 2\.1\.0 · rolling host/i)).toBeTruthy(); + expect(screen.getByText(/GeoLibre 2\.6\.0 · rolling host/i)).toBeTruthy(); expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ @@ -100,13 +100,13 @@ describe("GeoLibre iframe bridge", () => { { timestamp: "2026-07-22T00:00:00.000Z", event: "iframe_handshake_timeout", - details: { expectedVersion: "2.2.0" }, + details: { expectedVersion: "2.6.0" }, }, ]); expect(output).toContain("KYL GeoLibre technical log"); expect(output).toContain("iframe_handshake_timeout"); - expect(output).toContain('"expectedVersion":"2.2.0"'); + expect(output).toContain('"expectedVersion":"2.6.0"'); }); it("reloads a lazily hydrated project without fitting the tehsil again", () => { @@ -139,6 +139,50 @@ describe("GeoLibre iframe bridge", () => { ).toHaveLength(1); }); + it("keeps an already-loaded raster source when only visibility changes", () => { + const rasterProject = { + ...project, + layers: [ + { + id: "corestack-lulc_level_2_17_18", + name: "LULC Level 2 · 2017-2018", + type: "raster", + source: { + type: "raster", + tiles: ["https://geoserver.example/wms?year=17_18&style=level_2"], + }, + visible: true, + opacity: 1, + style: { rasterBrightnessMin: 0, rasterBrightnessMax: 1 }, + }, + ], + }; + const { rerender } = render(); + const frame = screen.getByTitle("GeoLibre GIS workspace"); + const postMessage = jest.spyOn(frame.contentWindow, "postMessage"); + + act(() => announceReady(frame)); + const loadCount = () => + postMessage.mock.calls.filter(([message]) => + message.type === "geolibre:load-project" + ).length; + expect(loadCount()).toBe(1); + + rerender( + ({ + ...layer, + visible: false, + })), + }} + /> + ); + + expect(loadCount()).toBe(1); + }); + it("forwards viewer state snapshots for toggle-triggered loading", () => { const onProjectState = jest.fn(); render( diff --git a/src/components/geolibre/GeoLibreLegend.jsx b/src/components/geolibre/GeoLibreLegend.jsx new file mode 100644 index 00000000..77924642 --- /dev/null +++ b/src/components/geolibre/GeoLibreLegend.jsx @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState } from "react"; + +const GeoLibreLegend = ({ legends = [] }) => { + const [collapsed, setCollapsed] = useState(false); + const [selectedTitle, setSelectedTitle] = useState(""); + const previousTitlesRef = useRef([]); + + useEffect(() => { + const titles = legends.map((legend) => legend.title); + const addedTitle = titles.find( + (title) => !previousTitlesRef.current.includes(title) + ); + if (addedTitle) setCollapsed(false); + setSelectedTitle((current) => + addedTitle || (titles.includes(current) ? current : titles[0] || "") + ); + previousTitlesRef.current = titles; + }, [legends]); + + if (!legends.length) return null; + + const selected = + legends.find((legend) => legend.title === selectedTitle) || legends[0]; + + return ( + + ); +}; + +export default GeoLibreLegend; diff --git a/src/components/geolibre/GeoLibreLegend.test.jsx b/src/components/geolibre/GeoLibreLegend.test.jsx new file mode 100644 index 00000000..42059fce --- /dev/null +++ b/src/components/geolibre/GeoLibreLegend.test.jsx @@ -0,0 +1,38 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import GeoLibreLegend from "./GeoLibreLegend"; + +const level1 = { + title: "LULC Level 1 legend", + items: [{ label: "Built-up", color: "#ff0000", shape: "square" }], +}; + +const level2 = { + title: "LULC Level 2 legend", + items: [{ label: "Crops", color: "#fad36f", shape: "square" }], +}; + +describe("GeoLibre legend", () => { + it("selects the legend for a newly visible LULC style", () => { + const { rerender } = render(); + + expect( + screen.getByRole("button", { name: "Legend" }).getAttribute("aria-expanded") + ).toBe("true"); + expect(screen.getByText("Built-up")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Legend" })); + expect( + screen.getByRole("button", { name: "Legend" }).getAttribute("aria-expanded") + ).toBe("false"); + + rerender(); + + expect( + screen.getByRole("button", { name: "Legend" }).getAttribute("aria-expanded") + ).toBe("true"); + expect( + screen.getByRole("combobox", { name: "Visible layer legend" }).value + ).toBe("LULC Level 2 legend"); + expect(screen.getByText("Crops")).toBeTruthy(); + }); +}); diff --git a/src/components/geolibre/README.md b/src/components/geolibre/README.md index 927f0807..6b80489e 100644 --- a/src/components/geolibre/README.md +++ b/src/components/geolibre/README.md @@ -1,15 +1,15 @@ # KYL GeoLibre integration `/download_layers` is a thin host for GeoLibre. KYL keeps its existing header -(including **GeoLibre User Guide**, **QGIS Documentation**, and the QML style -repository fallback) and gives the rest of the page to a trusted GeoLibre -iframe. There is no second KYL map, layer selector, or project panel. +(including **GeoLibre User Guide** and **QGIS Documentation**) and gives the +rest of the page to a trusted GeoLibre iframe. There is no second KYL map, +layer selector, or project panel. CoRE Stack datasets are available under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The implementation targets -[GeoLibre v2.2.0](https://github.com/opengeos/GeoLibre/releases/tag/v2.2.0) +[GeoLibre v2.6.0](https://github.com/opengeos/GeoLibre/releases/tag/v2.6.0) and uses its supported embed bridge and WFS project representation. ## Runtime flow @@ -69,11 +69,13 @@ sequenceDiagram 2. **Extent:** the shared panchayat-boundary WFS response supplies both default Demographic layers and the authoritative tehsil bbox. 3. **Catalog:** `geolibreLayers.js` is the single layer inventory. It assigns - the deployed KYL domain, GeoServer source, QML reference, year, and order. -4. **Cartography:** vector QML logic is represented in GeoLibre styles; raster - QML is rendered by the corresponding named GeoServer WMS style. Matching - color labels are synchronized into GeoLibre's native legend only while the - corresponding layer is visible. + the deployed KYL domain, GeoServer source, year, and order. +4. **Cartography:** named raster styles are rendered directly by GeoServer WMS. + Every layer exposes live GeoServer SLD and JSON/PNG legend endpoints. The + finalized vector profiles remain in the project as a visual-parity safeguard + because GeoLibre project JSON cannot attach a remote SLD to a predeclared + WFS layer. Matching color labels are synchronized into GeoLibre's native + legend only while the corresponding layer is visible. 5. **Loading:** only the shared default WFS is fetched at startup. Other vectors hydrate once on first toggle; rasters remain native lazy WMS layers. 6. **Download:** vector data remains available through GeoLibre and complete @@ -87,19 +89,25 @@ GeoLibre's own layer panel follows the deployed Download Layers taxonomy, ordered top-first as: 1. Demographic (Administrative Boundaries, Socio-Economic Profile) -2. Hydrology (including micro-watersheds and hydrological variables) -3. LULC Level 3 by year -4. LULC Level 2 by year -5. LULC Level 1 by year -6. Land -7. Agriculture -8. Restoration -9. Climate -10. NREGA +2. Village Data (facilities access, Mission Antyodaya and livestock) +3. Hydrology (including micro-watersheds, rivers, canals and hydrological variables) +4. LULC Level 3 by year +5. LULC Level 2 by year +6. LULC Level 1 by year +7. Land (including terrain and the Digital Elevation Model) +8. Agriculture +9. Restoration +10. Industry +11. NREGA The remaining groups are collapsed. Every layer outside the two default Demographic entries is toggle-to-load. Each LULC group shows 2024-2025 first -while retaining every available year back to 2017-2018. +while retaining every available year back to 2017-2018. The three LULC levels +are presentation choices over the same Level 3 coverage for each year: GeoLibre +uses `lulc_level_1_style`, `lulc_level_2_style`, or `lulc_level_3_style` without +requesting separate Level 1 and Level 2 raster datasets. These cross-workspace +styles are rendered through GeoServer's global WMS endpoint; downloads continue +to use the single Level 3 WCS coverage. The project camera is calculated from the Socio-Economic geometry using a padded Web Mercator fit. `mapView.bbox` is also retained in project metadata, @@ -117,25 +125,26 @@ attribution. A deployment can replace it with another valid MapLibre style: REACT_APP_GEOLIBRE_BASEMAP_STYLE_URL=https://maps.example.org/style.json ``` -GeoLibre's built-in Components plugin provides one on-map legend control in -minimized mode by default. Its rendered legend uses the bottom-right map corner, -and its selector contains only layers that are currently visible. Toggling a -layer on adds its symbol classes; toggling it off removes them. One selected -layer's classes are shown at a time, so enabling several layers does not expand -every palette across the map. The separate `legend` project field retains the -complete layer ordering and grouping for GeoLibre's Print Layout legend. +KYL renders one minimized on-map legend control over the bottom-right map corner. +Its selector is updated directly from GeoLibre state snapshots and contains the +currently visible layers. A newly enabled layer becomes the selected legend, so +Level 1, Level 2, and Level 3 LULC styles each immediately show their own class +palette. This update does not send the full project back to the iframe, preserving +GeoLibre's native raster sources and avoiding redundant tile reloads. The +separate `legend` project field retains the complete layer ordering and grouping +for GeoLibre's Print Layout legend. ## Version configuration -The default hosted viewer accepts any GeoLibre release from `2.0.0` up to, but +The default hosted viewer accepts any GeoLibre release from `2.6.0` up to, but not including, `3.0.0`. Compatible 2.x hosted upgrades need no KYL code change. The one source-code fallback to update is the version value in `../../config/geolibre.config.js`: ```js export const GEOLIBRE_CONFIG = Object.freeze({ - version: process.env.REACT_APP_GEOLIBRE_VERSION || "2.2.0", - minimumCompatibleVersion: "2.0.0", + version: process.env.REACT_APP_GEOLIBRE_VERSION || "2.6.0", + minimumCompatibleVersion: "2.6.0", supportedMajorVersion: 2, // ... }); @@ -148,7 +157,7 @@ versioned URL template. It cannot select the release served by the unversioned For an exactly pinned self-hosted release, set: ```dotenv -REACT_APP_GEOLIBRE_VERSION=2.3.0 +REACT_APP_GEOLIBRE_VERSION=2.6.0 REACT_APP_GEOLIBRE_URL_TEMPLATE=https://maps.example.org/geolibre/{version}/ REACT_APP_GEOLIBRE_STRICT_VERSION=true ``` @@ -161,7 +170,7 @@ the compatibility rules and project/bridge tests, not just the version value. The small badge over the iframe reports the version that actually completed the GeoLibre handshake and whether its deployment URL is `rolling` or `pinned`. -GeoLibre's application version (`2.2.0`) is separate from its project schema +GeoLibre's application version (`2.6.0`) is separate from its project schema version (`0.2.0`). Do not change the project format merely when upgrading the application. @@ -170,16 +179,16 @@ application. | File | Responsibility | |---|---| | `../../config/geolibre.config.js` | Viewer application version, URL resolution, strict handshake compatibility | -| `../../config/geolibreLayers.js` | GeoServer names, deployed domains, all LULC years, QML references and WMS styles | -| `geolibreProject.js` | Project generation, legends, Google imagery, vector hydration, WMS/WCS references and bbox camera | +| `../../config/geolibreLayers.js` | GeoServer names, deployed domains, all LULC years and named WMS styles | +| `geolibreProject.js` | Project generation, legends, Google imagery, vector hydration, GeoServer style/WFS/WMS/WCS references and bbox camera | | `GeoLibreFrame.jsx` | Iframe bridge, one-time bbox fit, human error states and downloadable bounded technical log | | `../../pages/LandscapeExplorer.jsx` | Route-to-project orchestration and fetch-on-first-toggle vector cache; no duplicate map or layer UI | -The current project contains 45 entries: 13 vector entries, 24 LULC year/level -rasters, and 8 other rasters. Initial startup performs exactly one distinct WFS -request for the shared Demographic data and no WMS request. Each other vector -makes its own WFS request only on its first toggle. Hidden rasters make -no WMS tile request. +The current project contains 55 entries: 22 vector entries, 24 LULC year/style +entries backed by 8 Level 3 yearly rasters, and 9 other rasters. Initial startup +performs exactly one distinct WFS request for the shared Demographic data and no +WMS request. Each other vector makes its own WFS request only on its first +toggle. Hidden rasters make no WMS tile request. ## Error handling @@ -193,13 +202,18 @@ a log file to the user's filesystem. ## Styling contract -- Vector QML rules are translated into GeoLibre categorized or expression - styles. The source QML URL remains in `metadata.corestack.qmlStyleUrl`. -- To use these layers with QGIS, download layer styles from the - [CoRE Stack QGIS Styles repository](https://github.com/core-stack-org/QGIS-Styles) - and load them through QGIS layer properties. -- Raster QML styles are published as named GeoServer styles and rendered by - WMS. Their original QML URLs are also retained. +- Style delivery no longer depends on GitHub-hosted QML files. Each layer's + `metadata.corestack.geoserverStyle` contains public GeoServer `GetStyles` and + `GetLegendGraphic` URLs for SLD, JSON legend, and PNG legend access. +- Named raster styles are applied by GeoServer in every WMS tile request, so + the rendered pixels and published server style remain one contract. +- WFS returns geometry and attributes, not cartography. GeoLibre 2.6 can import + an SLD interactively, but its project format cannot associate a remote SLD + URL with an already declared WFS layer. The finalized GeoLibre vector styles + and legends therefore remain embedded as a tested parity fallback instead of + adopting GeoServer's generic `polygon`, `line`, `point`, or `generic` defaults. +- When a finalized vector style is assigned on GeoServer, validate its public + SLD and JSON legend against the parity profile before making it authoritative. - Each raster keeps its styled WMS tiles for display and exposes its complete WCS GetCoverage GeoTIFF as `source.url`. This is the contract GeoLibre 2.1+ uses to show **Export → GeoTIFF (COG)** and save the returned bytes without @@ -211,9 +225,10 @@ backing file. If immutable original COG objects are published later, place those direct object URLs in the raster catalogue and use them instead of the WCS fallback. -Changing only a QML URL does not alter rendered vector symbology; update the -matching style profile in `geolibreProject.js`. Raster appearance changes must -be published to the named GeoServer WMS style. +Changing a GeoServer vector default does not automatically alter rendered +GeoLibre symbology; update and test the matching parity profile until GeoLibre +supports remote SLD URLs in saved project layers. Raster appearance changes +must be published to the named GeoServer WMS style. ## Fresh-checkout setup @@ -276,7 +291,8 @@ Check both routes: 6. Confirm the map does not refit after those vector loads. Enable a raster and verify its styled WMS display and **Export → GeoTIFF (COG)** full-coverage download. -7. Open both documentation buttons, the QML repository, and the CC BY 4.0 link. +7. Open both documentation buttons and the CC BY 4.0 link. Inspect a generated + layer's metadata and confirm its style URLs use the configured GeoServer. 8. If testing a failure state, confirm it uses human recovery guidance and that **Download technical log** saves a `.log` file. @@ -302,11 +318,12 @@ the user's browser with CORS enabled, and the site's framing policy must permit ## Future integration options -GeoLibre 2.2 leaves room for deeper work without another KYL map implementation: +GeoLibre 2.6 leaves room for deeper work without another KYL map implementation: - use direct object-store COG URLs for immutable original-file downloads; - preconfigure processing models, bookmarks, print layouts, stories, or plugins; - expose saved/shareable GeoLibre project files for partner workflows; -- add direct QML import once GeoLibre's web project/style contract supports it; +- replace vector parity profiles with live SLD URLs once GeoLibre's project + format supports remote styles on predeclared WFS layers; - self-host tested versioned builds so a single version change selects the exact deployed application binary. diff --git a/src/components/geolibre/geolibreProject.js b/src/components/geolibre/geolibreProject.js index 28205e5d..8c024f5d 100644 --- a/src/components/geolibre/geolibreProject.js +++ b/src/components/geolibre/geolibreProject.js @@ -161,6 +161,37 @@ const STYLE_PROFILES = { ], { fillColor: "#98fb98", strokeColor: "#111827", fillOpacity: 0.65 } ), + facilities: expressionStyle( + [ + "step", + numericProperty("l2_essential_education_distance_km"), + "#fff9c4", + 2, + "#ffc107", + ], + { fillColor: "#fff9c4", strokeColor: "#232323", fillOpacity: 0.8 } + ), + antyodaya: categoryStyle( + "road_connectivity_cat_cluster", + [ + ["LOW", "#dc143c", "Poor road connectivity"], + ["MEDIUM", "#ffd700", "Moderate road connectivity"], + ["HIGH", "#90ee90", "Strong road connectivity"], + ], + { fillColor: "#ffd700", strokeColor: "#232323", fillOpacity: 0.8 } + ), + livestock: expressionStyle( + [ + "step", + numericProperty("small_animals_total"), + "#dc143c", + 201, + "#ffd700", + 501, + "#90ee90", + ], + { fillColor: "#ffd700", strokeColor: "#232323", fillOpacity: 0.8 } + ), terrain_vector: categoryStyle( "terrainClu", [ @@ -199,6 +230,20 @@ const STYLE_PROFILES = { ], { fillColor: "#03045e", strokeColor: "#03045e", strokeWidth: 2 } ), + river: { + ...BASE_STYLE, + fillColor: "#2b93fa", + strokeColor: "#2b93fa", + strokeWidth: 2, + fillOpacity: 0.8, + }, + canal: { + ...BASE_STYLE, + fillColor: "#2b93fa", + strokeColor: "#2b93fa", + strokeWidth: 2, + fillOpacity: 0.8, + }, waterbodies: { ...BASE_STYLE, fillColor: "#6495ed", @@ -282,6 +327,20 @@ const STYLE_PROFILES = { circleRadius: 6, } ), + green_credit: { + ...BASE_STYLE, + fillColor: "#14d11d", + strokeColor: "#14d11d", + fillOpacity: 0.6, + }, + industry_point: { + ...BASE_STYLE, + fillColor: "#ff0000", + strokeColor: "#ffffff", + strokeWidth: 1, + fillOpacity: 1, + circleRadius: 10, + }, }; const LEGEND_PROFILES = { @@ -292,6 +351,20 @@ const LEGEND_PROFILES = { ["Literacy 59% to below 70%", "#228b22"], ["Literacy 70% or above", "#006400"], ], + facilities: [ + ["Primary education within 2 km", "#fff9c4"], + ["Primary education more than 2 km away", "#ffc107"], + ], + antyodaya: [ + ["Poor road connectivity", "#dc143c"], + ["Moderate road connectivity", "#ffd700"], + ["Strong road connectivity", "#90ee90"], + ], + livestock: [ + ["Bovine population 0 to 200", "#dc143c"], + ["Bovine population 201 to 500", "#ffd700"], + ["Bovine population above 500", "#90ee90"], + ], mws: [ ["Net groundwater change below -5", "#ff0000"], ["Net groundwater change -5 to below -1", "#ffff00"], @@ -299,6 +372,8 @@ const LEGEND_PROFILES = { ["Net groundwater change 1 or above", "#1017f8"], ], waterbodies: [["Surface waterbody", "#6495ed"]], + river: [["River", "#2b93fa", "line"]], + canal: [["Canal", "#2b93fa", "line"]], cropping_intensity: [ ["Average cropping intensity below 1", "#ff9371"], ["Average cropping intensity 1 to below 2", "#ffa500"], @@ -322,6 +397,27 @@ const LEGEND_PROFILES = { ["Midslope divides or local ridges", "#800000"], ["Mountain tops or high ridges", "#4d0000"], ], + dem: [ + ["0 m", "#0d0030"], + ["50 m", "#1a0f6e"], + ["100 m", "#1746a0"], + ["150 m", "#1a72c0"], + ["200 m", "#2191c0"], + ["250 m", "#1aab9e"], + ["300 m", "#16a085"], + ["340 m", "#1cb870"], + ["380 m", "#27ae60"], + ["410 m", "#5ab836"], + ["440 m", "#95c623"], + ["470 m", "#d4d400"], + ["500 m", "#f1c40f"], + ["530 m", "#e09a30"], + ["560 m", "#d4845a"], + ["590 m", "#b0623a"], + ["620 m", "#8b5e3c"], + ["660 m", "#c4a882"], + ["700 m or above", "#f5f0e8"], + ], clart: [ ["Good recharge", "#4ee323"], ["Moderate recharge", "#f3ff33"], @@ -371,6 +467,10 @@ const LEGEND_PROFILES = { ["Wide-scale restoration", "#0f077c"], ["Protection", "#4fbc14"], ], + green_credit: [["Green Credit project area", "#14d11d"]], + land_conflicts: [["Reported land conflict", "#ff0000", "circle"]], + industry: [["Industry or CSR site", "#ff0000", "circle"]], + mining: [["Mining site", "#ff0000", "circle"]], lulc_level_1: [ ["Built-up", "#ff0000"], ["Water", "#1ca3ec"], @@ -427,6 +527,7 @@ const layerLegend = (catalogLayer, style) => { const GROUPS_TOP_FIRST = [ { id: "demographic", name: "Demographic", collapsed: false }, + { id: "village-data", name: "Village Data", collapsed: true }, { id: "hydrology", name: "Hydrology", collapsed: true }, { id: "lulc-3", name: "LULC · Level 3 by year", collapsed: true }, { id: "lulc-2", name: "LULC · Level 2 by year", collapsed: true }, @@ -434,7 +535,7 @@ const GROUPS_TOP_FIRST = [ { id: "land", name: "Land", collapsed: true }, { id: "agriculture", name: "Agriculture", collapsed: true }, { id: "restoration", name: "Restoration", collapsed: true }, - { id: "climate", name: "Climate", collapsed: true }, + { id: "industry", name: "Industry", collapsed: true }, { id: "nrega", name: "NREGA", collapsed: true }, ]; @@ -500,8 +601,57 @@ const buildWfsRequest = (baseUrl, layer, layerName) => { }; }; +const wmsEndpointFor = (baseUrl, layer) => + layer.useGlobalWms + ? `${baseUrl}wms` + : `${baseUrl}${layer.workspace}/wms`; + +const buildGeoServerStyleSource = (baseUrl, layer, layerName) => { + const endpoint = wmsEndpointFor(baseUrl, layer); + const qualifiedName = `${layer.workspace}:${layerName}`; + const namedStyle = layer.wmsStyle || ""; + const getStylesEntry = namedStyle ? [["STYLES", namedStyle]] : []; + const legendStyleEntry = namedStyle ? [["STYLE", namedStyle]] : []; + const common = [ + ["SERVICE", "WMS"], + ["VERSION", "1.1.1"], + ]; + + return { + provider: "GeoServer", + name: namedStyle || null, + assignment: namedStyle ? "named-style" : "layer-default", + renderingMode: + layer.sourceType === "wms" + ? "server-rendered-wms" + : "geolibre-parity-profile", + sldUrl: appendQuery(endpoint, [ + ...common, + ["REQUEST", "GetStyles"], + ["LAYERS", qualifiedName], + ...getStylesEntry, + ]), + legendJsonUrl: appendQuery(endpoint, [ + ...common, + ["REQUEST", "GetLegendGraphic"], + ["FORMAT", "application/json"], + ["LAYER", qualifiedName], + ...legendStyleEntry, + ]), + legendImageUrl: appendQuery(endpoint, [ + ...common, + ["REQUEST", "GetLegendGraphic"], + ["FORMAT", "image/png"], + ["LAYER", qualifiedName], + ...legendStyleEntry, + ]), + }; +}; + const buildWmsSource = (baseUrl, layer, layerName, bounds) => { - const endpoint = `${baseUrl}${layer.workspace}/wms`; + // Cross-workspace LULC styles are available through GeoServer's global WMS, + // while other catalog layers retain their workspace-scoped endpoints. + const endpoint = wmsEndpointFor(baseUrl, layer); const qualifiedName = `${layer.workspace}:${layerName}`; const source = { type: "raster", @@ -653,19 +803,19 @@ const layerStyle = (layer) => ? { ...RASTER_STYLE } : { ...(STYLE_PROFILES[layer.styleProfile] || BASE_STYLE) }; -const coreStackMetadata = (layer, layerName, sourceUrl, style) => ({ +const coreStackMetadata = (layer, layerName, sourceUrl, style, baseUrl) => ({ domain: layer.domain, geoserverWorkspace: layer.workspace, geoserverLayer: layerName, sourceType: layer.sourceType, liveSource: sourceUrl, - qmlStyleUrl: layer.qmlStyleUrl, + geoserverStyle: buildGeoServerStyleSource(baseUrl, layer, layerName), year: layer.year || null, legend: layerLegend(layer, style), styleContract: layer.sourceType === "wms" - ? "GeoServer renders the named style published from the CoRE Stack QGIS style catalog." - : "The QGIS QML symbology is represented as a GeoLibre vector style.", + ? "GeoServer renders the published named style through WMS." + : "GeoLibre retains the finalized vector profile while the live GeoServer SLD and legend endpoints provide the server style contract.", }); const buildVectorLayer = ({ @@ -675,6 +825,7 @@ const buildVectorLayer = ({ data = EMPTY_FEATURE_COLLECTION, failure, loaded = false, + baseUrl, }) => { const style = layerStyle(catalogLayer); const isDefaultDisplay = catalogLayer.defaultVisible === true; @@ -703,7 +854,13 @@ const buildVectorLayer = ({ loadState, ...(failure ? { initialLoadError: failure.message } : {}), corestack: { - ...coreStackMetadata(catalogLayer, layerName, request.url, style), + ...coreStackMetadata( + catalogLayer, + layerName, + request.url, + style, + baseUrl + ), loadState, }, }, @@ -736,7 +893,13 @@ const buildRasterLayer = ({ catalogLayer, layerName, baseUrl, bounds }) => { metadata: { service: "wms", corestack: { - ...coreStackMetadata(catalogLayer, layerName, wmsSource.url, style), + ...coreStackMetadata( + catalogLayer, + layerName, + wmsSource.url, + style, + baseUrl + ), wcsDownloadUrl, rasterDownload: { kind: "full-coverage-geotiff", @@ -778,6 +941,11 @@ const mapLegendEntries = (orderedLayers) => { : entries; }; +export const activeGeoLibreLegends = (project) => + project?.layers + ? mapLegendEntries(project.layers.filter((layer) => layer.visible)) + : []; + const legendPluginState = (entries, currentPlugins) => { const selected = entries[0]; const currentComponents = @@ -791,7 +959,9 @@ const legendPluginState = (entries, currentPlugins) => { const legend = selectedEntry ? { ...currentLegend, - visible: true, + // KYL renders this state outside the cross-origin iframe so it can be + // updated without replacing the project and recreating raster sources. + visible: false, collapsed: currentLegend?.collapsed ?? true, hasLegend: true, selectedLegendIndex: selectedIndex, @@ -849,9 +1019,7 @@ const legendStateSignature = (legend) => export const syncGeoLibreActiveLegends = (project) => { if (!project?.layers) return project; - const entries = mapLegendEntries( - project.layers.filter((layer) => layer.visible) - ); + const entries = activeGeoLibreLegends(project); const plugins = legendPluginState(entries, project.plugins); const currentLegend = project.plugins?.settings?.["maplibre-gl-components"]?.legend; @@ -1077,6 +1245,7 @@ export const buildGeoLibreProject = async ({ return buildVectorLayer({ catalogLayer, layerName, + baseUrl, ...(result || { data: EMPTY_FEATURE_COLLECTION, request: buildWfsRequest(baseUrl, catalogLayer, layerName), @@ -1141,8 +1310,8 @@ export const buildGeoLibreProject = async ({ initialLoadFailures: [...failures], lazyLoadFailures: [], }, - qmlStyleContract: - "Vector QML symbology is represented in GeoLibre styles. Raster QML symbology is rendered by named GeoServer WMS styles. Original QML URLs are retained per layer.", + geoserverStyleContract: + "Raster symbology is rendered by named GeoServer WMS styles. Vector layers retain the verified GeoLibre parity profiles and expose live GeoServer GetStyles and GetLegendGraphic endpoints without depending on GitHub-hosted QML files.", }, }; }; diff --git a/src/components/geolibre/geolibreProject.test.js b/src/components/geolibre/geolibreProject.test.js index 708d076d..ec8560a8 100644 --- a/src/components/geolibre/geolibreProject.test.js +++ b/src/components/geolibre/geolibreProject.test.js @@ -1,4 +1,5 @@ import { + activeGeoLibreLegends, buildGeoLibreProject, DEFAULT_GEOLIBRE_BASEMAP_STYLE, formatGeoServerName, @@ -47,7 +48,7 @@ beforeEach(() => { ); }); -describe("GeoLibre 2.2 project generation", () => { +describe("GeoLibre 2.6 project generation", () => { it("normalizes KYL location labels for GeoServer layer names", () => { expect(formatGeoServerName(" Banas Kantha (Palanpur) ")).toBe( "banas_kantha_palanpur" @@ -75,7 +76,7 @@ describe("GeoLibre 2.2 project generation", () => { expect(project.version).toBe("0.2.0"); expect(project.layers).toHaveLength(GEOLIBRE_LAYERS.length); - expect(project.layers).toHaveLength(45); + expect(project.layers).toHaveLength(55); expect(project.mapView.bbox).toEqual([92.9, 24.7, 93.2, 25]); expect(project.basemapStyleUrl).toBe(DEFAULT_GEOLIBRE_BASEMAP_STYLE); expect(decodeURIComponent(project.basemapStyleUrl)).toContain( @@ -104,6 +105,13 @@ describe("GeoLibre 2.2 project generation", () => { service: "wfs", featureCount: 1, loadState: "loaded", + corestack: { + geoserverStyle: { + provider: "GeoServer", + assignment: "layer-default", + renderingMode: "geolibre-parity-profile", + }, + }, }, }); expect(socioeconomic.geojson.type).toBe("FeatureCollection"); @@ -149,6 +157,11 @@ describe("GeoLibre 2.2 project generation", () => { metadata: { service: "wms", corestack: { + geoserverStyle: { + name: "lulc_level_3_style", + assignment: "named-style", + renderingMode: "server-rendered-wms", + }, rasterDownload: { kind: "full-coverage-geotiff", bytePreservingInGeoLibre: true, @@ -162,11 +175,64 @@ describe("GeoLibre 2.2 project generation", () => { expect(latestLulc.source.tiles[0]).toContain( "BBOX={bbox-epsg-3857}" ); - expect(latestLulc.source.wmsUrl).toContain("/LULC_level_3/wms"); + expect(latestLulc.source.wmsUrl).toContain("/geoserver/wms"); + expect(latestLulc.metadata.corestack.geoserverStyle.sldUrl).toContain( + "REQUEST=GetStyles" + ); + expect( + latestLulc.metadata.corestack.geoserverStyle.legendJsonUrl + ).toContain("FORMAT=application%2Fjson"); expect(latestLulc.source.url).toContain("request=GetCoverage"); expect(latestLulc.source.url).toContain( "CoverageId=LULC_level_3%3ALULC_24_25_cachar_lakhipur_level_3" ); + const latestLulcStyles = [1, 2, 3].map((level) => + project.layers.find( + (layer) => layer.id === `corestack-lulc_level_${level}_24_25` + ) + ); + expect(latestLulcStyles.map((layer) => layer.source.layers)).toEqual([ + "LULC_level_3:LULC_24_25_cachar_lakhipur_level_3", + "LULC_level_3:LULC_24_25_cachar_lakhipur_level_3", + "LULC_level_3:LULC_24_25_cachar_lakhipur_level_3", + ]); + expect(latestLulcStyles.map((layer) => layer.source.url)).toEqual([ + latestLulc.source.url, + latestLulc.source.url, + latestLulc.source.url, + ]); + expect(latestLulcStyles.map((layer) => layer.source.styles)).toEqual([ + "lulc_level_1_style", + "lulc_level_2_style", + "lulc_level_3_style", + ]); + + const dem = project.layers.find((layer) => layer.id === "corestack-dem"); + expect(dem).toMatchObject({ + type: "raster", + visible: false, + source: { + layers: "dem:cachar_lakhipur_dem_raster", + styles: "dem_grayscale", + }, + metadata: { + corestack: { + geoserverWorkspace: "dem", + rasterDownload: { kind: "full-coverage-geotiff" }, + }, + }, + }); + expect(dem.source.url).toContain( + "CoverageId=dem%3Acachar_lakhipur_dem_raster" + ); + expect( + project.layers.every( + (layer) => + !JSON.stringify(layer.metadata?.corestack || {}).includes( + "githubusercontent.com" + ) + ) + ).toBe(true); expect(successfulFetch).toHaveBeenCalledTimes(1); }); @@ -179,9 +245,12 @@ describe("GeoLibre 2.2 project generation", () => { .reverse() .map((layer) => layer.id); - expect(displayIds.slice(0, 5)).toEqual([ + expect(displayIds.slice(0, 8)).toEqual([ "corestack-administrative_boundaries", "corestack-demographics", + "corestack-facilities", + "corestack-antyodaya", + "corestack-livestock", "corestack-mws_layers", "corestack-hydrological_boundaries", "corestack-mws_layers_fortnight", @@ -205,6 +274,7 @@ describe("GeoLibre 2.2 project generation", () => { ); expect(project.layerGroups.map((group) => group.id)).toEqual([ "demographic", + "village-data", "hydrology", "lulc-3", "lulc-2", @@ -212,12 +282,49 @@ describe("GeoLibre 2.2 project generation", () => { "land", "agriculture", "restoration", - "climate", + "industry", "nrega", ]); }); - it("starts a minimized legend containing only active default layers", async () => { + it("uses the deployed KYL names for hydrology, restoration, and industry sources", async () => { + const project = await buildGeoLibreProject({ + ...location, + fetchFeatureCollection: successfulFetch, + }); + const typeNames = Object.fromEntries( + project.layers + .filter((layer) => layer.type === "geojson") + .map((layer) => [layer.id, layer.source.typeName]) + ); + + expect(typeNames).toMatchObject({ + "corestack-facilities": + "facilities_proximity:facilities_cachar_lakhipur", + "corestack-antyodaya": + "antyodaya_2020:antyodaya20_cachar_lakhipur", + "corestack-livestock": "livestocks:livestocks_cachar_lakhipur", + "corestack-river": "river:cachar_lakhipur_river_vector", + "corestack-canal": "canal:cachar_lakhipur_canal_vector", + "corestack-green_credit": "green_credit:cachar_lakhipur_green_credit", + "corestack-land_conflicts": "lcw:cachar_lakhipur_lcw_conflict", + "corestack-industry": "factory_csr:cachar_lakhipur_factory_csr", + "corestack-mining": "mining:cachar_lakhipur_mining", + }); + + expect(project.styles["corestack-facilities"].vectorStyleExpression).toContain( + "l2_essential_education_distance_km" + ); + expect(project.styles["corestack-antyodaya"]).toMatchObject({ + vectorStyleMode: "categorized", + vectorStyleProperty: "road_connectivity_cat_cluster", + }); + expect(project.styles["corestack-livestock"].vectorStyleExpression).toContain( + "small_animals_total" + ); + }); + + it("prepares default legend data for the KYL overlay", async () => { const project = await buildGeoLibreProject({ ...location, fetchFeatureCollection: successfulFetch, @@ -229,7 +336,7 @@ describe("GeoLibre 2.2 project generation", () => { "maplibre-gl-components" ); expect(legend).toMatchObject({ - visible: true, + visible: false, collapsed: true, hasLegend: true, title: "Socio-Economic Profile legend", @@ -290,6 +397,43 @@ describe("GeoLibre 2.2 project generation", () => { ]); }); + it("returns a separate active legend for every visible LULC style", async () => { + const project = await buildGeoLibreProject({ + ...location, + fetchFeatureCollection: successfulFetch, + }); + const withLulcStyles = { + ...project, + layers: project.layers.map((layer) => + [ + "corestack-lulc_level_1_17_18", + "corestack-lulc_level_2_17_18", + "corestack-lulc_level_3_17_18", + ].includes(layer.id) + ? { ...layer, visible: true } + : layer + ), + }; + + const legends = activeGeoLibreLegends(withLulcStyles); + expect(legends.map((legend) => legend.title)).toEqual( + expect.arrayContaining([ + "LULC Level 1 legend", + "LULC Level 2 legend", + "LULC Level 3 legend", + ]) + ); + expect( + legends.find((legend) => legend.title === "LULC Level 1 legend").items + ).toHaveLength(5); + expect( + legends.find((legend) => legend.title === "LULC Level 2 legend").items + ).toHaveLength(2); + expect( + legends.find((legend) => legend.title === "LULC Level 3 legend").items + ).toHaveLength(4); + }); + it("loads only the shared Demographic source during project creation", async () => { const project = await buildGeoLibreProject({ ...location, diff --git a/src/config/geolibre.config.js b/src/config/geolibre.config.js index 2ffeba52..1c4d2585 100644 --- a/src/config/geolibre.config.js +++ b/src/config/geolibre.config.js @@ -8,8 +8,8 @@ const DEFAULT_VIEWER_URL = "https://web.geolibre.app/"; * uses this value only for {version} URL templates and project metadata. */ export const GEOLIBRE_CONFIG = Object.freeze({ - version: process.env.REACT_APP_GEOLIBRE_VERSION || "2.2.0", - minimumCompatibleVersion: "2.0.0", + version: process.env.REACT_APP_GEOLIBRE_VERSION || "2.6.0", + minimumCompatibleVersion: "2.6.0", supportedMajorVersion: 2, viewerUrlTemplate: process.env.REACT_APP_GEOLIBRE_URL_TEMPLATE || diff --git a/src/config/geolibre.config.test.js b/src/config/geolibre.config.test.js index e0667c6d..9b8dcaa9 100644 --- a/src/config/geolibre.config.test.js +++ b/src/config/geolibre.config.test.js @@ -5,8 +5,8 @@ import { } from "./geolibre.config"; const config = { - version: "2.2.0", - minimumCompatibleVersion: "2.0.0", + version: "2.6.0", + minimumCompatibleVersion: "2.6.0", supportedMajorVersion: 2, viewerUrlTemplate: "https://viewer.example/geolibre/{version}/", strictVersion: true, @@ -15,7 +15,7 @@ const config = { describe("GeoLibre application configuration", () => { it("resolves a versioned viewer URL and embed parameters", () => { expect(resolveGeoLibreViewer(config)).toEqual({ - url: "https://viewer.example/geolibre/2.2.0/?embed=1&welcome=0", + url: "https://viewer.example/geolibre/2.6.0/?embed=1&welcome=0", origin: "https://viewer.example", versionPinned: true, }); @@ -25,31 +25,28 @@ describe("GeoLibre application configuration", () => { expect(resolveGeoLibreViewer(GEOLIBRE_CONFIG).versionPinned).toBe(false); }); - it("accepts the configured v2.2 viewer", () => { - expect(geoLibreVersionStatus("2.2.0", config)).toEqual({ + it("accepts the configured v2.6 viewer", () => { + expect(geoLibreVersionStatus("2.6.0", config)).toEqual({ compatible: true, message: "", }); }); it("accepts supported hosted GeoLibre 2.x releases by default", () => { - expect(geoLibreVersionStatus("2.1.0", GEOLIBRE_CONFIG).compatible).toBe( - true - ); - expect(geoLibreVersionStatus("2.2.0", GEOLIBRE_CONFIG).compatible).toBe( + expect(geoLibreVersionStatus("2.6.0", GEOLIBRE_CONFIG).compatible).toBe( true ); }); it("rejects unexpected, older, and major-version viewers", () => { - expect(geoLibreVersionStatus("2.3.0", config).compatible).toBe(false); + expect(geoLibreVersionStatus("2.5.0", config).compatible).toBe(false); expect(geoLibreVersionStatus("1.9.9", config).compatible).toBe(false); expect(geoLibreVersionStatus("3.0.0", config).compatible).toBe(false); }); it("can allow a compatible newer 2.x viewer for an explicit test deployment", () => { expect( - geoLibreVersionStatus("2.4.0", { ...config, strictVersion: false }) + geoLibreVersionStatus("2.7.0", { ...config, strictVersion: false }) .compatible ).toBe(true); }); diff --git a/src/config/geolibreLayers.js b/src/config/geolibreLayers.js index 6d9ed637..8031b191 100644 --- a/src/config/geolibreLayers.js +++ b/src/config/geolibreLayers.js @@ -1,7 +1,4 @@ -const QML_RAW_BASE = - "https://raw.githubusercontent.com/core-stack-org/QGIS-Styles/main"; - -const qmlStyle = (path) => `${QML_RAW_BASE}/${path}`; +const LULC_SOURCE_WORKSPACE = "LULC_level_3"; export const GEOLIBRE_LULC_YEARS = [ { label: "2017-2018", value: "17_18" }, @@ -29,7 +26,6 @@ const LAYERS = [ geometryType: "polygon", layerName: ({ district, tehsil }) => `${district}_${tehsil}`, styleProfile: "boundary", - qmlStyleUrl: qmlStyle("Demographic/Administrative-Boundary-Style.qml"), }, { id: "demographics", @@ -42,7 +38,40 @@ const LAYERS = [ geometryType: "polygon", layerName: ({ district, tehsil }) => `${district}_${tehsil}`, styleProfile: "demographics", - qmlStyleUrl: qmlStyle("Demographic/literary_rate_style.qml"), + }, + { + id: "facilities", + label: "Facilities and Services Access", + domain: "Village", + loadGroup: "village-data", + sourceType: "wfs", + workspace: "facilities_proximity", + geometryType: "polygon", + layerName: ({ district, tehsil }) => `facilities_${district}_${tehsil}`, + styleProfile: "facilities", + }, + { + id: "antyodaya", + label: "Mission Antyodaya Village Indicators (2020)", + domain: "Village", + loadGroup: "village-data", + sourceType: "wfs", + workspace: "antyodaya_2020", + geometryType: "polygon", + layerName: ({ district, tehsil }) => + `antyodaya20_${district}_${tehsil}`, + styleProfile: "antyodaya", + }, + { + id: "livestock", + label: "Village Livestock Census", + domain: "Village", + loadGroup: "village-data", + sourceType: "wfs", + workspace: "livestocks", + geometryType: "polygon", + layerName: ({ district, tehsil }) => `livestocks_${district}_${tehsil}`, + styleProfile: "livestock", }, { id: "mws_layers", @@ -55,7 +84,6 @@ const LAYERS = [ layerName: ({ district, tehsil }) => `deltaG_well_depth_${district}_${tehsil}`, styleProfile: "mws", - qmlStyleUrl: qmlStyle("Climate/MWS-Well-Depth-18_23.qml"), }, { id: "hydrological_boundaries", @@ -68,7 +96,6 @@ const LAYERS = [ layerName: ({ district, tehsil }) => `deltaG_well_depth_${district}_${tehsil}`, styleProfile: "boundary", - qmlStyleUrl: qmlStyle("Climate/MWS-Well-Depth-18_23.qml"), }, { id: "mws_layers_fortnight", @@ -81,7 +108,6 @@ const LAYERS = [ layerName: ({ district, tehsil }) => `deltaG_fortnight_${district}_${tehsil}`, styleProfile: "boundary", - qmlStyleUrl: qmlStyle("Hydrology/water_balance_fortnightly.qml"), }, { id: "terrain_vector", @@ -93,7 +119,6 @@ const LAYERS = [ geometryType: "polygon", layerName: ({ district, tehsil }) => `${district}_${tehsil}_cluster`, styleProfile: "terrain_vector", - qmlStyleUrl: qmlStyle("Land/Terrain-Vector-Layer-Style.qml"), }, { id: "drainage", @@ -105,7 +130,30 @@ const LAYERS = [ geometryType: "line", layerName: ({ district, tehsil }) => `${district}_${tehsil}`, styleProfile: "drainage", - qmlStyleUrl: qmlStyle("Hydrology/Drainage-Layer-Style.qml"), + }, + { + id: "river", + label: "Rivers", + domain: "Hydrology", + loadGroup: "hydrology", + sourceType: "wfs", + workspace: "river", + geometryType: "line", + layerName: ({ district, tehsil }) => + `${district}_${tehsil}_river_vector`, + styleProfile: "river", + }, + { + id: "canal", + label: "Canals", + domain: "Hydrology", + loadGroup: "hydrology", + sourceType: "wfs", + workspace: "canal", + geometryType: "line", + layerName: ({ district, tehsil }) => + `${district}_${tehsil}_canal_vector`, + styleProfile: "canal", }, { id: "remote_sensed_waterbodies", @@ -118,7 +166,6 @@ const LAYERS = [ layerName: ({ district, tehsil }) => `surface_waterbodies_${district}_${tehsil}`, styleProfile: "waterbodies", - qmlStyleUrl: qmlStyle("Hydrology/Surface-Waterbody-style.qml"), }, { id: "soge", @@ -130,7 +177,6 @@ const LAYERS = [ geometryType: "polygon", layerName: ({ district, tehsil }) => `soge_vector_${district}_${tehsil}`, styleProfile: "soge", - qmlStyleUrl: qmlStyle("Hydrology/SOGE_style.qml"), }, { id: "aquifer", @@ -143,7 +189,6 @@ const LAYERS = [ layerName: ({ district, tehsil }) => `aquifer_vector_${district}_${tehsil}`, styleProfile: "aquifer", - qmlStyleUrl: qmlStyle("Hydrology/Aquifer_style.qml"), }, { id: "cropping_intensity", @@ -155,7 +200,6 @@ const LAYERS = [ geometryType: "polygon", layerName: ({ district, tehsil }) => `${district}_${tehsil}_intensity`, styleProfile: "cropping_intensity", - qmlStyleUrl: qmlStyle("Agriculture/Cropping_intensity.qml"), }, { id: "drought", @@ -167,7 +211,6 @@ const LAYERS = [ geometryType: "polygon", layerName: ({ district, tehsil }) => `${district}_${tehsil}_drought`, styleProfile: "drought", - qmlStyleUrl: qmlStyle("Agriculture/Drought_style.qml"), }, { id: "nrega", @@ -179,7 +222,53 @@ const LAYERS = [ geometryType: "point", layerName: ({ district, tehsil }) => `${district}_${tehsil}`, styleProfile: "nrega", - qmlStyleUrl: qmlStyle("NREGA/NREG-Assets-Classified-Style.qml"), + }, + { + id: "green_credit", + label: "Green Credit Projects", + domain: "Restoration", + loadGroup: "restoration", + sourceType: "wfs", + workspace: "green_credit", + geometryType: "polygon", + layerName: ({ district, tehsil }) => + `${district}_${tehsil}_green_credit`, + styleProfile: "green_credit", + }, + { + id: "land_conflicts", + label: "Land Conflicts", + domain: "Industry", + loadGroup: "industry", + sourceType: "wfs", + workspace: "lcw", + geometryType: "point", + layerName: ({ district, tehsil }) => + `${district}_${tehsil}_lcw_conflict`, + styleProfile: "industry_point", + }, + { + id: "industry", + label: "Industries and CSR", + domain: "Industry", + loadGroup: "industry", + sourceType: "wfs", + workspace: "factory_csr", + geometryType: "point", + layerName: ({ district, tehsil }) => + `${district}_${tehsil}_factory_csr`, + styleProfile: "industry_point", + }, + { + id: "mining", + label: "Mining Sites", + domain: "Industry", + loadGroup: "industry", + sourceType: "wfs", + workspace: "mining", + geometryType: "point", + layerName: ({ district, tehsil }) => `${district}_${tehsil}_mining`, + styleProfile: "industry_point", }, { id: "terrain", @@ -190,7 +279,16 @@ const LAYERS = [ workspace: "terrain", layerName: ({ district, tehsil }) => `${district}_${tehsil}_terrain_raster`, wmsStyle: "terrain:terrain_raster", - qmlStyleUrl: qmlStyle("Land/terrain_1-12class.qml"), + }, + { + id: "dem", + label: "Digital Elevation Model", + domain: "Land", + loadGroup: "land", + sourceType: "wms", + workspace: "dem", + layerName: ({ district, tehsil }) => `${district}_${tehsil}_dem_raster`, + wmsStyle: "dem_grayscale", }, { id: "clart", @@ -201,7 +299,6 @@ const LAYERS = [ workspace: "clart", layerName: ({ district, tehsil }) => `${district}_${tehsil}_clart`, wmsStyle: "clart:testClart", - qmlStyleUrl: qmlStyle("Hydrology/CLART-Layer-Style.qml"), }, { id: "afforestation", @@ -213,7 +310,6 @@ const LAYERS = [ layerName: ({ district, tehsil }) => `change_${district}_${tehsil}_Afforestation`, wmsStyle: "change_detection:afforestation", - qmlStyleUrl: qmlStyle("Land/change_tree_cover_gain.qml"), }, { id: "deforestation", @@ -225,7 +321,6 @@ const LAYERS = [ layerName: ({ district, tehsil }) => `change_${district}_${tehsil}_Deforestation`, wmsStyle: "change_detection:deforestation", - qmlStyleUrl: qmlStyle("Land/change_tree_cover_loss.qml"), }, { id: "degradation", @@ -237,7 +332,6 @@ const LAYERS = [ layerName: ({ district, tehsil }) => `change_${district}_${tehsil}_Degradation`, wmsStyle: "change_detection:degradation", - qmlStyleUrl: qmlStyle("Land/change_cropping_reduction.qml"), }, { id: "urbanization", @@ -249,7 +343,6 @@ const LAYERS = [ layerName: ({ district, tehsil }) => `change_${district}_${tehsil}_Urbanization`, wmsStyle: "change_detection:urbanization", - qmlStyleUrl: qmlStyle("Land/change_urbanization.qml"), }, { id: "cropintensity", @@ -261,7 +354,6 @@ const LAYERS = [ layerName: ({ district, tehsil }) => `change_${district}_${tehsil}_CropIntensity`, wmsStyle: "change_detection:cropintensity", - qmlStyleUrl: qmlStyle("Land/change_cropping_intensity.qml"), }, { id: "restoration", @@ -273,7 +365,6 @@ const LAYERS = [ layerName: ({ district, tehsil }) => `restoration_${district}_${tehsil}_raster`, wmsStyle: "restoration:restoration_style", - qmlStyleUrl: qmlStyle("Restoration/Restoration_style.qml"), }, ]; @@ -282,25 +373,22 @@ const LULC_LEVELS = [ id: "lulc_level_1", label: "LULC Level 1", domain: "Land", - workspace: "LULC_level_1", - wmsStyle: "LULC_level_1:lulc_level_1_style", - qmlStyleUrl: qmlStyle("Land/level-1-op.qml"), + workspace: LULC_SOURCE_WORKSPACE, + wmsStyle: "lulc_level_1_style", }, { id: "lulc_level_2", label: "LULC Level 2", domain: "Land", - workspace: "LULC_level_2", - wmsStyle: "LULC_level_2:lulc_level_2_style", - qmlStyleUrl: qmlStyle("Land/level-2.qml"), + workspace: LULC_SOURCE_WORKSPACE, + wmsStyle: "lulc_level_2_style", }, { id: "lulc_level_3", label: "LULC Level 3", domain: "Agriculture", - workspace: "LULC_level_3", - wmsStyle: "LULC_level_3:lulc_level_3_style", - qmlStyleUrl: qmlStyle("Agriculture/level-3.qml"), + workspace: LULC_SOURCE_WORKSPACE, + wmsStyle: "lulc_level_3_style", }, ]; @@ -312,9 +400,10 @@ export const GEOLIBRE_LULC_LAYERS = LULC_LEVELS.flatMap((level, index) => label: `${level.label} · ${year.label}`, loadGroup: `lulc-${index + 1}`, sourceType: "wms", + useGlobalWms: true, year: year.value, layerName: ({ district, tehsil }) => - `LULC_${year.value}_${district}_${tehsil}_level_${index + 1}`, + `LULC_${year.value}_${district}_${tehsil}_level_3`, })) ); diff --git a/src/pages/LandscapeExplorer.jsx b/src/pages/LandscapeExplorer.jsx index 46756a12..e324e577 100644 --- a/src/pages/LandscapeExplorer.jsx +++ b/src/pages/LandscapeExplorer.jsx @@ -1,457 +1,237 @@ -import { useState, useEffect, useRef, useCallback } from "react"; -import Map from "../components/landscape-explorer/map/Map.jsx"; -import RightSidebar from "../components/landscape-explorer/sidebar/RightSidebar.jsx"; -import { useRecoilState } from "recoil"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Link, useLocation } from "react-router-dom"; +import { useRecoilValue } from "recoil"; +import GeoLibreFrame from "../components/geolibre/GeoLibreFrame"; +import { + activeGeoLibreLegends, + buildGeoLibreProject, + hydrateGeoLibreVectorLayer, + syncGeoLibreActiveLegends, +} from "../components/geolibre/geolibreProject"; +import LandingNavbar from "../components/landing_navbar"; import { - stateDataAtom, - stateAtom, - districtAtom, blockAtom, - filterSelectionsAtom, - yearAtom, -} from "../store/locationStore.jsx"; -import getStates from "../actions/getStates.js"; -import * as downloadHelper from "../components/landscape-explorer/utils/downloadHelper"; + districtAtom, + stateAtom, +} from "../store/locationStore"; import { - trackPageView, - trackEvent, initializeAnalytics, + trackEvent, + trackPageView, } from "../services/analytics"; -import LandingNavbar from "../components/landing_navbar.jsx"; -const LandscapeExplorer = () => { - const [showLeftSidebar, setShowLeftSidebar] = useState(false); - const [showRightSidebar, setShowRightSidebar] = useState(true); - const [isLoading, setIsLoading] = useState(false); +const labelOf = (selection) => selection?.label || ""; - // Recoil state - const [statesData, setStatesData] = useRecoilState(stateDataAtom); - const [state, setState] = useRecoilState(stateAtom); - const [district, setDistrict] = useRecoilState(districtAtom); - const [block, setBlock] = useRecoilState(blockAtom); - const [filterSelections, setFilterSelections] = - useRecoilState(filterSelectionsAtom); - const [lulcYear1, setLulcYear1] = useState(null); - const [lulcYear2, setLulcYear2] = useState(null); - const [lulcYear3, setLulcYear3] = useState(null); +const scopeKeyOf = (project) => { + const scope = project?.metadata?.scope; + return scope ? [scope.state, scope.district, scope.tehsil].join("|") : ""; +}; - // Map ref for accessing map instance from other components - const mapRef = useRef(null); +const mergeHydratedVectorLayers = (viewerProject, hydratedLayers) => ({ + ...viewerProject, + layers: viewerProject.layers.map((layer) => { + const hydrated = hydratedLayers.get(layer.id); + if (!hydrated) return layer; + return { + ...layer, + geojson: hydrated.geojson, + metadata: { + ...layer.metadata, + ...hydrated.metadata, + corestack: { + ...layer.metadata?.corestack, + ...hydrated.metadata?.corestack, + }, + }, + }; + }), +}); - // Add flag to prevent infinite recursion - const isUpdatingFromMap = useRef(false); +const LandscapeExplorer = () => { + const selectedState = useRecoilValue(stateAtom); + const selectedDistrict = useRecoilValue(districtAtom); + const selectedTehsil = useRecoilValue(blockAtom); + const routeLocation = useLocation(); + const [project, setProject] = useState(null); + const [legends, setLegends] = useState([]); + const [progress, setProgress] = useState("Starting GeoLibre…"); + const [error, setError] = useState(""); + const [retryKey, setRetryKey] = useState(0); + const currentScopeKeyRef = useRef(""); + const lazyQueueRef = useRef(Promise.resolve()); + const lazyStateSequenceRef = useRef(0); + const hydratedLayersRef = useRef(new Map()); + const hydrationDirtyRef = useRef(false); + + const scope = useMemo(() => { + const params = new URLSearchParams(routeLocation.search); + return { + state: params.get("state") || labelOf(selectedState), + district: params.get("district") || labelOf(selectedDistrict), + tehsil: params.get("tehsil") || labelOf(selectedTehsil), + }; + }, [ + routeLocation.search, + selectedDistrict, + selectedState, + selectedTehsil, + ]); + + const hasLocation = Boolean(scope.state && scope.district && scope.tehsil); + const scopeKey = [scope.state, scope.district, scope.tehsil].join("|"); - // Track which resource category is active - const [activeResourceCategory, setActiveResourceCategory] = useState(null); + useEffect(() => { + currentScopeKeyRef.current = scopeKey; + lazyStateSequenceRef.current += 1; + lazyQueueRef.current = Promise.resolve(); + hydratedLayersRef.current = new Map(); + hydrationDirtyRef.current = false; + setLegends([]); + }, [scopeKey]); - // Set map ref with callback - const setMapRef = useCallback((node) => { - if (node !== null) { - mapRef.current = node; - } + useEffect(() => { + initializeAnalytics(); + trackPageView("/download_layers"); }, []); - // Layer toggle state - with demographics on by default - const [toggledLayers, setToggledLayers] = useState({ - // Basic layers - demographics: true, // Set to true by default - drainage: false, - remote_sensed_waterbodies: false, - hydrological_boundaries: false, - clart: false, - mws_layers: false, - nrega: false, - drought: false, - terrain: false, - administrative_boundaries: false, - cropping_intensity: false, - terrain_vector: false, - terrain_lulc_slope: false, - terrain_lulc_plain: false, - afforestation: false, - deforestation: false, - degradation: false, - urbanization: false, - cropintensity: false, - soge: false, - aquifer: false, - }); - - // State for map view settings - const [showMWS, setShowMWS] = useState(true); - const [showVillages, setShowVillages] = useState(true); - - // Add plans state - const [plans, setPlans] = useState([]); - - // Add internal state flag for when layers are ready - const [layersReady, setLayersReady] = useState(false); - - // Flag to track if we need to enable the fetch button - const [canFetchLayers, setCanFetchLayers] = useState(block !== null); - - // Handle item selection for dropdowns - const handleItemSelect = (setter, value) => { - // Handle the setState case specially if it affects parent component state - if (setter === setState) { - // Reset all dependent state values - if (value) { - trackEvent("Location", "select_state", value.label); - } - setDistrict(null); - setBlock(null); - resetAllStates(); - setState(value); - } else if (setter === setDistrict) { - // Reset block and filters when district changes - if (value) { - trackEvent("Location", "select_district", value.label); - } - setBlock(null); - resetAllStates(); - setDistrict(value); - } else if (setter === setBlock) { - resetAllStates(); - setBlock(value); - // When block is selected, enable fetch button and prepare layers automatically - setCanFetchLayers(true); - trackEvent("Location", "select_tehsil", value.label); - // Auto-prepare layers instead of requiring Fetch Layers button - setTimeout(() => { - if (mapRef.current && mapRef.current.prepareLayers) { - setIsLoading(true); - mapRef.current.prepareLayers(); - setLayersReady(true); - setToggledLayers((prev) => ({ - ...prev, - demographics: true, - })); - setIsLoading(false); - } - }, 100); - } else { - // Standard case for other setters - setter(value); - } - }; - - const resetAllStates = () => { - // Reset filters - setFilterSelections({ - selectedMWSValues: {}, - selectedVillageValues: {}, - }); - - setToggledLayers({ - demographics: true, // Keep demographics on - drainage: false, - remote_sensed_waterbodies: false, - hydrological_boundaries: false, - clart: false, - mws_layers: false, - nrega: false, - drought: false, - terrain: false, - administrative_boundaries: false, - cropping_intensity: false, - terrain_vector: false, - terrain_lulc_slope: false, - terrain_lulc_plain: false, - settlement: false, - water_structure: false, - well_structure: false, - agri_structure: false, - livelihood_structure: false, - recharge_structure: false, - afforestation: false, - deforestation: false, - degradation: false, - urbanization: false, - cropintensity: false, - soge: false, - aquifer: false, - }); - - setLayersReady(false); - setCanFetchLayers(false); - }; - - // Handle layer toggle from RightSidebar - const handleLayerToggle = (layerName, isVisible) => { - // Prevent recursion if the update is coming from the map component - if (isUpdatingFromMap.current) { - return; - } - - // Update local state immediately - setToggledLayers((prev) => ({ - ...prev, - [layerName]: isVisible, - })); - - // Then update the map with a slight delay - setTimeout(() => { - if (mapRef.current && mapRef.current.toggleLayer) { - mapRef.current.toggleLayer(layerName, isVisible); - } - }, 50); - }; - - // Handle GeoJSON download - const handleGeoJsonLayers = (layerName) => { - if (!district || !block) { - alert("Please select a district and block first"); - return; - } - - console.log(`Downloading GeoJSON for ${layerName}`); - - const districtFormatted = district.label - .toLowerCase() - .replace(/\s*\(\s*/g, "_") - .replace(/\s*\)\s*/g, "") - .replace(/\s+/g, "_"); - const blockFormatted = block.label - .toLowerCase() - .replace(/\s*\(\s*/g, "_") - .replace(/\s*\)\s*/g, "") - .replace(/\s+/g, "_"); - - // Create download URL based on layer name (following the original implementation's URL format) - let downloadUrl = ""; - - switch (layerName) { - case "demographics": - downloadUrl = `https://geoserver.core-stack.org:8443/geoserver/panchayat_boundaries/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=panchayat_boundaries:${districtFormatted}_${blockFormatted}&outputFormat=application/json&screen=main`; - break; - case "drainage": - downloadUrl = `https://geoserver.core-stack.org:8443/geoserver/drainage/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=drainage:${districtFormatted}_${blockFormatted}&outputFormat=application/json&screen=main`; - break; - case "remote_sensed_waterbodies": - downloadUrl = `https://geoserver.core-stack.org:8443/geoserver/swb/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=swb:surface_waterbodies_${districtFormatted}_${blockFormatted}&outputFormat=application/json&screen=main`; - break; - case "hydrological_boundaries": - downloadUrl = `https://geoserver.core-stack.org:8443/geoserver/mws_layers/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=mws_layers:deltaG_well_depth_${districtFormatted}_${blockFormatted}&outputFormat=application/json&screen=main`; - break; - // Add other cases as needed - default: - downloadUrl = `https://geoserver.core-stack.org:8443/geoserver/${layerName}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${layerName}:${districtFormatted}_${blockFormatted}&outputFormat=application/json&screen=main`; - } - - // Use the imported helper directly - downloadHelper.downloadGeoJson(downloadUrl, layerName); - }; - - // Handle KML download - const handleKMLLayers = (layerName) => { - if (!district || !block) { - alert("Please select a district and block first"); - return; - } - - console.log(`Downloading KML for ${layerName}`); - - const districtFormatted = district.label - .toLowerCase() - .replace(/\s*\(\s*/g, "_") - .replace(/\s*\)\s*/g, "") - .replace(/\s+/g, "_"); - const blockFormatted = block.label - .toLowerCase() - .replace(/\s*\(\s*/g, "_") - .replace(/\s*\)\s*/g, "") - .replace(/\s+/g, "_"); - - // Create download URL based on layer name (following original implementation) - let downloadUrl = ""; - - switch (layerName) { - case "demographics": - downloadUrl = `https://geoserver.core-stack.org:8443/geoserver/panchayat_boundaries/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=panchayat_boundaries:${districtFormatted}_${blockFormatted}&outputFormat=application/vnd.google-earth.kml+xml&screen=main`; - break; - case "drainage": - downloadUrl = `https://geoserver.core-stack.org:8443/geoserver/drainage/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=drainage:${districtFormatted}_${blockFormatted}&outputFormat=application/vnd.google-earth.kml+xml&screen=main`; - break; - case "remote_sensed_waterbodies": - downloadUrl = `https://geoserver.core-stack.org:8443/geoserver/water_bodies/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=water_bodies:surface_waterbodies_${districtFormatted}_${blockFormatted}&outputFormat=application/vnd.google-earth.kml+xml&screen=main`; - break; - case "hydrological_boundaries": - downloadUrl = `https://geoserver.core-stack.org:8443/geoserver/mws_layers/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=mws_layers:deltaG_well_depth_${districtFormatted}_${blockFormatted}&outputFormat=application/vnd.google-earth.kml+xml&screen=main`; - break; - // Add other cases as needed - default: - downloadUrl = `https://geoserver.core-stack.org:8443/geoserver/${layerName}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${layerName}:${districtFormatted}_${blockFormatted}&outputFormat=application/vnd.google-earth.kml+xml&screen=main`; - } - - // Use the imported helper directly - downloadHelper.downloadKml(downloadUrl, layerName); - }; - - // Handle Excel download - const handleExcelDownload = () => { - if (!district || !block) { - alert("Please select a district and block first"); - return; - } - - setIsLoading(true); - - // Using the exact URL format from the original implementation - fetch( - `https://geoserver.core-stack.org/api/v1/download_excel_layer?state=${state.label}&district=${district.label}&block=${block.label}`, - { - method: "GET", - headers: { - "ngrok-skip-browser-warning": "1", - "Content-Type": "blob", - }, - } - ) - .then((response) => response.arrayBuffer()) - .then((arybuf) => { - const url = window.URL.createObjectURL(new Blob([arybuf])); - const link = document.createElement("a"); - - link.href = url; - link.setAttribute("download", `${block.label}_data.xlsx`); - document.body.appendChild(link); - link.click(); - - link.remove(); - URL.revokeObjectURL(url); - setIsLoading(false); + useEffect(() => { + if (!hasLocation) return undefined; + const controller = new AbortController(); + setProject(null); + setError(""); + setProgress(`Loading the Socio-Economic Profile for ${scope.tehsil}…`); + + buildGeoLibreProject({ + ...scope, + signal: controller.signal, + viewport: { + width: Math.max(window.innerWidth - 340, 320), + height: Math.max(window.innerHeight - 100, 320), + }, + onProgress: ({ message }) => { + if (!controller.signal.aborted) setProgress(message); + }, + }) + .then((nextProject) => { + if (controller.signal.aborted) return; + setProject(nextProject); + setLegends(activeGeoLibreLegends(nextProject)); + setProgress("Overview is ready. Toggle another layer to load it."); + trackEvent("GeoLibre", "open_workspace", scope.tehsil); }) - .catch((error) => { - console.error("Error downloading Excel:", error); - setIsLoading(false); - alert("Failed to download Excel data. Please try again."); + .catch((buildError) => { + if (controller.signal.aborted) return; + setError( + buildError instanceof Error + ? buildError.message + : "The tehsil project could not be generated." + ); }); - }; - // Track category selection for resource layers - const handleCategoryChange = (category) => { - setActiveResourceCategory(category); - }; + return () => controller.abort(); + }, [hasLocation, retryKey, scope]); - // Fetch states data on component mount - useEffect(() => { - initializeAnalytics(); - trackPageView("/download_layers"); - if (statesData === null) { - getStates().then((data) => setStatesData(data)); + const handleProjectState = useCallback((viewerProject) => { + const viewerScopeKey = scopeKeyOf(viewerProject); + if (viewerScopeKey === currentScopeKeyRef.current) { + setLegends(activeGeoLibreLegends(viewerProject)); } - }, [statesData, setStatesData]); - - // Handle map-initiated layer toggle updates - const handleMapToggle = (layerName, isVisible) => { - // Set the recursion prevention flag - isUpdatingFromMap.current = true; + const sequence = lazyStateSequenceRef.current + 1; + lazyStateSequenceRef.current = sequence; + + lazyQueueRef.current = lazyQueueRef.current + .catch(() => undefined) + .then(async () => { + if (viewerScopeKey !== currentScopeKeyRef.current) return; + + const mergedProject = mergeHydratedVectorLayers( + viewerProject, + hydratedLayersRef.current + ); + let nextProject = mergedProject; + const layersToLoad = nextProject.layers.filter( + (layer) => + layer.type === "geojson" && + layer.visible && + ["unloaded", "error"].includes(layer.metadata?.loadState) + ); + + for (const layer of layersToLoad) { + nextProject = await hydrateGeoLibreVectorLayer({ + project: nextProject, + layerId: layer.id, + }); + const hydrated = nextProject.layers.find( + (item) => item.id === layer.id + ); + if (hydrated) hydratedLayersRef.current.set(layer.id, hydrated); + hydrationDirtyRef.current = true; + } - try { - // Special case for setState action - coming from map marker click - if (layerName === "setState" && typeof isVisible === "object") { - if (isVisible && isVisible.label && isVisible.district) { - setState(isVisible); + if ( + viewerScopeKey !== currentScopeKeyRef.current || + sequence !== lazyStateSequenceRef.current || + (!layersToLoad.length && !hydrationDirtyRef.current) + ) { return; } - } - // Update the toggledLayers state - setToggledLayers((prev) => ({ - ...prev, - [layerName]: isVisible, - })); - } finally { - // Reset the flag - isUpdatingFromMap.current = false; - } - }; + // A visibility-only state already lives inside the GeoLibre iframe. + // Sending it back as a full project would recreate every native raster + // source and make an already-loaded WMS layer fetch its tiles again. + // Only replace the project when lazy vector hydration supplied new data. + nextProject = syncGeoLibreActiveLegends(nextProject); + hydrationDirtyRef.current = false; + setProject(nextProject); + }); + }, []); - return ( -
-
+ if (!hasLocation) { + return ( +
-
-
- - -
- - -
- - {showRightSidebar && ( - setShowRightSidebar(false)} - handleLayerToggle={handleLayerToggle} - handleGeoJsonLayers={handleGeoJsonLayers} - handleKMLLayers={handleKMLLayers} - toggledLayers={toggledLayers} - toggleLayer={handleLayerToggle} - handleExcelDownload={handleExcelDownload} - isLoading={isLoading} - canFetchLayers={canFetchLayers} - onCategoryChange={handleCategoryChange} - lulcYear1={lulcYear1} - lulcYear2={lulcYear2} - lulcYear3={lulcYear3} - setLulcYear1={setLulcYear1} - setLulcYear2={setLulcYear2} - setLulcYear3={setLulcYear3} - /> - )} - - {!showRightSidebar && ( -
- + Select location +
- )} +
+ ); + } + + const failures = + project?.metadata?.layerLoading?.initialLoadFailures?.length || 0; + const lazyFailures = + project?.metadata?.layerLoading?.lazyLoadFailures?.length || 0; + const totalFailures = failures + lazyFailures; + const warning = totalFailures + ? `${totalFailures} layer${totalFailures === 1 ? "" : "s"} could not be loaded. Toggle the layer off and on to retry.` + : ""; + + return ( +
+ + setRetryKey((value) => value + 1)} + />
); };