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