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 && ( +