Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/ISSUE-marker-clustering.md
Original file line number Diff line number Diff line change
@@ -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.*
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 2 additions & 5 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,10 @@ function App() {
<Route path="/" element={<LEHomepage />} />
<Route path="/kyl_dashboard" element={<KYLDashboardPage />} />
<Route path="/download_layers" element={<LandscapeExplorer />} />
<Route path="/agrohorticulture" element={<AgroHorticulture/>}/>
<Route path="/rwb" element={<RWBDashboard/>}/>
<Route path="/agrohorticulture" element={<AgroHorticulture />} />
<Route path="/rwb" element={<RWBDashboard />} />
<Route path="/CCUsagePage" element={<PlansPage />} />
<Route path="/plan-view" element={<PlanViewPage />} />



</Routes>
</BrowserRouter>
);
Expand Down
164 changes: 164 additions & 0 deletions src/components/PatternIntensityMapModal.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" role="dialog" aria-modal="true" aria-labelledby="pattern-intensity-title">
<div className="bg-white rounded-xl shadow-xl flex flex-col w-[90vw] max-w-4xl h-[85vh] overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<h2 id="pattern-intensity-title" className="text-lg font-semibold text-gray-800">
Map View (Pattern intensity)
</h2>
<button
type="button"
onClick={onClose}
className="p-2 rounded-lg text-gray-500 hover:bg-gray-100 hover:text-gray-700"
aria-label="Close"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div ref={mapContainerRef} className="flex-1 min-h-0 w-full" />
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50 flex items-center gap-4 flex-wrap">
<span className="text-sm font-medium text-gray-700">Pattern intensity:</span>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-600">Safe</span>
<div
className="w-8 h-4 rounded border border-gray-300"
style={{ backgroundColor: patternIntensityColor(0, 1) }}
/>
<span className="text-xs text-gray-500">0</span>
</div>
<div className="flex items-center gap-1 flex-1 max-w-[200px]">
<div
className="h-4 flex-1 rounded-l border-y border-l border-gray-300"
style={{ background: "linear-gradient(to right, rgba(34,197,94,0.55), rgba(234,179,8,0.55))" }}
/>
<div
className="h-4 flex-1 rounded-r border border-gray-300"
style={{ background: "linear-gradient(to right, rgba(234,179,8,0.55), rgba(220,38,38,0.55))" }}
/>
</div>
<div className="flex items-center gap-2">
<div
className="w-8 h-4 rounded border border-gray-300"
style={{ backgroundColor: patternIntensityColor(1, 1) }}
/>
<span className="text-xs text-gray-600">High stress</span>
</div>
</div>
</div>
</div>
);
};

export default PatternIntensityMapModal;
7 changes: 4 additions & 3 deletions src/components/buttons/toggle_button_kyl.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
const ToggleButton = ({isOn, toggleSwitch}) => {
const ToggleButton = ({ isOn, toggleSwitch, disabled = false }) => {
return (
<div className="flex items-center gap-2 bg-white px-2 py-1 rounded-md border border-gray-200 shadow-sm hover:shadow transition-shadow">
<div className={`flex items-center gap-2 bg-white px-2 py-1 rounded-md border border-gray-200 shadow-sm transition-shadow ${disabled ? 'opacity-60' : 'hover:shadow'}`}>
<span className="text-xs font-medium text-gray-700">
Visualize
</span>
<button
onClick={toggleSwitch}
className={`relative w-9 h-5 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 ${
disabled={disabled}
className={`relative w-9 h-5 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-70 ${
isOn ? 'bg-blue-600' : 'bg-gray-300'
}`}
role="switch"
Expand Down
Loading