@@ -36,8 +36,21 @@ const GRID_PAINT = {
3636 "fill-extrusion-opacity" : 0.85 ,
3737} ;
3838
39+ // Cluster style constants
40+ const CLUSTER_COLORS = [
41+ [ 100 , "#e74c3c" ] , // 100+ buildings: red
42+ [ 20 , "#f39c12" ] , // 20–99: orange
43+ [ 0 , "#3498db" ] , // 0–19: blue
44+ ] ;
45+ const CLUSTER_RADII = [
46+ [ 100 , 30 ] ,
47+ [ 20 , 24 ] ,
48+ [ 0 , 18 ] ,
49+ ] ;
50+
3951let map = null ;
4052let buildingsGeoJSON = null ;
53+ let clusterGeoJSON = null ;
4154let gridCellsGeoJSON = null ;
4255let rawGridCells = null ;
4356let callbacks = { } ;
@@ -105,6 +118,106 @@ export async function initMap(containerId, cbs) {
105118 document . getElementById ( "style-switcher" ) ?. classList . add ( "visible" ) ;
106119}
107120
121+ /** Build a point FeatureCollection of building centroids for clustering */
122+ function buildClusterPoints ( buildings ) {
123+ const pts = buildings
124+ . filter ( ( b ) => b . geometry )
125+ . map ( ( b , i ) => {
126+ const centroid = turf . centroid ( { type : "Feature" , geometry : b . geometry } ) ;
127+ return {
128+ type : "Feature" ,
129+ geometry : centroid . geometry ,
130+ properties : {
131+ _index : i ,
132+ id : b . input_id ,
133+ status : b . status ,
134+ } ,
135+ } ;
136+ } ) ;
137+ return { type : "FeatureCollection" , features : pts } ;
138+ }
139+
140+ /** Add cluster source and layers to the map */
141+ function addClusterLayers ( visible ) {
142+ if ( ! clusterGeoJSON ) return ;
143+ const vis = visible ? "visible" : "none" ;
144+
145+ if ( ! map . getSource ( "buildings-clustered" ) ) {
146+ map . addSource ( "buildings-clustered" , {
147+ type : "geojson" ,
148+ data : clusterGeoJSON ,
149+ cluster : true ,
150+ clusterMaxZoom : 14 ,
151+ clusterRadius : 50 ,
152+ } ) ;
153+ }
154+
155+ // Cluster circles
156+ if ( ! map . getLayer ( "clusters" ) ) {
157+ map . addLayer ( {
158+ id : "clusters" ,
159+ type : "circle" ,
160+ source : "buildings-clustered" ,
161+ filter : [ "has" , "point_count" ] ,
162+ layout : { visibility : vis } ,
163+ paint : {
164+ "circle-color" : [
165+ "step" , [ "get" , "point_count" ] ,
166+ CLUSTER_COLORS [ 2 ] [ 1 ] , CLUSTER_COLORS [ 1 ] [ 0 ] ,
167+ CLUSTER_COLORS [ 1 ] [ 1 ] , CLUSTER_COLORS [ 0 ] [ 0 ] ,
168+ CLUSTER_COLORS [ 0 ] [ 1 ] ,
169+ ] ,
170+ "circle-radius" : [
171+ "step" , [ "get" , "point_count" ] ,
172+ CLUSTER_RADII [ 2 ] [ 1 ] , CLUSTER_RADII [ 1 ] [ 0 ] ,
173+ CLUSTER_RADII [ 1 ] [ 1 ] , CLUSTER_RADII [ 0 ] [ 0 ] ,
174+ CLUSTER_RADII [ 0 ] [ 1 ] ,
175+ ] ,
176+ "circle-stroke-width" : 2 ,
177+ "circle-stroke-color" : "#fff" ,
178+ } ,
179+ } ) ;
180+ }
181+
182+ // Cluster count labels
183+ if ( ! map . getLayer ( "cluster-count" ) ) {
184+ map . addLayer ( {
185+ id : "cluster-count" ,
186+ type : "symbol" ,
187+ source : "buildings-clustered" ,
188+ filter : [ "has" , "point_count" ] ,
189+ layout : {
190+ visibility : vis ,
191+ "text-field" : [ "get" , "point_count_abbreviated" ] ,
192+ "text-size" : 12 ,
193+ "text-allow-overlap" : true ,
194+ } ,
195+ paint : {
196+ "text-color" : "#fff" ,
197+ } ,
198+ } ) ;
199+ }
200+
201+ // Unclustered individual points
202+ if ( ! map . getLayer ( "unclustered-point" ) ) {
203+ map . addLayer ( {
204+ id : "unclustered-point" ,
205+ type : "circle" ,
206+ source : "buildings-clustered" ,
207+ filter : [ "!" , [ "has" , "point_count" ] ] ,
208+ layout : { visibility : vis } ,
209+ paint : {
210+ "circle-color" : [
211+ "case" , [ "==" , [ "get" , "status" ] , "success" ] , "#3498db" , "#95a5a6" ,
212+ ] ,
213+ "circle-radius" : 6 ,
214+ "circle-stroke-width" : 1.5 ,
215+ "circle-stroke-color" : "#fff" ,
216+ } ,
217+ } ) ;
218+ }
219+ }
220+
108221export function plotResults ( data ) {
109222 if ( ! map || ! data || ! data . buildings ) return ;
110223
@@ -131,6 +244,9 @@ export function plotResults(data) {
131244
132245 buildingsGeoJSON = { type : "FeatureCollection" , features } ;
133246
247+ // Build cluster point data
248+ clusterGeoJSON = buildClusterPoints ( data . buildings ) ;
249+
134250 // Add source
135251 if ( map . getSource ( "buildings" ) ) {
136252 map . getSource ( "buildings" ) . setData ( buildingsGeoJSON ) ;
@@ -158,6 +274,10 @@ export function plotResults(data) {
158274 } ) ;
159275 }
160276
277+ // Cluster layers
278+ const clusterToggle = document . getElementById ( "layer-toggle-clusters" ) ;
279+ addClusterLayers ( ! clusterToggle || clusterToggle . checked ) ;
280+
161281 // Grid cells — store raw LV95 data with angle, convert lazily on first toggle
162282 rawGridCells = data . buildings
163283 . filter ( ( b ) => b . grid_cells )
@@ -229,6 +349,27 @@ export function plotResults(data) {
229349 map . on ( "mouseenter" , "buildings-3d" , ( ) => { map . getCanvas ( ) . style . cursor = "pointer" ; } ) ;
230350 map . on ( "mouseleave" , "buildings-3d" , ( ) => { map . getCanvas ( ) . style . cursor = "" ; } ) ;
231351
352+ // Cluster click → zoom to expand
353+ map . on ( "click" , "clusters" , ( e ) => {
354+ const cluster = e . features [ 0 ] ;
355+ map . getSource ( "buildings-clustered" ) . getClusterExpansionZoom ( cluster . properties . cluster_id , ( err , zoom ) => {
356+ if ( err ) return ;
357+ map . easeTo ( { center : cluster . geometry . coordinates , zoom : zoom + 0.5 } ) ;
358+ } ) ;
359+ } ) ;
360+ map . on ( "mouseenter" , "clusters" , ( ) => { map . getCanvas ( ) . style . cursor = "pointer" ; } ) ;
361+ map . on ( "mouseleave" , "clusters" , ( ) => { map . getCanvas ( ) . style . cursor = "" ; } ) ;
362+
363+ // Unclustered point click → fly to building
364+ map . on ( "click" , "unclustered-point" , ( e ) => {
365+ if ( ! e . features . length ) return ;
366+ const idx = e . features [ 0 ] . properties . _index ;
367+ map . flyTo ( { center : e . lngLat , zoom : 17 } ) ;
368+ if ( callbacks . onBuildingSelect ) callbacks . onBuildingSelect ( idx ) ;
369+ } ) ;
370+ map . on ( "mouseenter" , "unclustered-point" , ( ) => { map . getCanvas ( ) . style . cursor = "pointer" ; } ) ;
371+ map . on ( "mouseleave" , "unclustered-point" , ( ) => { map . getCanvas ( ) . style . cursor = "" ; } ) ;
372+
232373 // Layer toggles
233374 document . getElementById ( "layer-toggle-footprints" ) ?. addEventListener ( "change" , ( e ) => {
234375 if ( map . getLayer ( "buildings-outline" ) ) {
@@ -253,6 +394,12 @@ export function plotResults(data) {
253394 map . setLayoutProperty ( "buildings-labels" , "visibility" , e . target . checked ? "visible" : "none" ) ;
254395 }
255396 } ) ;
397+ document . getElementById ( "layer-toggle-clusters" ) ?. addEventListener ( "change" , ( e ) => {
398+ const vis = e . target . checked ? "visible" : "none" ;
399+ for ( const id of [ "clusters" , "cluster-count" , "unclustered-point" ] ) {
400+ if ( map . getLayer ( id ) ) map . setLayoutProperty ( id , "visibility" , vis ) ;
401+ }
402+ } ) ;
256403
257404 // AV cadastral overlay
258405 document . getElementById ( "layer-toggle-av" ) ?. addEventListener ( "change" , ( e ) => {
@@ -323,6 +470,7 @@ function initBasemapSwitcher() {
323470 const visBuildings = document . getElementById ( "layer-toggle-buildings" ) ?. checked ? "visible" : "none" ;
324471 const visLabels = document . getElementById ( "layer-toggle-labels" ) ?. checked ? "visible" : "none" ;
325472 const visGrid = document . getElementById ( "layer-toggle-grid" ) ?. checked ? "visible" : "none" ;
473+ const visClusters = document . getElementById ( "layer-toggle-clusters" ) ?. checked !== false ;
326474
327475 map . addSource ( "buildings" , { type : "geojson" , data : savedData } ) ;
328476 map . addLayer ( { id : "buildings-3d" , type : "fill-extrusion" , source : "buildings" ,
@@ -335,6 +483,9 @@ function initBasemapSwitcher() {
335483 map . addSource ( "grid-cells" , { type : "geojson" , data : gridData } ) ;
336484 map . addLayer ( { id : "grid-cells-3d" , type : "fill-extrusion" , source : "grid-cells" ,
337485 layout : { visibility : visGrid } , paint : GRID_PAINT } ) ;
486+
487+ // Re-add cluster layers
488+ addClusterLayers ( visClusters ) ;
338489 }
339490 } ) ;
340491
0 commit comments