Skip to content
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ REACT_APP_GEOSERVER_URL=https://geoserver.core-stack.org:8443/geoserver/
# No GeoLibre variables are required for the official rolling deployment.
# Its tested fallback version and URL live in src/config/geolibre.config.js.
# Uncomment these only to override that source configuration at build time:
# REACT_APP_GEOLIBRE_VERSION=2.2.0
# REACT_APP_GEOLIBRE_VERSION=2.6.0
# REACT_APP_GEOLIBRE_URL=https://web.geolibre.app/
# REACT_APP_GEOLIBRE_STRICT_VERSION=false
# Optional MapLibre style URL. The default uses the same Google Satellite
Expand Down
26 changes: 26 additions & 0 deletions src/App.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import fs from "fs";
import path from "path";

describe("application routes", () => {
it("keeps /download_layers wired to the GeoLibre implementation", () => {
const appSource = fs.readFileSync(path.join(__dirname, "App.jsx"), "utf8");
const downloadPageSource = fs.readFileSync(
path.join(__dirname, "pages/LandscapeExplorer.jsx"),
"utf8"
);

expect(appSource).toContain(
'<Route path="/download_layers" element={<LandscapeExplorer />} />'
);
expect(downloadPageSource).toContain(
'import GeoLibreFrame from "../components/geolibre/GeoLibreFrame";'
);
expect(downloadPageSource).toContain("<GeoLibreFrame");
expect(downloadPageSource).not.toContain(
'components/landscape-explorer/map/Map.jsx'
);
expect(downloadPageSource).not.toContain(
'components/landscape-explorer/sidebar/RightSidebar.jsx'
);
});
});
43 changes: 38 additions & 5 deletions src/components/geolibre/GeoLibreFrame.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
geoLibreVersionStatus,
resolveGeoLibreViewer,
} from "../../config/geolibre.config";
import GeoLibreLegend from "./GeoLibreLegend";

const MAX_TECHNICAL_LOG_ENTRIES = 40;

Expand Down Expand Up @@ -35,18 +36,45 @@ export const formatGeoLibreLog = (entries) =>
),
].join("\n");

// Visibility and opacity are live viewer state. They must not cause a full
// project replacement, because GeoLibre would tear down and recreate native
// raster sources that are already resident in the map. Keep only fields that
// describe project structure, data, or styling in the load signature.
export const geoLibreProjectLoadSignature = (project) =>
project
? JSON.stringify({
version: project.version,
name: project.name,
scope: project.metadata?.scope,
bbox: project.mapView?.bbox,
layers: (project.layers || []).map((layer) => ({
id: layer.id,
name: layer.name,
type: layer.type,
source: layer.source,
sourcePath: layer.sourcePath,
groupId: layer.groupId,
style: layer.style,
loadState: layer.metadata?.loadState,
featureCount:
layer.metadata?.featureCount ?? layer.geojson?.features?.length,
})),
})
: "";

