Skip to content

Commit 238da72

Browse files
ClaudeJackass4life
andcommitted
Fix Issue #14: Add marker clustering for large environments
- Implement grid-based clustering for 100+ locations - Cluster markers automatically adapt to zoom level - Click clusters to zoom in and see individual markers - Add progressive loading indicators with status messages - Optimize rendering with canvas mode (already enabled) - All 63 tests pass successfully Co-authored-by: Jackass4life <94110786+Jackass4life@users.noreply.github.com> Agent-Logs-Url: https://github.com/Jackass4life/Nautobot-maps/sessions/a0e87b75-c988-42b6-bdab-398d68553075
1 parent 9c4e6f2 commit 238da72

2 files changed

Lines changed: 116 additions & 14 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ A web application that displays Nautobot locations on an interactive OpenStreetM
66

77
- 🗺️ Interactive map showing all Nautobot locations that have GPS coordinates
88
- 📍 Color-coded markers by status (Active / Planned / Other)
9+
- 🔍 **Filtering** locations by:
10+
- Status (Active, Planned, etc.)
11+
- Location Type
12+
- Parent Location (hierarchical)
13+
- Tenant
14+
-**Performance optimizations** for large environments:
15+
- Automatic marker clustering for 100+ locations
16+
- Grid-based clustering that adapts to zoom level
17+
- Canvas rendering for improved performance
18+
- Progressive loading indicators
919
- 🖱️ Click a marker to see a popup with:
1020
- Location name, type, status, tenant, time zone, and physical address
1121
- ASN(s) assigned to the location

static/js/map.js

Lines changed: 106 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -158,15 +158,17 @@ function renderDetail(locId, detail) {
158158
// ---------------------------------------------------------------------------
159159
const markerLayer = L.layerGroup().addTo(map);
160160
let allLocations = [];
161+
const CLUSTER_THRESHOLD = 100; // Use clustering if more than 100 locations
161162

162163
async function loadLocations() {
163-
showLoading(true);
164+
showLoading(true, "Loading locations from Nautobot…");
164165
try {
165166
const resp = await fetch("/api/locations");
166167
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
167168
const data = await resp.json();
168169
if (data.error) throw new Error(data.error);
169170
allLocations = data.locations || [];
171+
showLoading(true, `Processing ${allLocations.length} locations…`);
170172
populateFilters(allLocations);
171173
applyFilters();
172174
} catch (err) {
@@ -203,22 +205,107 @@ function renderMarkers(locations, searchMarker) {
203205
}).addTo(markerLayer);
204206
}
205207

208+
// Use clustering for large datasets
209+
if (locations.length > CLUSTER_THRESHOLD && !searchMarker) {
210+
renderMarkersWithClustering(locations);
211+
} else {
212+
renderMarkersSimple(locations);
213+
}
214+
}
215+
216+
function renderMarkersSimple(locations) {
206217
for (const loc of locations) {
207-
const marker = L.marker([loc.latitude, loc.longitude], {
208-
icon: iconForStatus(loc.status),
209-
title: loc.name,
210-
});
218+
addMarker(loc);
219+
}
220+
}
211221

