diff --git a/docs/ISSUE-marker-clustering.md b/docs/ISSUE-marker-clustering.md new file mode 100644 index 00000000..ffb8cf45 --- /dev/null +++ b/docs/ISSUE-marker-clustering.md @@ -0,0 +1,45 @@ +# Issue: Improve map marker clustering for densely located plantation sites + +## Issue Title +**Improve map marker clustering for densely located plantation sites** + +--- + +## Description + +### Problem +Many green markers (plantation sites) are stacked on top of each other on the map, making it hard to distinguish and interact with individual sites. + +- **Overlapping markers:** In dense areas (e.g. under projects like *MBRDI Biodiversity Conservation - Kolar*), multiple plantation markers overlap heavily, forming large green blobs where individual sites cannot be identified. +- **Poor usability:** Users cannot easily click or select individual markers when several sites share the same or very close coordinates. +- **Scale:** With many sites (e.g. 117 sites over 114.39 hectares), overlapping is common and worsens user experience in concentrated regions. + +### Expected behavior +- Individual plantation sites are distinguishable or accessible even in dense areas. +- Users can click or expand markers to view details for a specific site. +- At lower zoom levels, markers are grouped (clustered); when zoomed in or when a cluster is expanded, individual markers become visible and clickable. + +### Suggested solution +- **Marker clustering:** Use a clustering approach (e.g. **Leaflet MarkerCluster**, **Supercluster**, or equivalent for the current map stack) so that: + - At zoomed-out levels, nearby markers are grouped into a single cluster icon with a count. + - Clusters show the number of sites they contain. +- **Expand on zoom:** When the user zooms in, clusters break apart into smaller clusters or individual markers. +- **Spiderfy (optional):** When a cluster is clicked before zooming, consider “spiderfy” behavior so markers spread out in a circle or fan, making each marker clickable without overlapping. + +### Acceptance criteria +- [ ] Dense plantation markers are clustered when they would otherwise overlap. +- [ ] Cluster icons display the number of sites in the cluster. +- [ ] Zooming in splits clusters and reveals individual markers. +- [ ] Users can click individual markers to see site details. +- [ ] Optionally: cluster click expands markers (e.g. spiderfy) for easier selection. + +### Labels (suggested) +`enhancement`, `map`, `ux`, `marker-clustering` + +### Context +- **Screen:** Map view showing plantation sites (e.g. green circular markers with plant/tree icon) under a project (e.g. MBRDI Biodiversity Conservation - Kolar). +- **Map library:** Confirm whether the app uses Leaflet, OpenLayers, or another library and choose a clustering solution that integrates with it. + +--- + +*This issue can be copied into GitHub/GitLab as a new issue.* diff --git a/package.json b/package.json index d9bb4799..5d4acf51 100755 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "date-fns": "^4.1.0", "dotenv": "^16.4.7", "file-saver": "^2.0.5", + "lenis": "^1.3.18", "lucide-react": "^0.468.0", "mathjs": "^14.8.2", "ol": "^9.0.0", diff --git a/src/App.jsx b/src/App.jsx index e4d876e9..1eb0f951 100755 --- a/src/App.jsx +++ b/src/App.jsx @@ -14,13 +14,10 @@ function App() { } /> } /> } /> - }/> - }/> + } /> + } /> } /> } /> - - - ); diff --git a/src/components/PatternIntensityMapModal.jsx b/src/components/PatternIntensityMapModal.jsx new file mode 100644 index 00000000..931572fa --- /dev/null +++ b/src/components/PatternIntensityMapModal.jsx @@ -0,0 +1,164 @@ +import React, { useEffect, useRef, useMemo } from "react"; +import Map from "ol/Map"; +import View from "ol/View"; +import TileLayer from "ol/layer/Tile"; +import XYZ from "ol/source/XYZ"; +import { Fill, Stroke, Style } from "ol/style"; +import getVectorLayers from "../actions/getVectorLayers"; +import { getPatternCountByMws, patternIntensityColor } from "./utils/patternIntensityUtils"; + +const PatternIntensityMapModal = ({ + open, + onClose, + district, + block, + dataJson, + patternSelections, +}) => { + const mapContainerRef = useRef(null); + const mapRef = useRef(null); + const mwsLayerRef = useRef(null); + + const countByMws = useMemo(() => { + if (!dataJson || !patternSelections?.selectedMWSPatterns) return {}; + return getPatternCountByMws(dataJson, patternSelections.selectedMWSPatterns); + }, [dataJson, patternSelections?.selectedMWSPatterns]); + + const maxCount = useMemo(() => { + const values = Object.values(countByMws); + return values.length ? Math.max(...values) : 0; + }, [countByMws]); + + useEffect(() => { + if (!open || !mapContainerRef.current || !district?.label || !block?.label) return; + + const baseLayer = new TileLayer({ + source: new XYZ({ + url: "https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}", + }), + zIndex: 0, + }); + + const map = new Map({ + target: mapContainerRef.current, + view: new View({ + projection: "EPSG:4326", + center: [80, 23.5], + zoom: 8, + }), + layers: [baseLayer], + }); + mapRef.current = map; + + const layerName = `deltaG_well_depth_${district.label + .toLowerCase() + .split(" ") + .join("_")}_${block.label + .toLowerCase() + .replace(/\s*\(\s*/g, "_") + .replace(/\s*\)\s*/g, "") + .replace(/\s+/g, "_")}`; + + getVectorLayers("mws_layers", layerName, true, true).then((mwsLayer) => { + if (!mapRef.current) return; + mwsLayerRef.current = mwsLayer; + mwsLayer.setStyle((feature) => { + const uid = feature.get?.("uid") ?? feature.values_?.uid; + const count = countByMws[uid] ?? 0; + const fillColor = patternIntensityColor(count, maxCount); + return new Style({ + stroke: new Stroke({ color: "#374151", width: 1 }), + fill: new Fill({ color: fillColor }), + }); + }); + mapRef.current.addLayer(mwsLayer); + const source = mwsLayer.getSource(); + const tryFit = () => { + const ext = source.getExtent(); + if (ext && ext[0] !== Infinity && !Number.isNaN(ext[0]) && source.getFeatures().length > 0) { + mapRef.current?.getView().fit(ext, { padding: [40, 40, 80, 40], maxZoom: 14 }); + source.un("change", tryFit); + } + }; + source.on("change", tryFit); + }); + + return () => { + if (mwsLayerRef.current && mapRef.current) { + mapRef.current.removeLayer(mwsLayerRef.current); + mwsLayerRef.current = null; + } + mapRef.current?.setTarget(undefined); + mapRef.current = null; + }; + }, [open, district?.label, block?.label]); + + // Update style when countByMws/maxCount change (e.g. pattern selection changed while modal open) + useEffect(() => { + if (!open || !mwsLayerRef.current) return; + mwsLayerRef.current.setStyle((feature) => { + const uid = feature.get?.("uid") ?? feature.values_?.uid; + const count = countByMws[uid] ?? 0; + const fillColor = patternIntensityColor(count, maxCount); + return new Style({ + stroke: new Stroke({ color: "#374151", width: 1 }), + fill: new Fill({ color: fillColor }), + }); + }); + }, [open, countByMws, maxCount]); + + if (!open) return null; + + return ( +
+
+
+

