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 = () => {
- + {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..405a8951 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,59 @@ 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" }); + } + }; + useEffect(() => { initializeAnalytics(); trackPageView("/kyl_home"); @@ -68,18 +116,30 @@ export default function KYLHomePage() { }; return ( -
- -
+
+ +
+ + + +
+
{/* Know Section */}
@@ -204,7 +264,7 @@ export default function KYLHomePage() {
{/* Plan Section */} -
+

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

{/* Track Section */} -
+
{/* Narrow text container */}
@@ -429,9 +489,11 @@ export default function KYLHomePage() {
-
- -
+
+ +
+
+
); } diff --git a/src/pages/RWBDashboard.jsx b/src/pages/RWBDashboard.jsx index c7fc115c..39b7d356 100644 --- a/src/pages/RWBDashboard.jsx +++ b/src/pages/RWBDashboard.jsx @@ -92,11 +92,11 @@ const RWBDashboard =()=>{ }, [location.search]); const handleNavigate =()=>{ - if(!organization && !project) return; + if (!project?.value) return; const params = new URLSearchParams(location.search); params.set("type", "project"); params.set("projectId", project.value); - params.set("project_name", project.label); + params.set("project_name", project.label ?? ""); navigate( { pathname:location.pathname, @@ -178,8 +178,10 @@ const RWBDashboard =()=>{ handleItemSelect={(setState, e) => setState(e)} />
-
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 */}