@@ -158,15 +158,17 @@ function renderDetail(locId, detail) {
158158// ---------------------------------------------------------------------------
159159const markerLayer = L . layerGroup ( ) . addTo ( map ) ;
160160let allLocations = [ ] ;
161+ const CLUSTER_THRESHOLD = 100 ; // Use clustering if more than 100 locations
161162
162163async 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+
222309async 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
390482function showError ( message ) {
0 commit comments