+ Map View (Pattern intensity) +

+ +
+
+
+ Pattern intensity: +
+ Safe +
+ 0 +
+
+
+
+
+
+
+ High stress +
+
+
+
+ ); +}; + +export default PatternIntensityMapModal; diff --git a/src/components/buttons/toggle_button_kyl.jsx b/src/components/buttons/toggle_button_kyl.jsx index ebe8cdf5..7d840da6 100644 --- a/src/components/buttons/toggle_button_kyl.jsx +++ b/src/components/buttons/toggle_button_kyl.jsx @@ -1,12 +1,13 @@ -const ToggleButton = ({isOn, toggleSwitch}) => { +const ToggleButton = ({ isOn, toggleSwitch, disabled = false }) => { return ( -
+
Visualize
diff --git a/src/components/kyl_leftSidebar.jsx b/src/components/kyl_leftSidebar.jsx index 65cfb170..01393c35 100644 --- a/src/components/kyl_leftSidebar.jsx +++ b/src/components/kyl_leftSidebar.jsx @@ -25,7 +25,8 @@ const KYLLeftSidebar = ({ getPatternsForSubcategory, patternSelections, handlePatternSelection, - isPatternSelected + isPatternSelected, + onOpenPatternIntensityMap }) => { // State to track active tab (Patterns or Filters) const [activeTab, setActiveTab] = useState('Filters'); @@ -204,6 +205,15 @@ const KYLLeftSidebar = ({ Click to Know More About Patterns + + {/* Main Category Buttons (Agriculture, Livelihood) */}
{getAllPatternTypes && getAllPatternTypes().map((category) => ( diff --git a/src/components/kyl_rightSidebar.jsx b/src/components/kyl_rightSidebar.jsx index a3998413..671e6b50 100644 --- a/src/components/kyl_rightSidebar.jsx +++ b/src/components/kyl_rightSidebar.jsx @@ -325,7 +325,7 @@ const toggleConnectivity = () => { + {(!organization?.value || !project?.value) && ( +

+ {!organization?.value + ? "Select an organization to continue." + : "Select a project to continue."} +

+ )}
- + {process.env.NODE_ENV === "development" && organizationOptions.length === 0 && ( +

+ Dropdowns empty? Set REACT_APP_API_URL, REACT_APP_WATERBODYREJ_USERNAME and REACT_APP_WATERBODYREJ_PASSWORD in .env (same as production), then restart npm start. +

+ )}
diff --git a/src/pages/LE_homepage.jsx b/src/pages/LE_homepage.jsx index 5ed8d6eb..41fa8491 100644 --- a/src/pages/LE_homepage.jsx +++ b/src/pages/LE_homepage.jsx @@ -1,6 +1,7 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { useNavigate } from "react-router"; import { useRecoilState } from "recoil"; +import Lenis from "lenis"; import { stateDataAtom, stateAtom, @@ -23,12 +24,71 @@ import LandingNavbar from "../components/landing_navbar.jsx"; export default function KYLHomePage() { const navigate = useNavigate(); + const scrollRef = useRef(null); + const lenisRef = useRef(null); const [statesData, setStatesData] = useRecoilState(stateDataAtom); const [state, setState] = useRecoilState(stateAtom); const [district, setDistrict] = useRecoilState(districtAtom); const [block, setBlock] = useRecoilState(blockAtom); + useEffect(() => { + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + + const el = scrollRef.current; + if (!el) return; + + const content = el.firstElementChild; + if (!content) return; + + let lenis; + const init = () => { + lenis = new Lenis({ + wrapper: el, + content, + eventsTarget: el, + smoothWheel: true, + syncTouch: true, + syncTouchLerp: 0.12, + lerp: 0.18, + wheelMultiplier: 1.1, + touchMultiplier: 1.15, + autoRaf: true, + duration: 1.15, + }); + lenisRef.current = lenis; + }; + + requestAnimationFrame(() => requestAnimationFrame(init)); + + return () => { + lenisRef.current = null; + if (lenis) lenis.destroy(); + }; + }, []); + + const scrollToBottom = () => { + if (lenisRef.current) { + const limit = lenisRef.current.limit; + lenisRef.current.scrollTo(limit?.max ?? 1e6, { duration: 1.2 }); + } else if (scrollRef.current) { + const el = scrollRef.current; + el.scrollTo({ top: el.scrollHeight - el.clientHeight, behavior: "smooth" }); + } + }; + + const scrollToSection = (sectionId) => { + const selector = `#${sectionId}`; + if (lenisRef.current) { + lenisRef.current.scrollTo(selector, { duration: 1.1, offset: -80 }); + } else { + const el = document.querySelector(selector); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "start" }); + } + } + }; + useEffect(() => { initializeAnalytics(); trackPageView("/kyl_home"); @@ -68,18 +128,58 @@ export default function KYLHomePage() { }; return ( -
- -
+
+ +
+ + {/* In-page navigation for smooth scrolling between sections */} +
+
+ + + +
+
+ + + +
+
{/* Know Section */}
@@ -204,7 +304,7 @@ export default function KYLHomePage() {
{/* Plan Section */} -
+

@@ -329,7 +429,10 @@ export default function KYLHomePage() {

{/* Track Section */} -
+
{/* Narrow text container */}
@@ -392,46 +495,68 @@ export default function KYLHomePage() { icon: "☀️", }, ].map((item, index) => ( -
-
{ - if (item.link) { - handleNavigate(item.link, item.title); - } - }} - className="cursor-pointer bg-white rounded-2xl shadow-md p-6 sm:p-8 h-full min-h-[220px] flex flex-col justify-start transform transition-all duration-300 hover:shadow-xl hover:-translate-y-1 hover:scale-[1.02]" - > + ))}
-
- -
+
+ +
+
+
); } diff --git a/src/pages/LandscapeExplorer.jsx b/src/pages/LandscapeExplorer.jsx index 52cda349..dfbdfeff 100644 --- a/src/pages/LandscapeExplorer.jsx +++ b/src/pages/LandscapeExplorer.jsx @@ -19,6 +19,8 @@ import { initializeAnalytics, } from "../services/analytics"; import LandingNavbar from "../components/landing_navbar.jsx"; +import LoadingSpinner from "../components/ui/LoadingSpinner.jsx"; +import toast from "react-hot-toast"; const LandscapeExplorer = () => { const [showLeftSidebar, setShowLeftSidebar] = useState(false); @@ -201,7 +203,7 @@ const LandscapeExplorer = () => { // Handle GeoJSON download const handleGeoJsonLayers = (layerName) => { if (!district || !block) { - alert("Please select a district and block first"); + toast.error("Please select a district and block first."); return; } @@ -246,7 +248,7 @@ const LandscapeExplorer = () => { // Handle KML download const handleKMLLayers = (layerName) => { if (!district || !block) { - alert("Please select a district and block first"); + toast.error("Please select a district and block first."); return; } @@ -291,7 +293,7 @@ const LandscapeExplorer = () => { // Handle Excel download const handleExcelDownload = () => { if (!district || !block) { - alert("Please select a district and block first"); + toast.error("Please select a district and block first."); return; } @@ -321,11 +323,12 @@ const LandscapeExplorer = () => { link.remove(); URL.revokeObjectURL(url); setIsLoading(false); + toast.success("Excel data downloaded successfully."); }) .catch((error) => { console.error("Error downloading Excel:", error); setIsLoading(false); - alert("Failed to download Excel data. Please try again."); + toast.error("Failed to download Excel data. Please try again."); }); }; @@ -379,6 +382,16 @@ const LandscapeExplorer = () => { )}
+ {isLoading && ( +
+
+ +

+ Preparing data layers… +

+
+
+ )} {!showLeftSidebar && (
-
diff --git a/src/pages/kyl_dashboard.jsx b/src/pages/kyl_dashboard.jsx index 3fe11056..09b657b1 100644 --- a/src/pages/kyl_dashboard.jsx +++ b/src/pages/kyl_dashboard.jsx @@ -32,6 +32,7 @@ import PatternsData from '../components/data/Patterns.json'; import KYLLeftSidebar from "../components/kyl_leftSidebar"; import KYLRightSidebar from "../components/kyl_rightSidebar.jsx"; import KYLMapContainer from "../components/kyl_mapContainer.jsx"; +import PatternIntensityMapModal from "../components/PatternIntensityMapModal"; import layerStyle from "../components/utils/layerStyle.jsx"; import { getAllPatternTypes, getSubcategoriesForCategory, getPatternsForSubcategory } from '../components/utils/patternsHelper.js'; import { handlePatternSelection as handlePatternSelectionLogic, isPatternSelected } from '../components/utils/patternSelectionLogic.js'; @@ -87,11 +88,12 @@ const KYLDashboardPage = () => { const [indicatorType, setIndicatorType] = useState(null); const [showMWS, setShowMWS] = useState(true); const [showVillages, setShowVillages] = useState(true); - const [filtersEnabled, setFiltersEnabled] = useState(false); + const [filtersEnabled, setFiltersEnabled] = useState(true); const [toastId, setToastId] = useState(null); const [selectedMWSProfile, setSelectedMWSProfile] = useState(null); const [searchLatLong, setSearchLatLong] = useState(null); + const [showPatternIntensityModal, setShowPatternIntensityModal] = useState(false); // * Triggers const [filterTrigger, setFilterTrigger] = useState(0) @@ -667,27 +669,37 @@ const KYLDashboardPage = () => { return; } - // UID → coordinate map + // ------------------------- + // Create UID → coordinate map + // ------------------------- const uidToCoord = {}; + centroidFeatures.forEach((feature) => { - const uid = feature.get("uid"); + const uid = feature.get("uid") || feature.get("UID"); + if (!uid) return; + const coord = feature.getGeometry().getCoordinates(); - uidToCoord[uid] = coord; + uidToCoord[uid.toString().trim()] = coord; }); const arrowFeatures = []; + // ------------------------- + // Create arrows + // ------------------------- connectivityFeatures.forEach((feature) => { const uid = feature.get("uid"); const downstream = feature.get("downstream"); + if (!uid || !downstream) return; - const start = uidToCoord[uid]; - const end = uidToCoord[downstream]; + const start = uidToCoord[uid.toString().trim()]; + const end = uidToCoord[downstream.toString().trim()]; - if (!start || !end) return; + if (!start || !end) return; const line = new LineString([start, end]); + const arrowFeature = new Feature({ geometry: line, upstream: uid, @@ -704,40 +716,55 @@ const KYLDashboardPage = () => { const arrowLayer = new VectorLayer({ source: arrowSource, style: (feature) => { + const styles = []; + const geometry = feature.getGeometry(); const coords = geometry.getCoordinates(); - const start = coords[0]; - const end = coords[1]; + // Need at least 2 points + if (!coords || coords.length < 2) return styles; + + const start = coords[coords.length - 2]; + const end = coords[coords.length - 1]; const dx = end[0] - start[0]; const dy = end[1] - start[1]; - - // Fix: subtract Math.PI/2 to align with OL's rotation (from top, clockwise) - const rotation = -Math.atan2(dy, dx) + Math.PI / 2; - const isDownstream = end[1] < start[1]; - - const arrowColor = isDownstream ? "#39FF14" : "#FF1493"; //green down pink up - return [ - new Style({ - stroke: new Stroke({ - color: arrowColor, - width: 2.2, - lineCap: "round", - }), - }), + const len = Math.sqrt(dx * dx + dy * dy); + + // Skip zero-length or near-zero edges + if (len < 1e-6) return styles; + + const angle = Math.atan2(dy, dx); + const color = "#FF1493"; + + // Main line + styles.push( new Style({ - geometry: new Point(end), - image: new RegularShape({ - points: 3, - radius: 7, - fill: new Fill({ color: arrowColor }), - rotation: rotation, - rotateWithView: true, - angle: 0, - }), - }), + stroke: new Stroke({ color, width: 1.5 }), + }) + ); + + // Arrowhead size proportional to edge length, capped + const arrowLen = Math.min(len * 0.08, 0.006); + const arrowAngle = Math.PI / 6; + + const left = [ + end[0] - arrowLen * Math.cos(angle - arrowAngle), + end[1] - arrowLen * Math.sin(angle - arrowAngle), + ]; + const right = [ + end[0] - arrowLen * Math.cos(angle + arrowAngle), + end[1] - arrowLen * Math.sin(angle + arrowAngle), ]; + + styles.push( + new Style({ + geometry: new LineString([left, end, right]), + stroke: new Stroke({ color, width: 1.5 }), + }) + ); + + return styles; }, }); @@ -1038,10 +1065,11 @@ const KYLDashboardPage = () => { setDataJson(result); setIsLoading(false); - setFiltersEnabled(true) + setFiltersEnabled(true); } catch (e) { console.log(e); setIsLoading(false); + setFiltersEnabled(true); } }; @@ -1076,19 +1104,25 @@ const KYLDashboardPage = () => { }; const handleLayerSelection = async (filter) => { + if (!mapRef.current) return; + if (!district?.label || !block?.label) { + toast.error("Please select State, District and Tehsil first."); + return; + } let checkIfPresent = currentLayer.find((f) => f.name === filter.name); let checkIfInMap = mapRef.current.getLayers().getArray(); - let existingLayer = checkIfInMap.find((layer) => { - return layer.ol_uid === boundaryLayerRef.current.ol_uid; - }); + const boundaryLayer = boundaryLayerRef.current; + let existingLayer = boundaryLayer + ? checkIfInMap.find((layer) => layer && layer.ol_uid === boundaryLayer.ol_uid) + : false; let tempArr = currentLayer; let len = filter.layer_store.length; if (checkIfPresent) { - checkIfPresent.layerRef.map((item) => { - mapRef.current.removeLayer(item); + checkIfPresent.layerRef.forEach((item) => { + if (item) mapRef.current.removeLayer(item); }); - if (!existingLayer) { - mapRef.current.addLayer(boundaryLayerRef.current); + if (!existingLayer && boundaryLayer) { + mapRef.current.addLayer(boundaryLayer); } mwsLayerRef.current.setStyle((feature) => { if ( @@ -1124,8 +1158,8 @@ const KYLDashboardPage = () => { //setFiltersEnabled(true); } else if (currentLayer.length === 0) { let layerRef = []; - mapRef.current.removeLayer(mwsLayerRef.current); - mapRef.current.removeLayer(boundaryLayerRef.current); + if (mwsLayerRef.current) mapRef.current.removeLayer(mwsLayerRef.current); + if (boundaryLayerRef.current) mapRef.current.removeLayer(boundaryLayerRef.current); for (let i = 0; i < len; ++i) { let tempLayer; if (filter.layer_store[i] === "terrain") { @@ -1271,11 +1305,23 @@ const KYLDashboardPage = () => { color: "#254871", width: 2.0, }), + fill: new Fill({ + color: "rgba(255, 75, 75, 0.25)", + }), }); } + return new Style({ + stroke: new Stroke({ + color: "#0a1628", + width: 2, + }), + fill: new Fill({ + color: "rgba(74, 144, 226, 0.15)", + }), + }); }); - mapRef.current.addLayer(mwsLayerRef.current); - mapRef.current.addLayer(boundaryLayerRef.current); + if (mwsLayerRef.current) mapRef.current.addLayer(mwsLayerRef.current); + if (boundaryLayerRef.current) mapRef.current.addLayer(boundaryLayerRef.current); let tempObj = { name: filter.name, layerRef: layerRef, @@ -2221,6 +2267,16 @@ const KYLDashboardPage = () => { patternSelections={patternSelections} handlePatternSelection={handlePatternSelection} isPatternSelected={isPatternSelected} + onOpenPatternIntensityMap={() => setShowPatternIntensityModal(true)} + /> + + setShowPatternIntensityModal(false)} + district={district} + block={block} + dataJson={dataJson} + patternSelections={patternSelections} /> {/* Map Container */}