const GeoLibreFrame = ({
project,
preparationMessage,
preparationError,
warning,
legends,
onRetry,
onProjectState,
}) => {
const frameRef = useRef(null);
const fitTimerRef = useRef(null);
const fittedScopeRef = useRef("");
const sentProjectRef = useRef(null);
const sentProjectSignatureRef = useRef("");
const sequenceRef = useRef(0);
const technicalLogRef = useRef([]);
const [viewerState, setViewerState] = useState("loading");
Expand Down Expand Up @@ -138,7 +166,7 @@ const GeoLibreFrame = ({
actualVersion: event.data.version,
});
setViewerVersion(String(event.data.version));
sentProjectRef.current = null;
sentProjectSignatureRef.current = "";
setViewerIssue("");
setViewerState("ready");
setReadyGeneration((generation) => generation + 1);
Expand Down Expand Up @@ -181,11 +209,12 @@ const GeoLibreFrame = ({

useEffect(() => {
const target = frameRef.current?.contentWindow;
const projectSignature = geoLibreProjectLoadSignature(project);
if (
!target ||
!project ||
!["ready", "loaded"].includes(viewerState) ||
sentProjectRef.current === project
sentProjectSignatureRef.current === projectSignature
) {
return;
}
Expand All @@ -205,7 +234,7 @@ const GeoLibreFrame = ({
projectName: project.name,
layerCount: project.layers?.length || 0,
});
sentProjectRef.current = project;
sentProjectSignatureRef.current = projectSignature;
const bounds = project.mapView?.bbox;
const scope = project.metadata?.scope;
const scopeKey = scope
Expand Down Expand Up @@ -283,9 +312,13 @@ const GeoLibreFrame = ({
/>
)}

{!userIssue && viewerState === "loaded" && (
<GeoLibreLegend legends={legends} />
)}

{!userIssue && viewerVersion && (
<div
className="pointer-events-none absolute bottom-8 right-3 rounded-md border border-slate-300 bg-white/95 px-2 py-1 text-[11px] font-medium text-slate-700 shadow-sm"
className="pointer-events-none absolute bottom-3 left-3 rounded-md border border-slate-300 bg-white/95 px-2 py-1 text-[11px] font-medium text-slate-700 shadow-sm"
title={`Loaded from ${viewer.url}`}
role="status"
>
Expand Down
56 changes: 50 additions & 6 deletions src/components/geolibre/GeoLibreFrame.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(<GeoLibreFrame project={project} />);
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({
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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(<GeoLibreFrame project={rasterProject} />);
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(
<GeoLibreFrame
project={{
...rasterProject,
layers: rasterProject.layers.map((layer) => ({
...layer,
visible: false,
})),
}}
/>
);

expect(loadCount()).toBe(1);
});

it("forwards viewer state snapshots for toggle-triggered loading", () => {
const onProjectState = jest.fn();
render(
Expand Down
87 changes: 87 additions & 0 deletions src/components/geolibre/GeoLibreLegend.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<aside className="absolute bottom-10 right-3 z-20 w-72 max-w-[calc(100%-1.5rem)] rounded-lg border border-slate-200 bg-white/95 text-slate-800 shadow-xl backdrop-blur-sm md:right-[22rem]">
<button
type="button"
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm font-semibold"
aria-expanded={!collapsed}
onClick={() => setCollapsed((value) => !value)}
>
<span>Legend</span>
<span aria-hidden="true">{collapsed ? "+" : "−"}</span>
</button>

{!collapsed && (
<div className="border-t border-slate-200 px-3 pb-3 pt-2">
{legends.length > 1 ? (
<label className="block">
<span className="sr-only">Visible layer legend</span>
<select
className="mb-3 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm"
value={selected.title}
onChange={(event) => setSelectedTitle(event.target.value)}
>
{legends.map((legend) => (
<option key={legend.title} value={legend.title}>
{legend.title}
</option>
))}
</select>
</label>
) : (
<p className="mb-3 text-sm font-medium">{selected.title}</p>
)}

<ul className="max-h-64 space-y-2 overflow-y-auto text-xs">
{selected.items.map((item) => (
<li key={`${item.label}-${item.color}`} className="flex items-center gap-2">
<span
className={
item.shape === "circle"
? "h-3 w-3 shrink-0 rounded-full"
: item.shape === "line"
? "h-0.5 w-4 shrink-0"
: "h-3 w-3 shrink-0 rounded-sm"
}
style={{
backgroundColor: item.color,
border: item.strokeColor
? `1px solid ${item.strokeColor}`
: undefined,
}}
aria-hidden="true"
/>
<span>{item.label}</span>
</li>
))}
</ul>
</div>
)}
</aside>
);
};

export default GeoLibreLegend;
38 changes: 38 additions & 0 deletions src/components/geolibre/GeoLibreLegend.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<GeoLibreLegend legends={[level1]} />);

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(<GeoLibreLegend legends={[level1, level2]} />);

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();
});
});
Loading