From d251293b8734fa534b7f14660b227ea15e781479 Mon Sep 17 00:00:00 2001 From: apple Date: Sun, 22 Mar 2026 23:59:33 +0530 Subject: [PATCH 1/2] fix: use boolean setActive for drainage getVectorLayers calls Made-with: Cursor --- src/components/dashboard_basemap.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/dashboard_basemap.jsx b/src/components/dashboard_basemap.jsx index de9bc77a..86646db0 100644 --- a/src/components/dashboard_basemap.jsx +++ b/src/components/dashboard_basemap.jsx @@ -1188,7 +1188,7 @@ const [terrainLayer, drainageLayer] = await Promise.all([ getImageLayer("terrain", terrainKey, true, "Terrain_Style_11_Classes").catch(() => null), - getVectorLayers("drainage", drainageKey, true, "drainage").catch(() => null), + getVectorLayers("drainage", drainageKey, true, true).catch(() => null), ]); if (terrainLayer) { @@ -1332,7 +1332,7 @@ if (isTehsil) { const [terrainLayer, drainageLayer] = await Promise.all([ getImageLayer("terrain", terrainKey, true, "Terrain_Style_11_Classes").catch(() => null), - getVectorLayers("drainage", drainageKey, true, "drainage").catch(() => null), + getVectorLayers("drainage", drainageKey, true, true).catch(() => null), ]); // Clip terrain to MultiPolygon From b5e65cb7ee9db6e8404afc7021a25cacbe8945c6 Mon Sep 17 00:00:00 2001 From: apple Date: Tue, 24 Mar 2026 23:01:48 +0530 Subject: [PATCH 2/2] feat(landscape-explorer): shareable map URLs and copy link - Add landscapeExplorerUrlState util to encode/decode query params (state, district, block, layers, LULC years, lng/lat/zoom) - Hydrate Landscape Explorer from URL once states load; sync URL on changes - Map: moveend debounce for view, applyView/getViewSnapshot on ref - Add Copy link to this view control on /download_layers Made-with: Cursor --- src/components/landscape-explorer/map/Map.jsx | 53 +++++- src/pages/LandscapeExplorer.jsx | 154 ++++++++++++++++ src/utils/landscapeExplorerUrlState.js | 167 ++++++++++++++++++ 3 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 src/utils/landscapeExplorerUrlState.js diff --git a/src/components/landscape-explorer/map/Map.jsx b/src/components/landscape-explorer/map/Map.jsx index 8e07af5f..fcd9f04d 100644 --- a/src/components/landscape-explorer/map/Map.jsx +++ b/src/components/landscape-explorer/map/Map.jsx @@ -819,12 +819,18 @@ const Map = forwardRef(({ setShowVillages, lulcYear1, lulcYear2, - lulcYear3 + lulcYear3, + onViewChange }, ref) => { const mapElement = useRef(null); const mapRef = useRef(null); const baseLayerRef = useRef(null); const markersLayer = useRef(null); + const onViewChangeRef = useRef(onViewChange); + + useEffect(() => { + onViewChangeRef.current = onViewChange; + }, [onViewChange]); // Added flag to prevent recursion const handlingExternalToggle = useRef(false); @@ -931,6 +937,7 @@ const Map = forwardRef(({ 'degradation': 'Change Detection Degradation', 'urbanization': 'Change Detection Urbanization', 'cropIntensity': 'Change Detection Crop-Intensity', + 'cropintensity': 'Change Detection Crop-Intensity', 'restoration': 'Change Detection Restoration', 'soge': 'SOGE', 'aquifer': 'Aquifer', @@ -963,7 +970,25 @@ const Map = forwardRef(({ }); } }, - getMap: () => mapRef.current + getMap: () => mapRef.current, + getViewSnapshot: () => { + if (!mapRef.current) return null; + const view = mapRef.current.getView(); + const center = view.getCenter(); + const zoom = view.getZoom(); + if (!center || zoom == null) return null; + return { center, zoom }; + }, + applyView: ({ center, zoom }) => { + if (!mapRef.current) return; + const view = mapRef.current.getView(); + if (center && Array.isArray(center) && center.length >= 2) { + view.setCenter(center); + } + if (typeof zoom === "number" && !Number.isNaN(zoom)) { + view.setZoom(zoom); + } + } })); // Get block features (copied from original implementation) @@ -2307,6 +2332,29 @@ const Map = forwardRef(({ }; }, []); + // Report map viewport for shareable URL (debounced moveend) + useEffect(() => { + if (!isInitialized || !mapRef.current || !onViewChange) return; + const map = mapRef.current; + let timeoutId; + const handler = () => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + const view = map.getView(); + const center = view.getCenter(); + const zoom = view.getZoom(); + if (center && zoom != null) { + onViewChangeRef.current?.({ center, zoom }); + } + }, 400); + }; + map.on("moveend", handler); + return () => { + if (timeoutId) clearTimeout(timeoutId); + map.un("moveend", handler); + }; + }, [isInitialized, onViewChange]); + // When state changes, update district markers useEffect(() => { if (mapRef.current && state && !district) { @@ -2371,6 +2419,7 @@ const Map = forwardRef(({ 'degradation': 'Change Detection Degradation', 'urbanization': 'Change Detection Urbanization', 'cropintensity': 'Change Detection Crop-Intensity', + 'cropIntensity': 'Change Detection Crop-Intensity', 'restoration': 'Change Detection Restoration', 'soge': 'SOGE', 'aquifer': 'Aquifer', diff --git a/src/pages/LandscapeExplorer.jsx b/src/pages/LandscapeExplorer.jsx index 52cda349..52706fc3 100644 --- a/src/pages/LandscapeExplorer.jsx +++ b/src/pages/LandscapeExplorer.jsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef, useCallback } from "react"; +import { useSearchParams } from "react-router-dom"; import Map from "../components/landscape-explorer/map/Map.jsx"; import LeftSidebar from "../components/landscape-explorer/sidebar/LeftSidebar.jsx"; import RightSidebar from "../components/landscape-explorer/sidebar/RightSidebar.jsx"; @@ -19,8 +20,17 @@ import { initializeAnalytics, } from "../services/analytics"; import LandingNavbar from "../components/landing_navbar.jsx"; +import { + buildSearchParams, + findLocationInStatesData, + hasUrlMapParams, + lulcYearFromParam, + parseViewParams, + toggledLayersFromLayersParam, +} from "../utils/landscapeExplorerUrlState"; const LandscapeExplorer = () => { + const [, setSearchParams] = useSearchParams(); const [showLeftSidebar, setShowLeftSidebar] = useState(false); const [showRightSidebar, setShowRightSidebar] = useState(true); const [isLoading, setIsLoading] = useState(false); @@ -39,6 +49,13 @@ const LandscapeExplorer = () => { // Map ref for accessing map instance from other components const mapRef = useRef(null); + /** Apply map center/zoom from URL after block layers load (OpenLayers fits extent first). */ + const pendingViewFromUrlRef = useRef(null); + const hydratedUrlRef = useRef(false); + const [urlSyncReady, setUrlSyncReady] = useState(false); + const [mapView, setMapView] = useState(null); + const [linkCopied, setLinkCopied] = useState(false); + // Add flag to prevent infinite recursion const isUpdatingFromMap = useRef(false); @@ -343,6 +360,132 @@ const LandscapeExplorer = () => { } }, [statesData, setStatesData]); + // Hydrate location, layers, LULC, and view from URL (once when states load) + useEffect(() => { + if (!statesData || hydratedUrlRef.current) return; + const params = new URLSearchParams(window.location.search); + if (!hasUrlMapParams(params)) { + hydratedUrlRef.current = true; + setUrlSyncReady(true); + return; + } + hydratedUrlRef.current = true; + + const stateLabel = params.get("state"); + const districtLabel = params.get("district"); + const blockLabel = params.get("block"); + const found = findLocationInStatesData( + statesData, + stateLabel, + districtLabel, + blockLabel + ); + if (found?.state) setState(found.state); + if (found?.district) setDistrict(found.district); + if (found?.block) { + setBlock(found.block); + setCanFetchLayers(true); + setLayersReady(true); + } + + const mergedLayers = toggledLayersFromLayersParam(params.get("layers")); + if (mergedLayers) setToggledLayers(mergedLayers); + + const l1 = lulcYearFromParam(params.get("lulc1")); + const l2 = lulcYearFromParam(params.get("lulc2")); + const l3 = lulcYearFromParam(params.get("lulc3")); + if (l1) setLulcYear1(l1); + if (l2) setLulcYear2(l2); + if (l3) setLulcYear3(l3); + + const parsedView = parseViewParams(params); + if (parsedView) { + pendingViewFromUrlRef.current = parsedView; + setMapView({ + center: parsedView.center, + zoom: parsedView.zoom, + }); + } + setUrlSyncReady(true); + }, [statesData, setState, setDistrict, setBlock]); + + // Keep the URL in sync with app state (shareable links, refresh) + useEffect(() => { + if (!urlSyncReady) return; + const id = setTimeout(() => { + const params = buildSearchParams({ + state, + district, + block, + toggledLayers, + lulcYear1, + lulcYear2, + lulcYear3, + mapView, + }); + setSearchParams(params, { replace: true }); + }, 450); + return () => clearTimeout(id); + }, [ + urlSyncReady, + state, + district, + block, + toggledLayers, + lulcYear1, + lulcYear2, + lulcYear3, + mapView, + setSearchParams, + ]); + + // After URL-driven location is set, re-apply saved bbox view (map fits block first) + useEffect(() => { + if (!block || !pendingViewFromUrlRef.current) return; + const view = pendingViewFromUrlRef.current; + const timer = setTimeout(() => { + mapRef.current?.applyView?.(view); + pendingViewFromUrlRef.current = null; + }, 2000); + return () => clearTimeout(timer); + }, [block, state, district]); + + const handleMapViewChange = useCallback(({ center, zoom }) => { + setMapView({ center, zoom }); + }, []); + + const copyShareableLink = useCallback(() => { + const liveView = mapRef.current?.getViewSnapshot?.() || mapView; + const params = buildSearchParams({ + state, + district, + block, + toggledLayers, + lulcYear1, + lulcYear2, + lulcYear3, + mapView: liveView || undefined, + }); + const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`; + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(url).then(() => { + setLinkCopied(true); + setTimeout(() => setLinkCopied(false), 2000); + }); + } else { + window.prompt("Copy this link:", url); + } + }, [ + state, + district, + block, + toggledLayers, + lulcYear1, + lulcYear2, + lulcYear3, + mapView, + ]); + // Handle map-initiated layer toggle updates const handleMapToggle = (layerName, isVisible) => { // Set the recursion prevention flag @@ -417,7 +560,18 @@ const LandscapeExplorer = () => { lulcYear1={lulcYear1} lulcYear2={lulcYear2} lulcYear3={lulcYear3} + onViewChange={handleMapViewChange} /> + +
+ +
{showRightSidebar && ( diff --git a/src/utils/landscapeExplorerUrlState.js b/src/utils/landscapeExplorerUrlState.js new file mode 100644 index 00000000..62183ea7 --- /dev/null +++ b/src/utils/landscapeExplorerUrlState.js @@ -0,0 +1,167 @@ +/** + * Serialize / deserialize Landscape Explorer map state for shareable URLs. + * Query params: state, district, block, layers, lulc1–3, lng, lat, zoom + */ + +export const LULC_YEAR_OPTIONS = [ + { label: "None", value: null }, + { 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" }, +]; + +export function lulcYearFromParam(value) { + if (value == null || value === "" || value === "null") return null; + const found = LULC_YEAR_OPTIONS.find((o) => o.value === value); + return found ?? null; +} + +/** Base toggled layer flags (matches resetAllStates in LandscapeExplorer). */ +export function getDefaultToggledLayers() { + return { + demographics: true, + 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, + cropIntensity: false, + soge: false, + aquifer: false, + }; +} + +function normalizeLayerKey(key) { + if (key === "cropIntensity") return "cropintensity"; + return key; +} + +/** + * @param {string | null} layersParam — comma-separated layer ids + * @returns {Record | null} null if no param + */ +export function toggledLayersFromLayersParam(layersParam) { + if (layersParam == null || String(layersParam).trim() === "") return null; + const keys = String(layersParam) + .split(",") + .map((k) => normalizeLayerKey(k.trim())) + .filter(Boolean); + const next = { ...getDefaultToggledLayers() }; + Object.keys(next).forEach((k) => { + next[k] = false; + }); + keys.forEach((k) => { + next[k] = true; + if (k === "cropintensity") next.cropIntensity = true; + }); + if (!keys.length) { + next.demographics = true; + } + return next; +} + +export function findLocationInStatesData(statesData, stateLabel, districtLabel, blockLabel) { + if (!statesData || !stateLabel) return null; + const st = statesData.find((s) => s.label === stateLabel); + if (!st) return null; + const dist = + districtLabel && st.district + ? st.district.find((d) => d.label === districtLabel) + : null; + const blk = + dist && blockLabel && dist.blocks + ? dist.blocks.find((b) => b.label === blockLabel) + : null; + return { state: st, district: dist, block: blk }; +} + +export function parseViewParams(searchParams) { + const lng = parseFloat(searchParams.get("lng")); + const lat = parseFloat(searchParams.get("lat")); + const zoom = parseFloat(searchParams.get("zoom")); + if ( + Number.isFinite(lng) && + Number.isFinite(lat) && + Number.isFinite(zoom) && + lng >= -180 && + lng <= 180 && + lat >= -90 && + lat <= 90 && + zoom >= 0 && + zoom <= 30 + ) { + return { center: [lng, lat], zoom }; + } + return null; +} + +export function buildSearchParams({ + state, + district, + block, + toggledLayers, + lulcYear1, + lulcYear2, + lulcYear3, + mapView, +}) { + const p = new URLSearchParams(); + if (state?.label) p.set("state", state.label); + if (district?.label) p.set("district", district.label); + if (block?.label) p.set("block", block.label); + + const active = Object.entries(toggledLayers || {}) + .filter(([, v]) => v) + .map(([k]) => (k === "cropIntensity" ? "cropintensity" : k)); + const unique = [...new Set(active)]; + if (unique.length) p.set("layers", unique.join(",")); + + if (lulcYear1?.value) p.set("lulc1", lulcYear1.value); + if (lulcYear2?.value) p.set("lulc2", lulcYear2.value); + if (lulcYear3?.value) p.set("lulc3", lulcYear3.value); + + if ( + mapView?.center && + Array.isArray(mapView.center) && + mapView.center.length >= 2 && + typeof mapView.zoom === "number" + ) { + p.set("lng", String(mapView.center[0])); + p.set("lat", String(mapView.center[1])); + p.set("zoom", String(mapView.zoom)); + } + + return p; +} + +export function hasUrlMapParams(searchParams) { + return ( + searchParams.get("state") || + searchParams.get("district") || + searchParams.get("block") || + searchParams.get("layers") || + searchParams.get("lng") + ); +}