diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..fd125b6e --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Required by the existing KYL dashboards as well as /download_layers. +REACT_APP_API_URL=https://geoserver.core-stack.org/api/v1 +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_URL=https://web.geolibre.app/ +# REACT_APP_GEOLIBRE_STRICT_VERSION=false +# Optional MapLibre style URL. The default uses the same Google Satellite +# Hybrid tiles as the existing KYL maps. +# REACT_APP_GEOLIBRE_BASEMAP_STYLE_URL=https://maps.example.org/style.json + +# To make the version value select an exact build, deploy GeoLibre at stable +# versioned paths and use this instead of REACT_APP_GEOLIBRE_URL: +# REACT_APP_GEOLIBRE_URL_TEMPLATE=https://geolibre.core-stack.org/{version}/ +# REACT_APP_GEOLIBRE_STRICT_VERSION=true diff --git a/src/components/geolibre/GeoLibreFrame.jsx b/src/components/geolibre/GeoLibreFrame.jsx new file mode 100644 index 00000000..5f97700e --- /dev/null +++ b/src/components/geolibre/GeoLibreFrame.jsx @@ -0,0 +1,357 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + GEOLIBRE_CONFIG, + geoLibreVersionStatus, + resolveGeoLibreViewer, +} from "../../config/geolibre.config"; + +const MAX_TECHNICAL_LOG_ENTRIES = 40; + +const USER_ISSUES = { + preparation: { + title: "We couldn’t prepare this tehsil’s map", + message: + "Please try again. If the problem continues, select the tehsil again or share the technical log with the CoRE Stack team.", + }, + delayed: { + title: "The map is taking longer than expected", + message: + "Check your internet connection and try again. If the problem continues, download the technical log and share it with the CoRE Stack team.", + }, + unavailable: { + title: "The map is temporarily unavailable", + message: + "Please try again in a moment. If the problem continues, download the technical log and share it with the CoRE Stack team.", + }, +}; + +export const formatGeoLibreLog = (entries) => + [ + "KYL GeoLibre technical log", + `Generated: ${new Date().toISOString()}`, + ...entries.map( + ({ timestamp, event, details }) => + `${timestamp} ${event}${details ? ` ${JSON.stringify(details)}` : ""}` + ), + ].join("\n"); + +const GeoLibreFrame = ({ + project, + preparationMessage, + preparationError, + warning, + onRetry, + onProjectState, +}) => { + const frameRef = useRef(null); + const fitTimerRef = useRef(null); + const fittedScopeRef = useRef(""); + const sentProjectRef = useRef(null); + const sequenceRef = useRef(0); + const technicalLogRef = useRef([]); + const [viewerState, setViewerState] = useState("loading"); + const [viewerIssue, setViewerIssue] = useState(""); + const [viewerVersion, setViewerVersion] = useState(""); + const [readyGeneration, setReadyGeneration] = useState(0); + + const viewer = useMemo(() => { + try { + return { ...resolveGeoLibreViewer(), error: "" }; + } catch (error) { + return { + url: "", + origin: "", + error: + error instanceof Error + ? error.message + : "The configured GeoLibre URL is invalid.", + }; + } + }, []); + + const addTechnicalLog = useCallback((event, details) => { + technicalLogRef.current = [ + ...technicalLogRef.current, + { + timestamp: new Date().toISOString(), + event, + ...(details ? { details } : {}), + }, + ].slice(-MAX_TECHNICAL_LOG_ENTRIES); + }, []); + + useEffect(() => { + addTechnicalLog("viewer_configured", { + expectedVersion: GEOLIBRE_CONFIG.version, + viewerUrl: viewer.url || null, + configurationError: viewer.error || null, + }); + }, [addTechnicalLog, viewer.error, viewer.url]); + + useEffect(() => { + if (preparationError) { + addTechnicalLog("project_preparation_failed", { + message: String(preparationError), + }); + } + }, [addTechnicalLog, preparationError]); + + useEffect(() => { + if (!viewer.url) return undefined; + const frame = frameRef.current; + if (!frame) return undefined; + + let handshakeTimer = window.setTimeout(() => { + addTechnicalLog("iframe_handshake_timeout", { + expectedVersion: GEOLIBRE_CONFIG.version, + viewerUrl: viewer.url, + timeoutMs: 90000, + }); + setViewerIssue("delayed"); + setViewerState("error"); + }, 90000); + + const handleMessage = (event) => { + if ( + event.origin !== viewer.origin || + event.source !== frame.contentWindow || + !event.data || + typeof event.data !== "object" + ) { + return; + } + + if (event.data.type === "geolibre:ready") { + window.clearTimeout(handshakeTimer); + handshakeTimer = null; + const status = geoLibreVersionStatus(event.data.version); + if (!status.compatible) { + addTechnicalLog("viewer_version_rejected", { + actualVersion: event.data.version, + reason: status.message, + }); + setViewerIssue("unavailable"); + setViewerState("error"); + return; + } + addTechnicalLog("iframe_ready", { + actualVersion: event.data.version, + }); + setViewerVersion(String(event.data.version)); + sentProjectRef.current = null; + setViewerIssue(""); + setViewerState("ready"); + setReadyGeneration((generation) => generation + 1); + return; + } + + if (event.data.type === "geolibre:error") { + addTechnicalLog("viewer_reported_error", { + message: + event.data.message || "GeoLibre could not load the generated project.", + }); + setViewerIssue("unavailable"); + setViewerState("error"); + return; + } + + if (event.data.type === "geolibre:state" && event.data.project) { + addTechnicalLog("project_state_received", { + layerCount: event.data.project.layers?.length || 0, + }); + onProjectState?.(event.data.project); + } + }; + + window.addEventListener("message", handleMessage); + return () => { + window.removeEventListener("message", handleMessage); + if (handshakeTimer !== null) window.clearTimeout(handshakeTimer); + }; + }, [addTechnicalLog, onProjectState, viewer.origin, viewer.url]); + + useEffect( + () => () => { + if (fitTimerRef.current !== null) { + window.clearTimeout(fitTimerRef.current); + } + }, + [] + ); + + useEffect(() => { + const target = frameRef.current?.contentWindow; + if ( + !target || + !project || + !["ready", "loaded"].includes(viewerState) || + sentProjectRef.current === project + ) { + return; + } + + sequenceRef.current += 1; + const sequence = sequenceRef.current; + target.postMessage( + { + type: "geolibre:load-project", + project, + seq: sequence, + }, + viewer.origin + ); + addTechnicalLog("project_sent", { + sequence, + projectName: project.name, + layerCount: project.layers?.length || 0, + }); + sentProjectRef.current = project; + const bounds = project.mapView?.bbox; + const scope = project.metadata?.scope; + const scopeKey = scope + ? [scope.state, scope.district, scope.tehsil].join("|") + : project.name || "default"; + if ( + Array.isArray(bounds) && + bounds.length === 4 && + fittedScopeRef.current !== scopeKey + ) { + fittedScopeRef.current = scopeKey; + if (fitTimerRef.current !== null) { + window.clearTimeout(fitTimerRef.current); + } + fitTimerRef.current = window.setTimeout(() => { + target.postMessage( + { + type: "geolibre:command", + requestId: `kyl-fit-bounds-${sequence}`, + method: "fitBounds", + params: { bounds }, + }, + viewer.origin + ); + addTechnicalLog("initial_bounds_fit_requested", { bounds }); + fitTimerRef.current = null; + }, 1500); + } + setViewerState("loaded"); + }, [addTechnicalLog, project, readyGeneration, viewer.origin, viewerState]); + + const activeIssue = viewer.error + ? "unavailable" + : preparationError + ? "preparation" + : viewerIssue; + const userIssue = activeIssue ? USER_ISSUES[activeIssue] : null; + const showProgress = !userIssue && (!project || viewerState === "loading"); + + const downloadTechnicalLog = () => { + const blob = new Blob([formatGeoLibreLog(technicalLogRef.current)], { + type: "text/plain;charset=utf-8", + }); + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = objectUrl; + link.download = `kyl-geolibre-${new Date() + .toISOString() + .replace(/[:.]/g, "-")}.log`; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 0); + }; + + return ( + + {viewer.url && ( + { + addTechnicalLog("iframe_loaded", { viewerUrl: viewer.url }); + fittedScopeRef.current = ""; + setViewerVersion(""); + setViewerState((current) => + current === "loaded" ? current : "loading" + ); + }} + /> + )} + + {!userIssue && viewerVersion && ( + + GeoLibre {viewerVersion} · {viewer.versionPinned ? "pinned" : "rolling host"} + + )} + + {(showProgress || userIssue) && ( + + + {userIssue ? ( + <> + + {userIssue.title} + + + {userIssue.message} + + + {onRetry && ( + + Try again + + )} + + Download technical log + + + > + ) : ( + <> + + + Preparing GeoLibre + + + {preparationMessage || + `Starting GeoLibre ${GEOLIBRE_CONFIG.version}…`} + + > + )} + + + )} + + {!userIssue && warning && viewerState === "loaded" && ( + + {warning} + + )} + + ); +}; + +export default GeoLibreFrame; diff --git a/src/components/geolibre/GeoLibreFrame.test.jsx b/src/components/geolibre/GeoLibreFrame.test.jsx new file mode 100644 index 00000000..de057008 --- /dev/null +++ b/src/components/geolibre/GeoLibreFrame.test.jsx @@ -0,0 +1,166 @@ +import { act, render, screen } from "@testing-library/react"; +import GeoLibreFrame, { formatGeoLibreLog } from "./GeoLibreFrame"; + +const project = { + version: "0.2.0", + name: "Lakhipur project", + metadata: { + scope: { state: "Assam", district: "Cachar", tehsil: "Lakhipur" }, + }, + mapView: { + center: [93.04, 24.84], + zoom: 9.6, + bearing: 0, + pitch: 0, + bbox: [92.91, 24.71, 93.17, 24.99], + }, + layers: [], +}; + +const announceReady = (frame, version = "2.2.0") => { + window.dispatchEvent( + new MessageEvent("message", { + origin: "https://web.geolibre.app", + source: frame.contentWindow, + data: { type: "geolibre:ready", version }, + }) + ); +}; + +describe("GeoLibre iframe bridge", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it("loads the project and fits its bbox after a compatible v2.1 handshake", () => { + render(); + const frame = screen.getByTitle("GeoLibre GIS workspace"); + const postMessage = jest.spyOn(frame.contentWindow, "postMessage"); + + act(() => announceReady(frame, "2.1.0")); + + expect(screen.getByText(/GeoLibre 2\.1\.0 · rolling host/i)).toBeTruthy(); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "geolibre:load-project", + project, + seq: 1, + }), + "https://web.geolibre.app" + ); + + act(() => jest.advanceTimersByTime(1500)); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "geolibre:command", + method: "fitBounds", + params: { bounds: project.mapView.bbox }, + }), + "https://web.geolibre.app" + ); + }); + + it("does not send a project to an incompatible major version", () => { + render(); + const frame = screen.getByTitle("GeoLibre GIS workspace"); + const postMessage = jest.spyOn(frame.contentWindow, "postMessage"); + + act(() => announceReady(frame, "3.0.0")); + + expect(screen.getByRole("alert").textContent).toMatch( + /map is temporarily unavailable.*try again in a moment/i + ); + expect(screen.getByRole("alert").textContent).not.toMatch( + /GeoLibre 3\.0\.0|major version|iframe/i + ); + expect( + screen.getByRole("button", { name: /Download technical log/i }) + ).toBeTruthy(); + expect(postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "geolibre:load-project" }), + expect.any(String) + ); + }); + + it("shows helpful language when the iframe handshake is delayed", () => { + render(); + + act(() => jest.advanceTimersByTime(90000)); + + expect(screen.getByRole("alert").textContent).toMatch( + /map is taking longer than expected.*internet connection.*try again/i + ); + expect(screen.getByRole("alert").textContent).not.toMatch( + /iframe handshake|browser console/i + ); + }); + + it("formats bounded troubleshooting entries as a plain-text log", () => { + const output = formatGeoLibreLog([ + { + timestamp: "2026-07-22T00:00:00.000Z", + event: "iframe_handshake_timeout", + details: { expectedVersion: "2.2.0" }, + }, + ]); + + expect(output).toContain("KYL GeoLibre technical log"); + expect(output).toContain("iframe_handshake_timeout"); + expect(output).toContain('"expectedVersion":"2.2.0"'); + }); + + it("reloads a lazily hydrated project without fitting the tehsil again", () => { + const { rerender } = render(); + const frame = screen.getByTitle("GeoLibre GIS workspace"); + const postMessage = jest.spyOn(frame.contentWindow, "postMessage"); + + act(() => announceReady(frame)); + act(() => jest.advanceTimersByTime(1500)); + + const hydratedProject = { + ...project, + layers: [{ id: "corestack-drainage", visible: true }], + }; + rerender(); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "geolibre:load-project", + project: hydratedProject, + seq: 2, + }), + "https://web.geolibre.app" + ); + act(() => jest.advanceTimersByTime(1500)); + expect( + postMessage.mock.calls.filter( + ([message]) => message.type === "geolibre:command" && message.method === "fitBounds" + ) + ).toHaveLength(1); + }); + + it("forwards viewer state snapshots for toggle-triggered loading", () => { + const onProjectState = jest.fn(); + render( + + ); + const frame = screen.getByTitle("GeoLibre GIS workspace"); + act(() => announceReady(frame)); + + const viewerProject = { + ...project, + layers: [{ id: "corestack-drainage", visible: true }], + }; + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + origin: "https://web.geolibre.app", + source: frame.contentWindow, + data: { type: "geolibre:state", project: viewerProject }, + }) + ); + }); + + expect(onProjectState).toHaveBeenCalledWith(viewerProject); + }); +}); diff --git a/src/components/geolibre/README.md b/src/components/geolibre/README.md new file mode 100644 index 00000000..927f0807 --- /dev/null +++ b/src/components/geolibre/README.md @@ -0,0 +1,312 @@ +# 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. + +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) +and uses its supported embed bridge and WFS project representation. + +## Runtime flow + +1. The KYL homepage carries the selected state, district, and tehsil to + `/download_layers` as query parameters. +2. `geolibreProject.js` fetches the shared Demographic WFS source once. It creates + **Administrative Boundaries** and **Socio-Economic Profile** from that data, + derives the complete tehsil bounding box, and immediately opens GeoLibre. +3. Both default Demographic layers start visible at opacity `0.8`, in that + display order. +4. Every vector outside the two default Demographic entries starts listed, + hidden, and empty. Its first visibility toggle asks KYL to fetch that WFS + source and send the hydrated layer back to GeoLibre. The hydrated layer is + retained, so later off/on toggles do not repeat its WFS request. +5. Raster layers also start listed and hidden. GeoLibre requests their styled + WMS tiles only after the first visibility toggle and retains the live raster + source for later toggles; normal browser and MapLibre tile caches reuse tiles + that have already been fetched. +6. The iframe reports `geolibre:ready`; KYL verifies its application version, + sends the initial project, and fits the exact tehsil bounds once. Lazy + project updates preserve the user's live map view and never fit it again. + +```mermaid +flowchart LR + A[KYL location selection] --> B[/download_layers URL] + B --> C[Project builder] + C --> D[Shared tehsil WFS] + D --> C + C --> E[GeoLibre project] + E --> F[Trusted iframe bridge] + F --> G[GeoLibre 2.x] + G --> H[Lazy WFS vectors] + G --> I[WMS display and WCS downloads] +``` + +```mermaid +sequenceDiagram + actor User + participant GeoLibre + participant KYL + participant GeoServer + User->>GeoLibre: Toggle a hidden vector + GeoLibre-->>KYL: geolibre:state + KYL->>GeoServer: WFS GetFeature + GeoServer-->>KYL: GeoJSON FeatureCollection + KYL-->>GeoLibre: Hydrated state and active-layer legends + User->>GeoLibre: Toggle the layer off and on + GeoLibre-->>KYL: geolibre:state + Note over KYL,GeoLibre: Reuse cached layer; no second WFS request or bbox fit +``` + +## Methodology + +1. **Scope:** state, district, and tehsil are read from the route so a project + is reproducible and shareable. +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. +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 + raster coverage is exposed through WCS for **GeoTIFF (COG)** export. +7. **Failure handling:** users receive short recovery guidance. A bounded + technical trace can be downloaded as a `.log` file when support needs it. + +## Native layer organization + +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 + +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. + +The project camera is calculated from the Socio-Economic geometry using a +padded Web Mercator fit. `mapView.bbox` is also retained in project metadata, +and the iframe receives one `fitBounds` command after its initial load, so the +initial map contains the full tehsil rather than a generic India extent. Layer +toggles and lazy hydration do not issue another fit command. + +## Basemap and legends + +The default is the same Google Satellite Hybrid tile source used by the +existing KYL maps. It is wrapped in an inline MapLibre style with Google +attribution. A deployment can replace it with another valid MapLibre style: + +```dotenv +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. + +## Version configuration + +The default hosted viewer accepts any GeoLibre release from `2.0.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", + supportedMajorVersion: 2, + // ... +}); +``` + +`version` records the preferred/tested release and fills `{version}` in a +versioned URL template. It cannot select the release served by the unversioned +`https://web.geolibre.app/` deployment. + +For an exactly pinned self-hosted release, set: + +```dotenv +REACT_APP_GEOLIBRE_VERSION=2.3.0 +REACT_APP_GEOLIBRE_URL_TEMPLATE=https://maps.example.org/geolibre/{version}/ +REACT_APP_GEOLIBRE_STRICT_VERSION=true +``` + +The hosted URL follows GeoLibre's current web deployment and may also be served +from an existing browser cache. KYL checks its reported version, accepts the +compatible 2.x range, and rejects other major versions. `{version}` is replaced +automatically for versioned deployments. A major-version update should update +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 +version (`0.2.0`). Do not change the project format merely when upgrading the +application. + +## Files + +| 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 | +| `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. + +## Error handling + +The page never presents iframe handshakes, version parsing, viewer URLs, or +browser-console instructions as end-user error text. It asks the user to retry, +check their connection where relevant, and contact the CoRE Stack team if the +problem continues. **Download technical log** creates a small +`kyl-geolibre-.log` file containing at most the latest 40 lifecycle +events. Browsers require this explicit user download and cannot silently write +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. +- 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 + subset extraction or client-side re-encoding. + +GeoServer WCS returns the complete published coverage, matching KYL's previous +download flow. It is not guaranteed to be byte-identical to GeoServer's private +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. + +## Fresh-checkout setup + +No backend patch, generated project file, vendored GeoLibre bundle, or local +`.local/` prototype is required. A tester needs this branch and one `.env` file: + +```bash +git fetch origin +git switch feat/geolibre-cog-download +git pull --ff-only +cp .env.example .env +npm install +``` + +The committed `.env.example` provides the public API and GeoServer values needed +by both the existing KYL dashboard and the GeoLibre route. GeoLibre uses its +source defaults unless an operator intentionally enables the commented override +variables. Restart the React development server after any `.env` change. + +## Validation + +Focused tests: + +```bash +CI=true npm test -- --watchAll=false \ + src/config/geolibre.config.test.js \ + src/components/geolibre/geolibreProject.test.js \ + src/components/geolibre/GeoLibreFrame.test.jsx \ + src/components/landing_navbar.test.jsx +``` + +Build: + +```bash +npm run build +``` + +Local demo: + +```bash +HOST=0.0.0.0 PORT=3000 BROWSER=none npm start +``` + +Check both routes: + +1. Open `http://localhost:3000/kyl_dashboard` and confirm there is no + `REACT_APP_GEOSERVER_URL is not set` runtime error. +2. Open `http://localhost:3000`, select a state, district, and tehsil, and click + **Download Layers**. +3. Confirm the GeoLibre badge reports a compatible 2.x version, the Google + Satellite Hybrid basemap appears, the map fits the tehsil, and Demographic + shows Administrative Boundaries then Socio-Economic Profile, + both visible at `0.80`. +4. Confirm the native legend starts minimized and lists only Administrative + Boundaries and Socio-Economic Profile. Toggle a raster or LULC layer on and + confirm its legend appears; toggle it off and confirm that entry disappears. +5. Confirm no other layer loads by itself. Toggle Micro-watersheds and Drainage + under Hydrology and confirm each loads. Toggle each + off and on again and confirm its WFS request is not repeated. +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. +8. If testing a failure state, confirm it uses human recovery guidance and that + **Download technical log** saves a `.log` file. + +The generated `/download_layers?state=...&district=...&tehsil=...` URL can be +refreshed or shared on the same KYL host because the location is URL-backed. + +For a release upgrade, verify all of the following before changing the default +version: the ready handshake reports the expected release, the project loads +without `geolibre:error`, both default Demographic layers are visible at `0.8`, +the full tehsil fits exactly once, Hydrology and other vectors hydrate +only when toggled and are then reused, WMS tiles appear only when enabled, and GeoLibre +can save/export the resulting project. + +## Production deployment + +The deployment must set `REACT_APP_API_URL` and `REACT_APP_GEOSERVER_URL` before +running `npm run build`. The current official viewer is a rolling URL. To pin an +exact self-hosted build, additionally set the three version-template variables +shown above. GeoServer WFS, WMS, and WCS endpoints must remain reachable from +the user's browser with CORS enabled, and the site's framing policy must permit +`https://web.geolibre.app`. Browser policy must also permit imagery requests to +`https://mt1.google.com`, unless the basemap override is used. + +## Future integration options + +GeoLibre 2.2 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; +- 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 new file mode 100644 index 00000000..28205e5d --- /dev/null +++ b/src/components/geolibre/geolibreProject.js @@ -0,0 +1,1152 @@ +import { + GEOLIBRE_CONFIG, + GEOLIBRE_PROJECT_FORMAT_VERSION, + resolveGeoLibreViewer, +} from "../../config/geolibre.config"; +import { + GEOLIBRE_LAYERS, + GEOLIBRE_VECTOR_LAYERS, +} from "../../config/geolibreLayers"; + +const DEFAULT_GEOSERVER_URL = + "https://geoserver.core-stack.org:8443/geoserver/"; +const GOOGLE_SATELLITE_HYBRID_STYLE = { + version: 8, + name: "Google Satellite Hybrid", + sources: { + "google-satellite-hybrid": { + type: "raster", + tiles: ["https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}"], + tileSize: 256, + attribution: "© Google", + maxzoom: 20, + }, + }, + layers: [ + { + id: "google-satellite-hybrid", + type: "raster", + source: "google-satellite-hybrid", + }, + ], +}; +export const DEFAULT_GEOLIBRE_BASEMAP_STYLE = + process.env.REACT_APP_GEOLIBRE_BASEMAP_STYLE_URL || + `data:application/json;charset=utf-8,${encodeURIComponent( + JSON.stringify(GOOGLE_SATELLITE_HYBRID_STYLE) + )}`; +const EMPTY_FEATURE_COLLECTION = Object.freeze({ + type: "FeatureCollection", + features: [], +}); + +const BASE_STYLE = { + minZoom: 0, + maxZoom: 24, + fillColor: "#8b5cf6", + strokeColor: "#4c1d95", + strokeWidth: 1.5, + strokeWidthUnit: "pixels", + fillOpacity: 0.48, + circleRadius: 6, + vectorStyleMode: "single", + vectorStyleProperty: "", + vectorStyleClassCount: 1, + vectorStyleColorRamp: "viridis", + vectorStyleClassificationScheme: "unique-values", + vectorStyleStops: [], + vectorStyleExpression: "", + vectorRules: [], +}; + +const RASTER_STYLE = { + ...BASE_STYLE, + fillOpacity: 1, + rasterBrightnessMin: 0, + rasterBrightnessMax: 1, + rasterSaturation: 0, + rasterContrast: 0, + rasterHueRotate: 0, +}; + +const categoryStyle = (property, stops, overrides = {}) => ({ + ...BASE_STYLE, + ...overrides, + vectorStyleMode: "categorized", + vectorStyleProperty: property, + vectorStyleClassCount: stops.length, + vectorStyleStops: stops.map(([value, color, label]) => ({ + value, + color, + label, + })), +}); + +const expressionStyle = (expression, overrides = {}) => ({ + ...BASE_STYLE, + ...overrides, + vectorStyleMode: "expression", + vectorStyleExpression: JSON.stringify(expression), +}); + +const numericProperty = (property, fallback = 0) => [ + "to-number", + ["get", property], + fallback, +]; + +const croppingIntensityAverage = [ + "/", + [ + "+", + ...Array.from({ length: 8 }, (_, index) => + numericProperty(`cropping_intensity_${2017 + index}`) + ), + ], + 8, +]; + +const droughtOccurrences = (year, category) => [ + "-", + [ + "length", + ["split", ["to-string", ["get", `drlb_${year}`]], String(category)], + ], + 1, +]; + +const droughtYearFlag = (year) => [ + "case", + [ + ">=", + ["+", droughtOccurrences(year, 2), droughtOccurrences(year, 3)], + 5, + ], + 1, + 0, +]; + +const droughtYearCount = [ + "+", + ...Array.from({ length: 8 }, (_, index) => droughtYearFlag(2017 + index)), +]; + +const STYLE_PROFILES = { + boundary: { + ...BASE_STYLE, + fillColor: "#ffffff", + fillOpacity: 0, + strokeColor: "#111827", + strokeWidth: 1.5, + }, + demographics: expressionStyle( + [ + "step", + [ + "*", + [ + "/", + numericProperty("P_LIT"), + ["max", numericProperty("TOT_P", 1), 1], + ], + 100, + ], + "#98fb98", + 46, + "#32cd32", + 59, + "#228b22", + 70, + "#006400", + ], + { fillColor: "#98fb98", strokeColor: "#111827", fillOpacity: 0.65 } + ), + terrain_vector: categoryStyle( + "terrainClu", + [ + ["0", "#324a1c", "Broad Sloppy and Hilly"], + ["1", "#97c76b", "Mostly Plains"], + ["2", "#673a13", "Mostly Hills and Valleys"], + ["3", "#e5e059", "Broad Plains and Slopes"], + ], + { fillColor: "#e5e059", strokeColor: "#232323", fillOpacity: 0.75 } + ), + mws: expressionStyle( + [ + "step", + numericProperty("Net2018_23"), + "#ff0000", + -5, + "#ffff00", + -1, + "#25b63c", + 1, + "#1017f8", + ], + { fillColor: "#25b63c", strokeColor: "#232323", fillOpacity: 0.55 } + ), + drainage: categoryStyle( + "ORDER", + [ + ["1", "#03045e", "Stream order 1"], + ["2", "#023e8a", "Stream order 2"], + ["3", "#0077b6", "Stream order 3"], + ["4", "#0096c7", "Stream order 4"], + ["5", "#00b4d8", "Stream order 5"], + ["6", "#48cae4", "Stream order 6"], + ["7", "#90e0ef", "Stream order 7"], + ["8", "#ade8f4", "Stream order 8"], + ], + { fillColor: "#03045e", strokeColor: "#03045e", strokeWidth: 2 } + ), + waterbodies: { + ...BASE_STYLE, + fillColor: "#6495ed", + fillOpacity: 0.5, + strokeColor: "#2563eb", + strokeWidth: 2, + }, + soge: categoryStyle( + "class", + [ + ["Safe", "#ffffff", "Safe"], + ["Semi-critical", "#e0f3f8", "Semi-critical"], + ["Critical", "#4575b4", "Critical"], + ["Over Exploited", "#313695", "Over Exploited"], + ], + { fillColor: "#9ca3af", strokeColor: "#232323", fillOpacity: 0.72 } + ), + aquifer: categoryStyle( + "Principal_", + [ + ["Alluvium", "#fffdb5", "Alluvium"], + ["Laterite", "#f3a425", "Laterite"], + ["Basalt", "#99ecf1", "Basalt"], + ["Sandstone", "#a5f8c5", "Sandstone"], + ["Shale", "#f57c99", "Shale"], + ["Limestone", "#e8d52e", "Limestone"], + ["Granite", "#3c92f2", "Granite"], + ["Schist", "#d5db21", "Schist"], + ["Quartzite", "#cf7ff4", "Quartzite"], + ["Charnockite", "#f4dbff", "Charnockite"], + ["Khondalite", "#50c02b", "Khondalite"], + ["Banded Gneissic Complex", "#ffe1b5", "Banded Gneissic Complex"], + ["Gneiss", "#e4cff1", "Gneiss"], + ["Intrusive", "#57d2ff", "Intrusive"], + ], + { fillColor: "#57d2ff", strokeColor: "#232323", fillOpacity: 0.72 } + ), + cropping_intensity: expressionStyle( + [ + "step", + croppingIntensityAverage, + "#ff9371", + 1, + "#ffa500", + 2, + "#bad93e", + ], + { fillColor: "#ffa500", strokeColor: "#232323", fillOpacity: 0.7 } + ), + drought: expressionStyle( + [ + "step", + droughtYearCount, + "#f4d03f", + 1, + "#eb984e", + 2, + "#e74c3c", + ], + { fillColor: "#eb984e", strokeColor: "#232323", fillOpacity: 0.5 } + ), + nrega: categoryStyle( + "WorkCatego", + [ + ["Agri Impact - HH, Community", "#ffa500", "Land restoration"], + ["Household Livelihood", "#c2678d", "Off-farm livelihood assets"], + ["Irrigation - Site level impact", "#1a759f", "Irrigation on farms"], + ["Others - HH, Community", "#355070", "Community assets"], + ["Plantation", "#52b69a", "Plantations"], + [ + "SWC - Landscape level impact", + "#6495ed", + "Soil and Water conservation", + ], + ["Un Identified", "#6d597a", "Unidentified"], + ], + { + fillColor: "#6d597a", + strokeColor: "#ffffff", + fillOpacity: 0.9, + circleRadius: 6, + } + ), +}; + +const LEGEND_PROFILES = { + boundary: [["Administrative or hydrological boundary", "#111827", "line"]], + demographics: [ + ["Literacy below 46%", "#98fb98"], + ["Literacy 46% to below 59%", "#32cd32"], + ["Literacy 59% to below 70%", "#228b22"], + ["Literacy 70% or above", "#006400"], + ], + mws: [ + ["Net groundwater change below -5", "#ff0000"], + ["Net groundwater change -5 to below -1", "#ffff00"], + ["Net groundwater change -1 to below 1", "#25b63c"], + ["Net groundwater change 1 or above", "#1017f8"], + ], + waterbodies: [["Surface waterbody", "#6495ed"]], + cropping_intensity: [ + ["Average cropping intensity below 1", "#ff9371"], + ["Average cropping intensity 1 to below 2", "#ffa500"], + ["Average cropping intensity 2 or above", "#bad93e"], + ], + drought: [ + ["No recurrent drought year", "#f4d03f"], + ["One recurrent drought year", "#eb984e"], + ["Two or more recurrent drought years", "#e74c3c"], + ], + terrain: [ + ["V-shaped river valleys and deep narrow canyons", "#313695"], + ["Lateral midslope drainage and local valleys", "#4575b4"], + ["Upland drainage and stream headwaters", "#a50026"], + ["U-shaped valleys", "#e0f3f8"], + ["Broad flat areas", "#fffc00"], + ["Broad open slopes", "#feb24c"], + ["Mesa tops", "#f46d43"], + ["Upper slopes", "#d73027"], + ["Local ridges or hilltops", "#91bfdb"], + ["Midslope divides or local ridges", "#800000"], + ["Mountain tops or high ridges", "#4d0000"], + ], + clart: [ + ["Good recharge", "#4ee323"], + ["Moderate recharge", "#f3ff33"], + ["Surface-water harvesting", "#f21223"], + ["Regeneration", "#b40f7d"], + ["High-runoff zone", "#1774de"], + ], + afforestation: [ + ["Trees to trees", "#73bb53"], + ["Built-up to trees", "#ff0000"], + ["Crops to trees", "#eee05d"], + ["Barren to trees", "#a9a9a9"], + ["Shrubs and scrubs to trees", "#eaa4f0"], + ], + deforestation: [ + ["Trees to trees", "#73bb53"], + ["Trees to built-up", "#ff0000"], + ["Trees to crops", "#eee05d"], + ["Trees to barren", "#a9a9a9"], + ["Trees to shrubs and scrubs", "#eaa4f0"], + ], + degradation: [ + ["Crops to crops", "#eee05d"], + ["Crops to built-up", "#ff0000"], + ["Crops to barren", "#a9a9a9"], + ["Crops to shrubs and scrubs", "#eaa4f0"], + ], + urbanization: [ + ["Built-up to built-up", "#ff0000"], + ["Water to built-up", "#1ca3ec"], + ["Trees or crops to built-up", "#73bb53"], + ["Barren or shrubs and scrubs to built-up", "#a9a9a9"], + ], + cropintensity: [ + ["Double to single cropping", "#ff6347"], + ["Triple, annual or perennial to single", "#ff4500"], + ["Triple, annual or perennial to double", "#ff0000"], + ["Single to double cropping", "#00ff00"], + ["Single to triple, annual or perennial", "#32cd32"], + ["Double to triple, annual or perennial", "#228b22"], + ["Single to single cropping", "#4227f5"], + ["Double to double cropping", "#712103"], + ["Triple, annual or perennial unchanged", "#ad27f5"], + ], + restoration: [ + ["Mosaic restoration", "#d79b0f"], + ["Wide-scale restoration", "#0f077c"], + ["Protection", "#4fbc14"], + ], + lulc_level_1: [ + ["Built-up", "#ff0000"], + ["Water", "#1ca3ec"], + ["Greenery", "#73bb53"], + ["Barren lands", "#a9a9a9"], + ["Shrubs and scrubs", "#eaa4f0"], + ], + lulc_level_2: [ + ["Trees and forests", "#73bb53"], + ["Crops", "#fad36f"], + ], + lulc_level_3: [ + ["Single Kharif", "#d9f0a3"], + ["Single non-Kharif", "#a6d96a"], + ["Double cropping", "#4daf4a"], + ["Triple cropping", "#006d2c"], + ], +}; + +const legendShape = (catalogLayer) => + catalogLayer.geometryType === "line" + ? "line" + : catalogLayer.geometryType === "point" + ? "circle" + : "square"; + +const layerLegend = (catalogLayer, style) => { + const profile = + LEGEND_PROFILES[catalogLayer.id] || + LEGEND_PROFILES[catalogLayer.baseId] || + LEGEND_PROFILES[catalogLayer.styleProfile]; + const shape = legendShape(catalogLayer); + const entries = + profile || + (style.vectorStyleStops || []).map((stop) => [ + stop.label || String(stop.value), + stop.color, + ]); + const items = entries.map(([label, color, itemShape]) => ({ + label, + color, + shape: itemShape || shape, + })); + const baseTitle = catalogLayer.baseId + ? catalogLayer.label.split(" · ")[0] + : catalogLayer.label; + return { + key: catalogLayer.baseId || catalogLayer.id, + title: `${baseTitle} legend`, + items, + legendPosition: "bottom-right", + }; +}; + +const GROUPS_TOP_FIRST = [ + { id: "demographic", name: "Demographic", collapsed: false }, + { 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 }, + { id: "lulc-1", name: "LULC · Level 1 by year", collapsed: true }, + { 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: "nrega", name: "NREGA", collapsed: true }, +]; + +const projectPreferences = { + map: { + restrictBounds: false, + bounds: [-180, -85, 180, 85], + minZoom: 0, + maxZoom: 24, + maxPitch: 85, + renderWorldCopies: true, + projection: "globe", + ellipsoidId: "earth", + scaleUnit: "metric", + }, + environmentVariables: [], + geocoding: { providerId: "nominatim", apiKeys: {} }, +}; + +const normalizeBaseUrl = (url) => `${url.replace(/\/+$/, "")}/`; + +export const formatGeoServerName = (value) => + String(value || "") + .replace(/[()]/g, "") + .replace(/\s+/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, "") + .toLowerCase(); + +const appendQuery = (endpoint, entries) => { + const separator = endpoint.includes("?") + ? endpoint.endsWith("?") || endpoint.endsWith("&") + ? "" + : "&" + : "?"; + const query = entries + .map(([key, value]) => { + const encodedValue = + value === "{bbox-epsg-3857}" ? value : encodeURIComponent(value); + return `${encodeURIComponent(key)}=${encodedValue}`; + }) + .join("&"); + return `${endpoint}${separator}${query}`; +}; + +const buildWfsRequest = (baseUrl, layer, layerName) => { + const endpoint = `${baseUrl}${layer.workspace}/ows`; + const typeName = `${layer.workspace}:${layerName}`; + return { + endpoint, + typeName, + version: "1.0.0", + outputFormat: "application/json", + srsName: "EPSG:4326", + url: appendQuery(endpoint, [ + ["service", "WFS"], + ["version", "1.0.0"], + ["request", "GetFeature"], + ["typeName", typeName], + ["outputFormat", "application/json"], + ["srsName", "EPSG:4326"], + ]), + }; +}; + +const buildWmsSource = (baseUrl, layer, layerName, bounds) => { + const endpoint = `${baseUrl}${layer.workspace}/wms`; + const qualifiedName = `${layer.workspace}:${layerName}`; + const source = { + type: "raster", + tiles: [ + appendQuery(endpoint, [ + ["SERVICE", "WMS"], + ["REQUEST", "GetMap"], + ["VERSION", "1.1.1"], + ["LAYERS", qualifiedName], + ["STYLES", layer.wmsStyle || ""], + ["FORMAT", "image/png"], + ["TRANSPARENT", "TRUE"], + ["SRS", "EPSG:3857"], + ["BBOX", "{bbox-epsg-3857}"], + ["WIDTH", "256"], + ["HEIGHT", "256"], + ]), + ], + tileSize: 256, + url: endpoint, + layers: qualifiedName, + styles: layer.wmsStyle || "", + format: "image/png", + transparent: true, + version: "1.1.1", + }; + if (bounds) source.bounds = bounds; + return source; +}; + +const buildWcsUrl = (baseUrl, layer, layerName) => + appendQuery(`${baseUrl}${layer.workspace}/wcs`, [ + ["service", "WCS"], + ["version", "2.0.1"], + ["request", "GetCoverage"], + ["CoverageId", `${layer.workspace}:${layerName}`], + ["format", "geotiff"], + ["compression", "LZW"], + ]); + +const validBounds = (bounds) => + Array.isArray(bounds) && + bounds.length === 4 && + bounds.every(Number.isFinite) && + bounds[0] < bounds[2] && + bounds[1] < bounds[3]; + +export const geoJsonBounds = (featureCollection) => { + if (validBounds(featureCollection?.bbox)) { + return featureCollection.bbox.map(Number); + } + + const bounds = [Infinity, Infinity, -Infinity, -Infinity]; + const visitCoordinates = (coordinates) => { + if (!Array.isArray(coordinates)) return; + if ( + coordinates.length >= 2 && + Number.isFinite(Number(coordinates[0])) && + Number.isFinite(Number(coordinates[1])) + ) { + const longitude = Number(coordinates[0]); + const latitude = Number(coordinates[1]); + bounds[0] = Math.min(bounds[0], longitude); + bounds[1] = Math.min(bounds[1], latitude); + bounds[2] = Math.max(bounds[2], longitude); + bounds[3] = Math.max(bounds[3], latitude); + return; + } + coordinates.forEach(visitCoordinates); + }; + + for (const feature of featureCollection?.features || []) { + const geometry = feature?.geometry; + if (geometry?.type === "GeometryCollection") { + geometry.geometries?.forEach((item) => visitCoordinates(item.coordinates)); + } else { + visitCoordinates(geometry?.coordinates); + } + } + + return validBounds(bounds) ? bounds : null; +}; + +const mercatorY = (latitude) => { + const clamped = Math.max(-85.051129, Math.min(85.051129, latitude)); + const radians = (clamped * Math.PI) / 180; + return ( + (1 - + Math.log(Math.tan(radians) + 1 / Math.cos(radians)) / Math.PI) / + 2 + ); +}; + +export const mapViewFromBounds = ( + bounds, + { width = 1100, height = 720, padding = 72 } = {} +) => { + if (!validBounds(bounds)) { + throw new Error("A valid [west, south, east, north] extent is required."); + } + const [west, south, east, north] = bounds.map(Number); + const longitudeFraction = Math.max((east - west) / 360, 1e-9); + const latitudeFraction = Math.max( + Math.abs(mercatorY(north) - mercatorY(south)), + 1e-9 + ); + const usableWidth = Math.max(width - padding * 2, 256); + const usableHeight = Math.max(height - padding * 2, 256); + const zoom = Math.min( + Math.log2(usableWidth / 256 / longitudeFraction), + Math.log2(usableHeight / 256 / latitudeFraction) + ); + + return { + center: [(west + east) / 2, (south + north) / 2], + zoom: Math.max(3, Math.min(16, Math.floor((zoom - 0.2) * 10) / 10)), + bearing: 0, + pitch: 0, + bbox: [west, south, east, north], + }; +}; + +const isFeatureCollection = (value) => + value?.type === "FeatureCollection" && Array.isArray(value.features); + +export const fetchWfsFeatureCollection = async (request, { signal } = {}) => { + const response = await fetch(request.url, { + signal, + headers: { Accept: "application/geo+json, application/json" }, + }); + if (!response.ok) { + throw new Error(`WFS request failed with HTTP ${response.status}.`); + } + + let data; + try { + data = await response.json(); + } catch (_error) { + throw new Error("WFS returned a response that was not JSON."); + } + if (!isFeatureCollection(data)) { + throw new Error("WFS response is not a GeoJSON FeatureCollection."); + } + return data; +}; + +const layerStyle = (layer) => + layer.sourceType === "wms" + ? { ...RASTER_STYLE } + : { ...(STYLE_PROFILES[layer.styleProfile] || BASE_STYLE) }; + +const coreStackMetadata = (layer, layerName, sourceUrl, style) => ({ + domain: layer.domain, + geoserverWorkspace: layer.workspace, + geoserverLayer: layerName, + sourceType: layer.sourceType, + liveSource: sourceUrl, + qmlStyleUrl: layer.qmlStyleUrl, + 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.", +}); + +const buildVectorLayer = ({ + catalogLayer, + layerName, + request, + data = EMPTY_FEATURE_COLLECTION, + failure, + loaded = false, +}) => { + const style = layerStyle(catalogLayer); + const isDefaultDisplay = catalogLayer.defaultVisible === true; + const loadState = failure ? "error" : loaded ? "loaded" : "unloaded"; + return { + id: `corestack-${catalogLayer.id}`, + name: catalogLayer.label, + type: "geojson", + source: { + type: "geojson", + url: request.url, + service: "wfs", + typeName: request.typeName, + version: request.version, + outputFormat: request.outputFormat, + srsName: request.srsName, + }, + visible: isDefaultDisplay, + opacity: isDefaultDisplay ? 0.8 : 1, + style, + metadata: { + featureCount: data.features.length, + service: "wfs", + sourceKind: "wfs-getfeature", + typeName: request.typeName, + loadState, + ...(failure ? { initialLoadError: failure.message } : {}), + corestack: { + ...coreStackMetadata(catalogLayer, layerName, request.url, style), + loadState, + }, + }, + geojson: data, + sourcePath: request.url, + groupId: catalogLayer.loadGroup, + }; +}; + +const buildRasterLayer = ({ catalogLayer, layerName, baseUrl, bounds }) => { + const wmsSource = buildWmsSource(baseUrl, catalogLayer, layerName, bounds); + const wcsDownloadUrl = buildWcsUrl(baseUrl, catalogLayer, layerName); + const source = { + ...wmsSource, + // GeoLibre exposes its byte-preserving "GeoTIFF (COG)" export for a + // raster layer when source.url points to a complete downloadable file. + // Rendering still uses the styled WMS tile template above. + url: wcsDownloadUrl, + wmsUrl: wmsSource.url, + }; + const style = layerStyle(catalogLayer); + return { + id: `corestack-${catalogLayer.id}`, + name: catalogLayer.label, + type: "raster", + source, + visible: false, + opacity: 1, + style, + metadata: { + service: "wms", + corestack: { + ...coreStackMetadata(catalogLayer, layerName, wmsSource.url, style), + wcsDownloadUrl, + rasterDownload: { + kind: "full-coverage-geotiff", + url: wcsDownloadUrl, + bytePreservingInGeoLibre: true, + }, + }, + }, + sourcePath: wcsDownloadUrl, + groupId: catalogLayer.loadGroup, + }; +}; + +const displayOrderForGroup = (groupId, layers) => { + const matching = layers.filter((layer) => layer.groupId === groupId); + return groupId.startsWith("lulc-") ? [...matching].reverse() : matching; +}; + +export const orderGeoLibreLayers = (layers) => { + const topFirst = GROUPS_TOP_FIRST.flatMap((group) => + displayOrderForGroup(group.id, layers) + ); + return topFirst.reverse(); +}; + +const mapLegendEntries = (orderedLayers) => { + const seen = new Set(); + const entries = [...orderedLayers] + .reverse() + .map((layer) => layer.metadata?.corestack?.legend) + .filter((entry) => { + if (!entry?.items?.length || seen.has(entry.key)) return false; + seen.add(entry.key); + return true; + }); + const selected = entries.find((entry) => entry.key === "demographics"); + return selected + ? [selected, ...entries.filter((entry) => entry !== selected)] + : entries; +}; + +const legendPluginState = (entries, currentPlugins) => { + const selected = entries[0]; + const currentComponents = + currentPlugins?.settings?.["maplibre-gl-components"] || {}; + const currentLegend = currentComponents.legend; + const selectedIndex = Math.max( + 0, + entries.findIndex((entry) => entry.title === currentLegend?.title) + ); + const selectedEntry = entries[selectedIndex] || selected; + const legend = selectedEntry + ? { + ...currentLegend, + visible: true, + collapsed: currentLegend?.collapsed ?? true, + hasLegend: true, + selectedLegendIndex: selectedIndex, + title: selectedEntry.title, + items: selectedEntry.items, + legendPosition: selectedEntry.legendPosition, + legends: entries.map(({ key: _key, ...entry }) => entry), + } + : { + ...currentLegend, + visible: false, + collapsed: true, + hasLegend: false, + selectedLegendIndex: 0, + title: "Legend", + items: [], + legendPosition: "bottom-right", + legends: [], + }; + + return { + manifestUrls: currentPlugins?.manifestUrls || [], + activePluginIds: Array.from( + new Set([ + ...(currentPlugins?.activePluginIds || [ + "maplibre-layer-control", + "maplibre-atmosphere-effects", + "maplibre-deckgl-viz", + ]), + "maplibre-gl-components", + ]) + ), + mapControlPositions: { + ...currentPlugins?.mapControlPositions, + "maplibre-gl-components": "top-right", + }, + settings: { + ...currentPlugins?.settings, + "maplibre-gl-components": { + ...currentComponents, + legend, + }, + }, + }; +}; + +const legendStateSignature = (legend) => + JSON.stringify({ + visible: legend?.visible, + title: legend?.title, + items: legend?.items, + legendPosition: legend?.legendPosition, + legends: legend?.legends, + }); + +export const syncGeoLibreActiveLegends = (project) => { + if (!project?.layers) return project; + const entries = mapLegendEntries( + project.layers.filter((layer) => layer.visible) + ); + const plugins = legendPluginState(entries, project.plugins); + const currentLegend = + project.plugins?.settings?.["maplibre-gl-components"]?.legend; + const nextLegend = plugins.settings["maplibre-gl-components"].legend; + + if ( + legendStateSignature(currentLegend) === legendStateSignature(nextLegend) + ) { + return project; + } + return { ...project, plugins }; +}; + +const readableError = (error) => + error instanceof Error ? error.message : String(error); + +const replaceProjectLayer = (project, layerId, replacement) => ({ + ...project, + layers: project.layers.map((layer) => + layer.id === layerId ? replacement : layer + ), +}); + +const withLazyLoadFailure = (project, layerId, failure) => { + const layerLoading = project.metadata?.layerLoading || {}; + const remainingFailures = (layerLoading.lazyLoadFailures || []).filter( + (item) => item.layerId !== layerId + ); + return { + ...project, + metadata: { + ...project.metadata, + layerLoading: { + ...layerLoading, + lazyLoadFailures: failure + ? [...remainingFailures, failure] + : remainingFailures, + }, + }, + }; +}; + +export const hydrateGeoLibreVectorLayer = async ({ + project, + layerId, + signal, + fetchFeatureCollection = fetchWfsFeatureCollection, +}) => { + const layer = project?.layers?.find((item) => item.id === layerId); + if (!layer || layer.type !== "geojson") { + throw new Error(`GeoLibre vector layer ${layerId} is not available.`); + } + if (layer.metadata?.loadState === "loaded") return project; + + const request = { + url: layer.source?.url, + typeName: layer.source?.typeName, + version: layer.source?.version, + outputFormat: layer.source?.outputFormat, + srsName: layer.source?.srsName, + }; + if (!request.url) { + throw new Error(`GeoLibre vector layer ${layer.name} has no WFS URL.`); + } + + try { + const data = await fetchFeatureCollection(request, { signal }); + const { initialLoadError: _initialLoadError, ...metadata } = + layer.metadata || {}; + const hydratedLayer = { + ...layer, + geojson: data, + metadata: { + ...metadata, + featureCount: data.features.length, + loadState: "loaded", + corestack: { + ...metadata.corestack, + loadState: "loaded", + }, + }, + }; + return withLazyLoadFailure( + replaceProjectLayer(project, layerId, hydratedLayer), + layerId, + null + ); + } catch (error) { + if (signal?.aborted) throw error; + const failure = { + layerId, + layerName: layer.name, + sourceUrl: request.url, + message: readableError(error), + }; + const failedLayer = { + ...layer, + metadata: { + ...layer.metadata, + loadState: "error", + initialLoadError: failure.message, + corestack: { + ...layer.metadata?.corestack, + loadState: "error", + }, + }, + }; + return withLazyLoadFailure( + replaceProjectLayer(project, layerId, failedLayer), + layerId, + failure + ); + } +}; + +export const buildGeoLibreProject = async ({ + state, + district, + tehsil, + viewport, + geoserverUrl = process.env.REACT_APP_GEOSERVER_URL || DEFAULT_GEOSERVER_URL, + signal, + onProgress = () => {}, + fetchFeatureCollection = fetchWfsFeatureCollection, +}) => { + if (!state || !district || !tehsil) { + throw new Error("Select a state, district, and tehsil first."); + } + + const baseUrl = normalizeBaseUrl(geoserverUrl); + const scope = { + district: formatGeoServerName(district), + tehsil: formatGeoServerName(tehsil), + }; + const requestCache = new Map(); + const vectorResults = new Map(); + const failures = []; + + const requestFor = (catalogLayer) => { + const layerName = catalogLayer.layerName(scope); + return { + layerName, + request: buildWfsRequest(baseUrl, catalogLayer, layerName), + }; + }; + + const loadVector = async ( + catalogLayer, + required = false, + reportProgress = true + ) => { + const { layerName, request } = requestFor(catalogLayer); + if (reportProgress) { + onProgress({ + phase: catalogLayer.id, + message: `Loading ${catalogLayer.label}…`, + }); + } + + if (!requestCache.has(request.url)) { + requestCache.set( + request.url, + fetchFeatureCollection(request, { signal }) + ); + } + + try { + const data = await requestCache.get(request.url); + vectorResults.set(catalogLayer.id, { + data, + layerName, + request, + loaded: true, + }); + return data; + } catch (error) { + if (signal?.aborted) throw error; + const failure = { + layerId: catalogLayer.id, + layerName: catalogLayer.label, + sourceUrl: request.url, + message: readableError(error), + }; + if (required) { + throw new Error( + `Could not load the Socio-Economic Profile needed to locate ${tehsil}: ${failure.message}` + ); + } + failures.push(failure); + vectorResults.set(catalogLayer.id, { + data: EMPTY_FEATURE_COLLECTION, + layerName, + request, + failure, + loaded: false, + }); + return EMPTY_FEATURE_COLLECTION; + } + }; + + const socioeconomic = GEOLIBRE_VECTOR_LAYERS.find( + (layer) => layer.id === "demographics" + ); + const administrative = GEOLIBRE_VECTOR_LAYERS.find( + (layer) => layer.id === "administrative_boundaries" + ); + const socioeconomicData = await loadVector(socioeconomic, true); + // Both default Demographic entries use the same GeoServer source. The request cache + // makes this a metadata/style duplication, not a second network download. + await loadVector(administrative, true, false); + const bounds = geoJsonBounds(socioeconomicData); + if (!bounds) { + throw new Error( + `The Socio-Economic Profile for ${tehsil} has no usable geographic extent.` + ); + } + + const createProject = () => { + const layers = GEOLIBRE_LAYERS.map((catalogLayer) => { + const layerName = catalogLayer.layerName(scope); + if (catalogLayer.sourceType === "wfs") { + const result = vectorResults.get(catalogLayer.id); + return buildVectorLayer({ + catalogLayer, + layerName, + ...(result || { + data: EMPTY_FEATURE_COLLECTION, + request: buildWfsRequest(baseUrl, catalogLayer, layerName), + loaded: false, + }), + }); + } + return buildRasterLayer({ catalogLayer, layerName, baseUrl, bounds }); + }); + const orderedLayers = orderGeoLibreLayers(layers); + const styles = Object.fromEntries( + orderedLayers.map((layer) => [layer.id, layer.style]) + ); + const mapLegends = mapLegendEntries( + orderedLayers.filter((layer) => layer.visible) + ); + const viewer = resolveGeoLibreViewer(); + + return { + version: GEOLIBRE_PROJECT_FORMAT_VERSION, + name: `${tehsil}, ${district}: CoRE Stack landscape`, + mapView: mapViewFromBounds(bounds, viewport), + basemapStyleUrl: DEFAULT_GEOLIBRE_BASEMAP_STYLE, + basemapVisible: true, + basemapOpacity: 1, + layers: orderedLayers, + layerGroups: GROUPS_TOP_FIRST.map((group) => ({ + ...group, + visible: true, + opacity: 1, + })), + styles, + preferences: projectPreferences, + plugins: legendPluginState(mapLegends), + legend: { + title: `${tehsil} CoRE Stack layers`, + groupByLayer: true, + order: [...orderedLayers].reverse().map((layer) => layer.id), + overrides: {}, + }, + metadata: { + generatedAtUtc: new Date().toISOString(), + generatedBy: "Know Your Landscape", + license: { + name: "CC BY 4.0", + url: "https://creativecommons.org/licenses/by/4.0/", + notice: "CoRE Stack datasets are available under CC BY 4.0", + }, + scope: { level: "tehsil", state, district, tehsil, bounds }, + geolibre: { + applicationVersion: GEOLIBRE_CONFIG.version, + projectFormatVersion: GEOLIBRE_PROJECT_FORMAT_VERSION, + viewerUrl: viewer.url, + }, + layerLoading: { + stage: "demographic", + order: [ + "Administrative Boundaries and Socio-Economic Profile", + "All other vector layers on first visibility toggle", + "Raster tiles on visibility toggle", + ], + 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.", + }, + }; + }; + + onProgress({ phase: "project", message: "Opening this tehsil in GeoLibre…" }); + return createProject(); +}; diff --git a/src/components/geolibre/geolibreProject.test.js b/src/components/geolibre/geolibreProject.test.js new file mode 100644 index 00000000..708d076d --- /dev/null +++ b/src/components/geolibre/geolibreProject.test.js @@ -0,0 +1,411 @@ +import { + buildGeoLibreProject, + DEFAULT_GEOLIBRE_BASEMAP_STYLE, + formatGeoServerName, + geoJsonBounds, + hydrateGeoLibreVectorLayer, + mapViewFromBounds, + syncGeoLibreActiveLegends, +} from "./geolibreProject"; +import { GEOLIBRE_LAYERS } from "../../config/geolibreLayers"; + +const location = { + state: "Assam", + district: "Cachar", + tehsil: "Lakhipur", +}; + +const polygonFeatureCollection = (request) => ({ + type: "FeatureCollection", + features: [ + { + type: "Feature", + id: request.typeName, + properties: { P_LIT: 60, TOT_P: 100 }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [92.9, 24.7], + [93.2, 24.7], + [93.2, 25.0], + [92.9, 25.0], + [92.9, 24.7], + ], + ], + }, + }, + ], +}); + +const successfulFetch = jest.fn(); + +beforeEach(() => { + successfulFetch.mockReset(); + successfulFetch.mockImplementation(async (request) => + polygonFeatureCollection(request) + ); +}); + +describe("GeoLibre 2.2 project generation", () => { + it("normalizes KYL location labels for GeoServer layer names", () => { + expect(formatGeoServerName(" Banas Kantha (Palanpur) ")).toBe( + "banas_kantha_palanpur" + ); + }); + + it("derives a complete bounding box and a padded map view", () => { + const bounds = geoJsonBounds(polygonFeatureCollection({ typeName: "test" })); + expect(bounds).toEqual([92.9, 24.7, 93.2, 25]); + expect(mapViewFromBounds(bounds, { width: 1000, height: 700 })).toEqual( + expect.objectContaining({ + center: [93.05000000000001, 24.85], + bbox: bounds, + bearing: 0, + pitch: 0, + }) + ); + }); + + it("builds default Demographic WFS layers and downloadable, lazy styled rasters", async () => { + const project = await buildGeoLibreProject({ + ...location, + fetchFeatureCollection: successfulFetch, + }); + + expect(project.version).toBe("0.2.0"); + expect(project.layers).toHaveLength(GEOLIBRE_LAYERS.length); + expect(project.layers).toHaveLength(45); + 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( + "https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}" + ); + expect(project.metadata.license).toMatchObject({ + name: "CC BY 4.0", + notice: "CoRE Stack datasets are available under CC BY 4.0", + }); + + const socioeconomic = project.layers.find( + (layer) => layer.id === "corestack-demographics" + ); + expect(socioeconomic).toMatchObject({ + type: "geojson", + visible: true, + opacity: 0.8, + source: { + type: "geojson", + service: "wfs", + version: "1.0.0", + typeName: "panchayat_boundaries:cachar_lakhipur", + }, + metadata: { + sourceKind: "wfs-getfeature", + service: "wfs", + featureCount: 1, + loadState: "loaded", + }, + }); + expect(socioeconomic.geojson.type).toBe("FeatureCollection"); + + const visibleLayers = project.layers.filter((layer) => layer.visible); + expect(visibleLayers.map((layer) => layer.id)).toEqual([ + "corestack-demographics", + "corestack-administrative_boundaries", + ]); + expect( + visibleLayers.every((layer) => layer.opacity === 0.8) + ).toBe(true); + expect( + project.layers + .filter((layer) => !layer.visible) + .every((layer) => layer.opacity === 1) + ).toBe(true); + + const mws = project.layers.find( + (layer) => layer.id === "corestack-mws_layers" + ); + expect(mws).toMatchObject({ + visible: false, + metadata: { loadState: "unloaded", featureCount: 0 }, + geojson: { type: "FeatureCollection", features: [] }, + }); + + const drainage = project.layers.find( + (layer) => layer.id === "corestack-drainage" + ); + expect(drainage).toMatchObject({ + visible: false, + metadata: { loadState: "unloaded", featureCount: 0 }, + geojson: { type: "FeatureCollection", features: [] }, + }); + + const latestLulc = project.layers.find( + (layer) => layer.id === "corestack-lulc_level_3_24_25" + ); + expect(latestLulc).toMatchObject({ + type: "raster", + visible: false, + metadata: { + service: "wms", + corestack: { + rasterDownload: { + kind: "full-coverage-geotiff", + bytePreservingInGeoLibre: true, + }, + }, + }, + }); + expect(latestLulc.source.layers).toBe( + "LULC_level_3:LULC_24_25_cachar_lakhipur_level_3" + ); + expect(latestLulc.source.tiles[0]).toContain( + "BBOX={bbox-epsg-3857}" + ); + expect(latestLulc.source.wmsUrl).toContain("/LULC_level_3/wms"); + expect(latestLulc.source.url).toContain("request=GetCoverage"); + expect(latestLulc.source.url).toContain( + "CoverageId=LULC_level_3%3ALULC_24_25_cachar_lakhipur_level_3" + ); + expect(successfulFetch).toHaveBeenCalledTimes(1); + }); + + it("uses the deployed domain taxonomy while preserving the preferred order", async () => { + const project = await buildGeoLibreProject({ + ...location, + fetchFeatureCollection: successfulFetch, + }); + const displayIds = [...project.layers] + .reverse() + .map((layer) => layer.id); + + expect(displayIds.slice(0, 5)).toEqual([ + "corestack-administrative_boundaries", + "corestack-demographics", + "corestack-mws_layers", + "corestack-hydrological_boundaries", + "corestack-mws_layers_fortnight", + ]); + expect( + project.layers + .filter((layer) => + [ + "corestack-mws_layers", + "corestack-hydrological_boundaries", + "corestack-mws_layers_fortnight", + ].includes(layer.id) + ) + .every((layer) => layer.groupId === "hydrology") + ).toBe(true); + expect(displayIds.indexOf("corestack-lulc_level_3_24_25")).toBeLessThan( + displayIds.indexOf("corestack-lulc_level_3_23_24") + ); + expect(displayIds.indexOf("corestack-lulc_level_3_24_25")).toBeLessThan( + displayIds.indexOf("corestack-terrain") + ); + expect(project.layerGroups.map((group) => group.id)).toEqual([ + "demographic", + "hydrology", + "lulc-3", + "lulc-2", + "lulc-1", + "land", + "agriculture", + "restoration", + "climate", + "nrega", + ]); + }); + + it("starts a minimized legend containing only active default layers", async () => { + const project = await buildGeoLibreProject({ + ...location, + fetchFeatureCollection: successfulFetch, + }); + const legend = + project.plugins.settings["maplibre-gl-components"].legend; + + expect(project.plugins.activePluginIds).toContain( + "maplibre-gl-components" + ); + expect(legend).toMatchObject({ + visible: true, + collapsed: true, + hasLegend: true, + title: "Socio-Economic Profile legend", + }); + expect(legend.items).toContainEqual({ + label: "Literacy 70% or above", + color: "#006400", + shape: "square", + }); + expect(legend.legendPosition).toBe("bottom-right"); + expect(legend.legends.map((entry) => entry.title)).toEqual([ + "Socio-Economic Profile legend", + "Administrative Boundaries legend", + ]); + }); + + it("adds and removes legend entries when layer visibility changes", async () => { + const project = await buildGeoLibreProject({ + ...location, + fetchFeatureCollection: successfulFetch, + }); + const withVisibleLayers = { + ...project, + layers: project.layers.map((layer) => + ["corestack-drainage", "corestack-terrain"].includes(layer.id) + ? { ...layer, visible: true } + : layer + ), + }; + const synced = syncGeoLibreActiveLegends(withVisibleLayers); + const legend = + synced.plugins.settings["maplibre-gl-components"].legend; + + expect(legend.legends.map((entry) => entry.title)).toEqual([ + "Socio-Economic Profile legend", + "Administrative Boundaries legend", + "Drainage legend", + "Terrain legend", + ]); + + const drainageHidden = { + ...synced, + layers: synced.layers.map((layer) => + layer.id === "corestack-drainage" + ? { ...layer, visible: false } + : layer + ), + }; + const resynced = syncGeoLibreActiveLegends(drainageHidden); + expect( + resynced.plugins.settings["maplibre-gl-components"].legend.legends.map( + (entry) => entry.title + ) + ).toEqual([ + "Socio-Economic Profile legend", + "Administrative Boundaries legend", + "Terrain legend", + ]); + }); + + it("loads only the shared Demographic source during project creation", async () => { + const project = await buildGeoLibreProject({ + ...location, + fetchFeatureCollection: successfulFetch, + }); + + expect(project.metadata.layerLoading.stage).toBe("demographic"); + expect( + project.layers + .filter( + (layer) => + layer.type === "geojson" && + ![ + "corestack-administrative_boundaries", + "corestack-demographics", + ].includes(layer.id) + ) + .every((layer) => layer.metadata.loadState === "unloaded") + ).toBe(true); + expect( + project.layers.find((layer) => layer.id === "corestack-mws_layers") + .metadata.loadState + ).toBe("unloaded"); + expect( + project.layers + .filter((layer) => layer.visible) + .map((layer) => layer.id) + ).toEqual([ + "corestack-demographics", + "corestack-administrative_boundaries", + ]); + expect(successfulFetch.mock.calls[0][0].typeName).toBe( + "panchayat_boundaries:cachar_lakhipur" + ); + expect(successfulFetch).toHaveBeenCalledTimes(1); + }); + + it("loads a toggled vector once and reuses its hydrated data", async () => { + const project = await buildGeoLibreProject({ + ...location, + fetchFeatureCollection: successfulFetch, + }); + const drainageId = "corestack-drainage"; + const toggledProject = { + ...project, + layers: project.layers.map((layer) => + layer.id === drainageId ? { ...layer, visible: true } : layer + ), + }; + + const hydratedProject = await hydrateGeoLibreVectorLayer({ + project: toggledProject, + layerId: drainageId, + fetchFeatureCollection: successfulFetch, + }); + const drainage = hydratedProject.layers.find( + (layer) => layer.id === drainageId + ); + + expect(drainage).toMatchObject({ + visible: true, + metadata: { loadState: "loaded", featureCount: 1 }, + }); + expect(successfulFetch.mock.calls[1][0].typeName).toContain("drainage"); + expect(successfulFetch).toHaveBeenCalledTimes(2); + + const reusedProject = await hydrateGeoLibreVectorLayer({ + project: hydratedProject, + layerId: drainageId, + fetchFeatureCollection: successfulFetch, + }); + expect(reusedProject).toBe(hydratedProject); + expect(successfulFetch).toHaveBeenCalledTimes(2); + }); + + it("keeps a failed lazy vector available for a later toggle retry", async () => { + const project = await buildGeoLibreProject({ + ...location, + fetchFeatureCollection: successfulFetch, + }); + const layerId = "corestack-mws_layers_fortnight"; + const failedFetch = jest.fn(async () => { + throw new Error("temporary outage"); + }); + + const failedProject = await hydrateGeoLibreVectorLayer({ + project, + layerId, + fetchFeatureCollection: failedFetch, + }); + expect( + failedProject.layers.find((layer) => layer.id === layerId).metadata + ).toMatchObject({ loadState: "error", initialLoadError: "temporary outage" }); + expect(failedProject.metadata.layerLoading.lazyLoadFailures).toHaveLength(1); + + const retriedProject = await hydrateGeoLibreVectorLayer({ + project: failedProject, + layerId, + fetchFeatureCollection: successfulFetch, + }); + expect( + retriedProject.layers.find((layer) => layer.id === layerId).metadata + .loadState + ).toBe("loaded"); + expect(retriedProject.metadata.layerLoading.lazyLoadFailures).toEqual([]); + }); + + it("requires the socioeconomic extent", async () => { + const failedFetch = jest.fn(async () => { + throw new Error("offline"); + }); + await expect( + buildGeoLibreProject({ + ...location, + fetchFeatureCollection: failedFetch, + }) + ).rejects.toThrow(/socio-economic profile.*offline/i); + }); +}); diff --git a/src/components/landing_navbar.jsx b/src/components/landing_navbar.jsx index 3765fc29..23769a1d 100644 --- a/src/components/landing_navbar.jsx +++ b/src/components/landing_navbar.jsx @@ -40,17 +40,44 @@ const LandingNavbar = () => { {isDownloadPage && ( - - - QGIS Documentation - - - + + + + + GeoLibre User Guide + + + + + + QGIS Documentation + + + + + + To Use these layers with QGIS, download layer styles from the{" "} + + CoRE Stack QGIS Styles repository + + . + + )} {isHomePage && ( @@ -111,4 +138,4 @@ const LandingNavbar = () => { ); }; -export default LandingNavbar; \ No newline at end of file +export default LandingNavbar; diff --git a/src/components/landing_navbar.test.jsx b/src/components/landing_navbar.test.jsx new file mode 100644 index 00000000..95592850 --- /dev/null +++ b/src/components/landing_navbar.test.jsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import LandingNavbar from "./landing_navbar"; + +jest.mock( + "react-router-dom", + () => ({ useLocation: () => ({ pathname: "/download_layers" }) }), + { virtual: true } +); + +describe("Download Layers navigation", () => { + it("links to GeoLibre, QGIS, and the QML style fallback", () => { + render(); + + expect( + screen.getByRole("link", { name: /GeoLibre User Guide/i }) + .getAttribute("href") + ).toBe("https://geolibre.app/user-guide/interface/"); + expect( + screen.getByRole("link", { name: /QGIS Documentation/i }) + .getAttribute("href") + ).toContain("docs.google.com/document"); + expect( + screen.getByRole("link", { name: /CoRE Stack QGIS Styles repository/i }) + .getAttribute("href") + ).toBe("https://github.com/core-stack-org/QGIS-Styles"); + expect(document.body.textContent).toContain( + "To Use these layers with QGIS, download layer styles from the CoRE Stack QGIS Styles repository." + ); + }); +}); diff --git a/src/config/geolibre.config.js b/src/config/geolibre.config.js new file mode 100644 index 00000000..2ffeba52 --- /dev/null +++ b/src/config/geolibre.config.js @@ -0,0 +1,89 @@ +const DEFAULT_VIEWER_URL = "https://web.geolibre.app/"; + +/** + * The preferred GeoLibre application version for a versioned deployment. + * + * The public web.geolibre.app URL is unversioned, so this value does not select + * what that server returns. By default KYL accepts compatible 2.x viewers and + * 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", + supportedMajorVersion: 2, + viewerUrlTemplate: + process.env.REACT_APP_GEOLIBRE_URL_TEMPLATE || + process.env.REACT_APP_GEOLIBRE_URL || + DEFAULT_VIEWER_URL, + strictVersion: + process.env.REACT_APP_GEOLIBRE_STRICT_VERSION === "true", +}); + +// GeoLibre's project schema version is independent from its application release. +export const GEOLIBRE_PROJECT_FORMAT_VERSION = "0.2.0"; + +const parseVersion = (value) => { + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec( + String(value || "").trim() + ); + return match ? match.slice(1).map(Number) : null; +}; + +const compareVersions = (left, right) => { + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +}; + +export const resolveGeoLibreViewer = (config = GEOLIBRE_CONFIG) => { + const version = String(config.version).replace(/^v/, ""); + const versionPinned = config.viewerUrlTemplate.includes("{version}"); + const resolvedTemplate = config.viewerUrlTemplate.replaceAll( + "{version}", + version + ); + const url = new URL(resolvedTemplate); + url.searchParams.set("embed", "1"); + url.searchParams.set("welcome", "0"); + return { url: url.toString(), origin: url.origin, versionPinned }; +}; + +export const geoLibreVersionStatus = ( + actualVersion, + config = GEOLIBRE_CONFIG +) => { + const actual = parseVersion(actualVersion); + const expected = parseVersion(config.version); + const minimum = parseVersion(config.minimumCompatibleVersion); + + if (!actual || !expected || !minimum) { + return { + compatible: false, + message: `GeoLibre reported an invalid version (${actualVersion || "missing"}).`, + }; + } + + const supportedMajorVersion = Number( + config.supportedMajorVersion ?? minimum[0] + ); + if ( + actual[0] !== supportedMajorVersion || + expected[0] !== supportedMajorVersion || + compareVersions(actual, minimum) < 0 + ) { + return { + compatible: false, + message: `GeoLibre ${actualVersion} is not compatible with this KYL integration (requires ${config.minimumCompatibleVersion} or newer in major version ${supportedMajorVersion}).`, + }; + } + + if (config.strictVersion && compareVersions(actual, expected) !== 0) { + return { + compatible: false, + message: `KYL is configured for GeoLibre ${config.version}, but the iframe loaded ${actualVersion}. Update the configured version only after compatibility testing.`, + }; + } + + return { compatible: true, message: "" }; +}; diff --git a/src/config/geolibre.config.test.js b/src/config/geolibre.config.test.js new file mode 100644 index 00000000..e0667c6d --- /dev/null +++ b/src/config/geolibre.config.test.js @@ -0,0 +1,63 @@ +import { + GEOLIBRE_CONFIG, + geoLibreVersionStatus, + resolveGeoLibreViewer, +} from "./geolibre.config"; + +const config = { + version: "2.2.0", + minimumCompatibleVersion: "2.0.0", + supportedMajorVersion: 2, + viewerUrlTemplate: "https://viewer.example/geolibre/{version}/", + strictVersion: true, +}; + +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", + origin: "https://viewer.example", + versionPinned: true, + }); + }); + + it("marks the official unversioned deployment as rolling", () => { + expect(resolveGeoLibreViewer(GEOLIBRE_CONFIG).versionPinned).toBe(false); + }); + + it("accepts the configured v2.2 viewer", () => { + expect(geoLibreVersionStatus("2.2.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( + true + ); + }); + + it("rejects unexpected, older, and major-version viewers", () => { + expect(geoLibreVersionStatus("2.3.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 }) + .compatible + ).toBe(true); + }); + + it("does not treat a version-only major upgrade as compatible", () => { + expect( + geoLibreVersionStatus("3.0.0", { ...config, version: "3.0.0" }) + .compatible + ).toBe(false); + }); +}); diff --git a/src/config/geolibreLayers.js b/src/config/geolibreLayers.js new file mode 100644 index 00000000..6d9ed637 --- /dev/null +++ b/src/config/geolibreLayers.js @@ -0,0 +1,332 @@ +const QML_RAW_BASE = + "https://raw.githubusercontent.com/core-stack-org/QGIS-Styles/main"; + +const qmlStyle = (path) => `${QML_RAW_BASE}/${path}`; + +export const GEOLIBRE_LULC_YEARS = [ + { label: "2017-2018", value: "17_18" }, + { label: "2018-2019", value: "18_19" }, + { label: "2019-2020", value: "19_20" }, + { label: "2020-2021", value: "20_21" }, + { label: "2021-2022", value: "21_22" }, + { label: "2022-2023", value: "22_23" }, + { label: "2023-2024", value: "23_24" }, + { label: "2024-2025", value: "24_25" }, +]; + +export const LATEST_GEOLIBRE_LULC_YEAR = + GEOLIBRE_LULC_YEARS[GEOLIBRE_LULC_YEARS.length - 1].value; + +const LAYERS = [ + { + id: "administrative_boundaries", + label: "Administrative Boundaries", + domain: "Demographic", + loadGroup: "demographic", + defaultVisible: true, + sourceType: "wfs", + workspace: "panchayat_boundaries", + geometryType: "polygon", + layerName: ({ district, tehsil }) => `${district}_${tehsil}`, + styleProfile: "boundary", + qmlStyleUrl: qmlStyle("Demographic/Administrative-Boundary-Style.qml"), + }, + { + id: "demographics", + label: "Socio-Economic Profile", + domain: "Demographic", + loadGroup: "demographic", + defaultVisible: true, + sourceType: "wfs", + workspace: "panchayat_boundaries", + geometryType: "polygon", + layerName: ({ district, tehsil }) => `${district}_${tehsil}`, + styleProfile: "demographics", + qmlStyleUrl: qmlStyle("Demographic/literary_rate_style.qml"), + }, + { + id: "mws_layers", + label: "Micro-watersheds and Hydrological Variables", + domain: "Hydrology", + loadGroup: "hydrology", + sourceType: "wfs", + workspace: "mws_layers", + geometryType: "polygon", + layerName: ({ district, tehsil }) => + `deltaG_well_depth_${district}_${tehsil}`, + styleProfile: "mws", + qmlStyleUrl: qmlStyle("Climate/MWS-Well-Depth-18_23.qml"), + }, + { + id: "hydrological_boundaries", + label: "Hydrological Boundaries", + domain: "Hydrology", + loadGroup: "hydrology", + sourceType: "wfs", + workspace: "mws_layers", + geometryType: "polygon", + layerName: ({ district, tehsil }) => + `deltaG_well_depth_${district}_${tehsil}`, + styleProfile: "boundary", + qmlStyleUrl: qmlStyle("Climate/MWS-Well-Depth-18_23.qml"), + }, + { + id: "mws_layers_fortnight", + label: "Fortnightly Hydrological Variables", + domain: "Hydrology", + loadGroup: "hydrology", + sourceType: "wfs", + workspace: "mws_layers", + geometryType: "polygon", + layerName: ({ district, tehsil }) => + `deltaG_fortnight_${district}_${tehsil}`, + styleProfile: "boundary", + qmlStyleUrl: qmlStyle("Hydrology/water_balance_fortnightly.qml"), + }, + { + id: "terrain_vector", + label: "Terrain Vector", + domain: "Land", + loadGroup: "land", + sourceType: "wfs", + workspace: "terrain", + geometryType: "polygon", + layerName: ({ district, tehsil }) => `${district}_${tehsil}_cluster`, + styleProfile: "terrain_vector", + qmlStyleUrl: qmlStyle("Land/Terrain-Vector-Layer-Style.qml"), + }, + { + id: "drainage", + label: "Drainage", + domain: "Hydrology", + loadGroup: "hydrology", + sourceType: "wfs", + workspace: "drainage", + geometryType: "line", + layerName: ({ district, tehsil }) => `${district}_${tehsil}`, + styleProfile: "drainage", + qmlStyleUrl: qmlStyle("Hydrology/Drainage-Layer-Style.qml"), + }, + { + id: "remote_sensed_waterbodies", + label: "Remote-Sensed Waterbodies", + domain: "Hydrology", + loadGroup: "hydrology", + sourceType: "wfs", + workspace: "swb", + geometryType: "polygon", + layerName: ({ district, tehsil }) => + `surface_waterbodies_${district}_${tehsil}`, + styleProfile: "waterbodies", + qmlStyleUrl: qmlStyle("Hydrology/Surface-Waterbody-style.qml"), + }, + { + id: "soge", + label: "Stage of Groundwater Extraction", + domain: "Hydrology", + loadGroup: "hydrology", + sourceType: "wfs", + workspace: "soge", + geometryType: "polygon", + layerName: ({ district, tehsil }) => `soge_vector_${district}_${tehsil}`, + styleProfile: "soge", + qmlStyleUrl: qmlStyle("Hydrology/SOGE_style.qml"), + }, + { + id: "aquifer", + label: "Aquifer", + domain: "Hydrology", + loadGroup: "hydrology", + sourceType: "wfs", + workspace: "aquifer", + geometryType: "polygon", + layerName: ({ district, tehsil }) => + `aquifer_vector_${district}_${tehsil}`, + styleProfile: "aquifer", + qmlStyleUrl: qmlStyle("Hydrology/Aquifer_style.qml"), + }, + { + id: "cropping_intensity", + label: "Cropping Intensity", + domain: "Agriculture", + loadGroup: "agriculture", + sourceType: "wfs", + workspace: "crop_intensity", + geometryType: "polygon", + layerName: ({ district, tehsil }) => `${district}_${tehsil}_intensity`, + styleProfile: "cropping_intensity", + qmlStyleUrl: qmlStyle("Agriculture/Cropping_intensity.qml"), + }, + { + id: "drought", + label: "Drought", + domain: "Agriculture", + loadGroup: "agriculture", + sourceType: "wfs", + workspace: "drought", + geometryType: "polygon", + layerName: ({ district, tehsil }) => `${district}_${tehsil}_drought`, + styleProfile: "drought", + qmlStyleUrl: qmlStyle("Agriculture/Drought_style.qml"), + }, + { + id: "nrega", + label: "NREGA Assets", + domain: "NREGA", + loadGroup: "nrega", + sourceType: "wfs", + workspace: "nrega_assets", + geometryType: "point", + layerName: ({ district, tehsil }) => `${district}_${tehsil}`, + styleProfile: "nrega", + qmlStyleUrl: qmlStyle("NREGA/NREG-Assets-Classified-Style.qml"), + }, + { + id: "terrain", + label: "Terrain", + domain: "Land", + loadGroup: "land", + sourceType: "wms", + workspace: "terrain", + layerName: ({ district, tehsil }) => `${district}_${tehsil}_terrain_raster`, + wmsStyle: "terrain:terrain_raster", + qmlStyleUrl: qmlStyle("Land/terrain_1-12class.qml"), + }, + { + id: "clart", + label: "CLART", + domain: "Hydrology", + loadGroup: "hydrology", + sourceType: "wms", + workspace: "clart", + layerName: ({ district, tehsil }) => `${district}_${tehsil}_clart`, + wmsStyle: "clart:testClart", + qmlStyleUrl: qmlStyle("Hydrology/CLART-Layer-Style.qml"), + }, + { + id: "afforestation", + label: "Change Detection: Afforestation", + domain: "Restoration", + loadGroup: "restoration", + sourceType: "wms", + workspace: "change_detection", + layerName: ({ district, tehsil }) => + `change_${district}_${tehsil}_Afforestation`, + wmsStyle: "change_detection:afforestation", + qmlStyleUrl: qmlStyle("Land/change_tree_cover_gain.qml"), + }, + { + id: "deforestation", + label: "Change Detection: Deforestation", + domain: "Restoration", + loadGroup: "restoration", + sourceType: "wms", + workspace: "change_detection", + layerName: ({ district, tehsil }) => + `change_${district}_${tehsil}_Deforestation`, + wmsStyle: "change_detection:deforestation", + qmlStyleUrl: qmlStyle("Land/change_tree_cover_loss.qml"), + }, + { + id: "degradation", + label: "Change Detection: Degradation", + domain: "Restoration", + loadGroup: "restoration", + sourceType: "wms", + workspace: "change_detection", + layerName: ({ district, tehsil }) => + `change_${district}_${tehsil}_Degradation`, + wmsStyle: "change_detection:degradation", + qmlStyleUrl: qmlStyle("Land/change_cropping_reduction.qml"), + }, + { + id: "urbanization", + label: "Change Detection: Urbanization", + domain: "Restoration", + loadGroup: "restoration", + sourceType: "wms", + workspace: "change_detection", + layerName: ({ district, tehsil }) => + `change_${district}_${tehsil}_Urbanization`, + wmsStyle: "change_detection:urbanization", + qmlStyleUrl: qmlStyle("Land/change_urbanization.qml"), + }, + { + id: "cropintensity", + label: "Change Detection: Crop Intensity", + domain: "Restoration", + loadGroup: "restoration", + sourceType: "wms", + workspace: "change_detection", + layerName: ({ district, tehsil }) => + `change_${district}_${tehsil}_CropIntensity`, + wmsStyle: "change_detection:cropintensity", + qmlStyleUrl: qmlStyle("Land/change_cropping_intensity.qml"), + }, + { + id: "restoration", + label: "Restoration Opportunities", + domain: "Restoration", + loadGroup: "restoration", + sourceType: "wms", + workspace: "restoration", + layerName: ({ district, tehsil }) => + `restoration_${district}_${tehsil}_raster`, + wmsStyle: "restoration:restoration_style", + qmlStyleUrl: qmlStyle("Restoration/Restoration_style.qml"), + }, +]; + +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"), + }, + { + 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"), + }, + { + 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"), + }, +]; + +export const GEOLIBRE_LULC_LAYERS = LULC_LEVELS.flatMap((level, index) => + GEOLIBRE_LULC_YEARS.map((year) => ({ + ...level, + id: `${level.id}_${year.value}`, + baseId: level.id, + label: `${level.label} · ${year.label}`, + loadGroup: `lulc-${index + 1}`, + sourceType: "wms", + year: year.value, + layerName: ({ district, tehsil }) => + `LULC_${year.value}_${district}_${tehsil}_level_${index + 1}`, + })) +); + +export const GEOLIBRE_LAYERS = Object.freeze([ + ...LAYERS, + ...GEOLIBRE_LULC_LAYERS, +]); + +export const GEOLIBRE_VECTOR_LAYERS = GEOLIBRE_LAYERS.filter( + (layer) => layer.sourceType === "wfs" +); + +export const GEOLIBRE_RASTER_LAYERS = GEOLIBRE_LAYERS.filter( + (layer) => layer.sourceType === "wms" +); diff --git a/src/pages/LE_homepage.jsx b/src/pages/LE_homepage.jsx index 2cae1c19..bd79fa99 100644 --- a/src/pages/LE_homepage.jsx +++ b/src/pages/LE_homepage.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useEffect } from "react"; import { useNavigate } from "react-router"; import { useRecoilState } from "recoil"; import { @@ -38,7 +38,7 @@ export default function KYLHomePage() { fetchStates(); setBlock(null); - }, []); + }, [setBlock, setStatesData]); const handleItemSelect = (setter, value) => { if (setter === setState) { @@ -62,7 +62,16 @@ export default function KYLHomePage() { } else{ trackEvent("Navigation", "button_click", buttonName); - navigate(path); + if (path === "/download_layers" && state && district && block) { + const params = new URLSearchParams({ + state: state.label, + district: district.label, + tehsil: block.label, + }); + navigate(`${path}?${params.toString()}`); + } else { + navigate(path); + } } }; @@ -160,8 +169,10 @@ export default function KYLHomePage() { Know Your Landscape handleNavigate("/download_layers", "Download Layers")} + disabled={!state || !district || !block} + title={!block ? "Select a state, district, and tehsil first" : undefined} > Download Layers @@ -393,4 +404,4 @@ export default function KYLHomePage() { ); -} \ No newline at end of file +} diff --git a/src/pages/LandscapeExplorer.jsx b/src/pages/LandscapeExplorer.jsx index 46756a12..83c6e31c 100644 --- a/src/pages/LandscapeExplorer.jsx +++ b/src/pages/LandscapeExplorer.jsx @@ -1,457 +1,227 @@ -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 { + 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); - - // 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); - - // Map ref for accessing map instance from other components - const mapRef = useRef(null); - - // Add flag to prevent infinite recursion - const isUpdatingFromMap = useRef(false); - - // Track which resource category is active - const [activeResourceCategory, setActiveResourceCategory] = useState(null); - - // Set map ref with callback - const setMapRef = useCallback((node) => { - if (node !== null) { - mapRef.current = node; - } - }, []); - - // 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 = ""; +const labelOf = (selection) => selection?.label || ""; - 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); +const scopeKeyOf = (project) => { + const scope = project?.metadata?.scope; + return scope ? [scope.state, scope.district, scope.tehsil].join("|") : ""; +}; - // 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", +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, }, - } - ) - .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); - }) - .catch((error) => { - console.error("Error downloading Excel:", error); - setIsLoading(false); - alert("Failed to download Excel data. Please try again."); - }); - }; +const LandscapeExplorer = () => { + const selectedState = useRecoilValue(stateAtom); + const selectedDistrict = useRecoilValue(districtAtom); + const selectedTehsil = useRecoilValue(blockAtom); + const routeLocation = useLocation(); + const [project, setProject] = useState(null); + 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 category selection for resource layers - const handleCategoryChange = (category) => { - setActiveResourceCategory(category); - }; + useEffect(() => { + currentScopeKeyRef.current = scopeKey; + lazyStateSequenceRef.current += 1; + lazyQueueRef.current = Promise.resolve(); + hydratedLayersRef.current = new Map(); + hydrationDirtyRef.current = false; + }, [scopeKey]); - // Fetch states data on component mount useEffect(() => { initializeAnalytics(); trackPageView("/download_layers"); - if (statesData === null) { - getStates().then((data) => setStatesData(data)); - } - }, [statesData, setStatesData]); + }, []); - // Handle map-initiated layer toggle updates - const handleMapToggle = (layerName, isVisible) => { - // Set the recursion prevention flag - isUpdatingFromMap.current = true; + 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); + setProgress("Overview is ready. Toggle another layer to load it."); + trackEvent("GeoLibre", "open_workspace", scope.tehsil); + }) + .catch((buildError) => { + if (controller.signal.aborted) return; + setError( + buildError instanceof Error + ? buildError.message + : "The tehsil project could not be generated." + ); + }); + + return () => controller.abort(); + }, [hasLocation, retryKey, scope]); + + const handleProjectState = useCallback((viewerProject) => { + const viewerScopeKey = scopeKeyOf(viewerProject); + 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 = syncGeoLibreActiveLegends(mergedProject); + const legendChanged = 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 && + !legendChanged) + ) { return; } - } - // Update the toggledLayers state - setToggledLayers((prev) => ({ - ...prev, - [layerName]: isVisible, - })); - } finally { - // Reset the flag - isUpdatingFromMap.current = false; - } - }; + 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 && ( - - setShowRightSidebar(true)} - className="flex items-center" - aria-label="Open filters panel" + + + + Select a tehsil first + + + GeoLibre projects are generated for a selected state, district, + and tehsil. + + - - Filters & Data - - - - - + 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)} + /> ); };
+ {userIssue.message} +
+ {preparationMessage || + `Starting GeoLibre ${GEOLIBRE_CONFIG.version}…`} +
+ To Use these layers with QGIS, download layer styles from the{" "} + + CoRE Stack QGIS Styles repository + + . +
+ GeoLibre projects are generated for a selected state, district, + and tehsil. +