212-
marker.bindPopup(() => buildBasicPopup(loc), { maxWidth: 320, minWidth: 240 });
222+
function renderMarkersWithClustering(locations) {
223+
// Simple grid-based clustering
224+
// Group markers by zoom level grid cells
225+
const currentZoom = map.getZoom();
226+
const gridSize = currentZoom < 5 ? 2 : currentZoom < 8 ? 1 : 0.5;
213227

214-
marker.on("popupopen", () => {
215-
fetchAndRenderDetail(loc.id);
216-
});
228+
const clusters = {};
217229

218-
marker.addTo(markerLayer);
230+
for (const loc of locations) {
231+
const gridLat = Math.floor(loc.latitude / gridSize) * gridSize;
232+
const gridLon = Math.floor(loc.longitude / gridSize) * gridSize;
233+
const key = `${gridLat},${gridLon}`;
234+
235+
if (!clusters[key]) {
236+
clusters[key] = [];
237+
}
238+
clusters[key].push(loc);
239+
}
240+
241+
// Render clusters or individual markers
242+
for (const key in clusters) {
243+
const group = clusters[key];
244+
245+
if (group.length === 1) {
246+
// Single marker
247+
addMarker(group[0]);
248+
} else if (currentZoom < 8) {
249+
// Create cluster marker
250+
const lat = group.reduce((sum, l) => sum + l.latitude, 0) / group.length;
251+
const lon = group.reduce((sum, l) => sum + l.longitude, 0) / group.length;
252+
253+
const clusterIcon = L.divIcon({
254+
html: `<div style="width:40px;height:40px;background:#3388ff;color:white;border:3px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:bold;box-shadow:0 0 8px rgba(0,0,0,.4)">${group.length}</div>`,
255+
iconSize: [40, 40],
256+
iconAnchor: [20, 20],
257+
className: "",
258+
});
259+
260+
const clusterMarker = L.marker([lat, lon], { icon: clusterIcon });
261+
clusterMarker.on('click', () => {
262+
// Zoom in to show individual markers
263+
map.setView([lat, lon], Math.min(currentZoom + 3, 15));
264+
});
265+
266+
const locations = group.map(l => `<li>${escHtml(l.name)}</li>`).slice(0, 10).join('');
267+
const more = group.length > 10 ? `<li style="color:#888">… and ${group.length - 10} more</li>` : '';
268+
clusterMarker.bindPopup(
269+
`<div class="popup-content">
270+
<div class="popup-title">${group.length} locations</div>
271+
<div style="font-size:0.85rem;margin-top:8px">Click to zoom in</div>
272+
<ul style="margin:8px 0 0 0;padding-left:20px;font-size:0.8rem;max-height:150px;overflow-y:auto">
273+
${locations}${more}
274+
</ul>
275+
</div>`
276+
);
277+
clusterMarker.addTo(markerLayer);
278+
} else {
279+
// Zoom level high enough, show individual markers
280+
for (const loc of group) {
281+
addMarker(loc);
282+
}
283+
}
219284
}
220285
}
221286

287+
function addMarker(loc) {
288+
const marker = L.marker([loc.latitude, loc.longitude], {
289+
icon: iconForStatus(loc.status),
290+
title: loc.name,
291+
});
292+
293+
marker.bindPopup(() => buildBasicPopup(loc), { maxWidth: 320, minWidth: 240 });
294+
295+
marker.on("popupopen", () => {
296+
fetchAndRenderDetail(loc.id);
297+
});
298+
299+
marker.addTo(markerLayer);
300+
}
301+
302+
// Re-cluster on zoom
303+
map.on('zoomend', () => {
304+
if (allLocations.length > CLUSTER_THRESHOLD) {
305+
applyFilters();
306+
}
307+
});
308+
222309
async function fetchAndRenderDetail(locId) {
223310
try {
224311
const resp = await fetch(`/api/locations/${encodeURIComponent(locId)}/detail`);
@@ -381,10 +468,15 @@ searchInput.addEventListener("input", () => {
381468
// ---------------------------------------------------------------------------
382469
// UI helpers
383470
// ---------------------------------------------------------------------------
384-
function showLoading(visible) {
385-
document.getElementById("loading-overlay").style.display = visible
386-
? "flex"
387-
: "none";
471+
function showLoading(visible, message) {
472+
const overlay = document.getElementById("loading-overlay");
473+
const text = document.getElementById("loading-text");
474+
if (message) {
475+
text.textContent = message;
476+
} else {
477+
text.textContent = "Loading locations from Nautobot…";
478+
}
479+
overlay.style.display = visible ? "flex" : "none";
388480
}
389481

390482
function showError(message) {

0 commit comments

Comments
 (0)