@@ -35,6 +35,13 @@ const GRID_PAINT = {
3535 "fill-extrusion-base" : 0 ,
3636 "fill-extrusion-opacity" : 0.85 ,
3737} ;
38+ const STOREY_PAINT = {
39+ "fill-extrusion-color" : [ "interpolate" , [ "linear" ] , [ "get" , "storey" ] ,
40+ 1 , "#4a90d9" , 2 , "#3498db" , 3 , "#2ecc71" , 5 , "#f39c12" , 8 , "#e74c3c" ] ,
41+ "fill-extrusion-height" : [ "get" , "top" ] ,
42+ "fill-extrusion-base" : [ "get" , "base" ] ,
43+ "fill-extrusion-opacity" : 0.75 ,
44+ } ;
3845
3946// Cluster style constants
4047const CLUSTER_COLORS = [
@@ -53,6 +60,8 @@ let buildingsGeoJSON = null;
5360let clusterGeoJSON = null ;
5461let gridCellsGeoJSON = null ;
5562let rawGridCells = null ;
63+ let storeyPolygonsGeoJSON = null ;
64+ let rawStoreyData = null ;
5665let callbacks = { } ;
5766let summaryToggleCb = null ;
5867
@@ -79,6 +88,31 @@ function ensureGridCellsConverted() {
7988 }
8089}
8190
91+ /** Convert raw LV95 storey polygons to WGS84 GeoJSON on first use */
92+ function ensureStoreyPolygonsConverted ( ) {
93+ if ( storeyPolygonsGeoJSON || ! rawStoreyData || rawStoreyData . length === 0 ) return ;
94+ const features = rawStoreyData . map ( ( sp ) => {
95+ // Convert polygon rings from LV95 to WGS84
96+ const geom = sp . polygonLV95 ;
97+ const convertRing = ( ring ) => ring . map ( ( [ x , y ] ) => fromLV95 ( x , y ) ) ;
98+ let coordinates ;
99+ if ( geom . type === "MultiPolygon" ) {
100+ coordinates = geom . coordinates . map ( ( poly ) => poly . map ( convertRing ) ) ;
101+ } else {
102+ coordinates = geom . coordinates . map ( convertRing ) ;
103+ }
104+ return {
105+ type : "Feature" ,
106+ geometry : { type : geom . type , coordinates } ,
107+ properties : { storey : sp . storey , base : sp . base , top : sp . top , buildingIndex : sp . buildingIndex } ,
108+ } ;
109+ } ) ;
110+ storeyPolygonsGeoJSON = { type : "FeatureCollection" , features } ;
111+ if ( map && map . getSource ( "storey-polygons" ) ) {
112+ map . getSource ( "storey-polygons" ) . setData ( storeyPolygonsGeoJSON ) ;
113+ }
114+ }
115+
82116export function onSummaryToggle ( cb ) { summaryToggleCb = cb ; }
83117export function setSummaryToggleVisible ( visible ) {
84118 const btn = document . getElementById ( "summary-toggle-btn" ) ;
@@ -304,6 +338,81 @@ export function plotResults(data) {
304338 } ) ;
305339 }
306340
341+ // Storey polygons — compute merged polygon per storey per building
342+ rawStoreyData = [ ] ;
343+ storeyPolygonsGeoJSON = null ;
344+ const half = GRID_SPACING / 2 ;
345+
346+ for ( let bi = 0 ; bi < data . buildings . length ; bi ++ ) {
347+ const b = data . buildings [ bi ] ;
348+ if ( ! b . grid_cells || ! b . floor_height_used || b . floor_height_used <= 0 ) continue ;
349+
350+ const floorH = b . floor_height_used ;
351+ const angle = b . grid_angle || 0 ;
352+ const cos = Math . cos ( angle ) , sin = Math . sin ( angle ) ;
353+ const maxH = Math . max ( ...b . grid_cells . map ( ( c ) => c . h ) ) ;
354+ const maxStoreys = Math . ceil ( maxH / floorH ) ;
355+
356+ for ( let s = 1 ; s <= maxStoreys ; s ++ ) {
357+ const threshold = ( s - 1 ) * floorH ;
358+ const cellsAtLevel = b . grid_cells . filter ( ( c ) => c . h > threshold ) ;
359+
360+ // Skip storey if fewer than 50% of cells reach it (partial roof artefact)
361+ if ( cellsAtLevel . length < b . grid_cells . length * 0.5 && s > Math . floor ( maxH / floorH ) ) continue ;
362+ if ( cellsAtLevel . length === 0 ) continue ;
363+
364+ // Build cell polygons in LV95 for union
365+ const cellFeatures = cellsAtLevel . map ( ( c ) => {
366+ const corners = [ [ - half , - half ] , [ half , - half ] , [ half , half ] , [ - half , half ] ] . map ( ( [ dx , dy ] ) =>
367+ [ c . x + dx * cos - dy * sin , c . y + dx * sin + dy * cos ]
368+ ) ;
369+ corners . push ( corners [ 0 ] ) ;
370+ return turf . polygon ( [ corners ] ) ;
371+ } ) ;
372+
373+ // Union all cell polygons into a single polygon for this storey
374+ let merged ;
375+ if ( cellFeatures . length === 1 ) {
376+ merged = cellFeatures [ 0 ] ;
377+ } else {
378+ try {
379+ merged = turf . union ( turf . featureCollection ( cellFeatures ) ) ;
380+ } catch ( _ ) {
381+ // If union fails, skip this storey
382+ continue ;
383+ }
384+ }
385+
386+ if ( merged ) {
387+ rawStoreyData . push ( {
388+ buildingIndex : bi ,
389+ storey : s ,
390+ polygonLV95 : merged . geometry ,
391+ base : ( s - 1 ) * floorH ,
392+ top : Math . min ( s * floorH , maxH ) ,
393+ } ) ;
394+ }
395+ }
396+ }
397+
398+ // Storey polygons source + layer (starts empty, converted lazily)
399+ const emptyGeoJSON2 = { type : "FeatureCollection" , features : [ ] } ;
400+ if ( map . getSource ( "storey-polygons" ) ) {
401+ map . getSource ( "storey-polygons" ) . setData ( emptyGeoJSON2 ) ;
402+ } else {
403+ map . addSource ( "storey-polygons" , { type : "geojson" , data : emptyGeoJSON2 } ) ;
404+ }
405+
406+ if ( ! map . getLayer ( "storey-polygons-3d" ) ) {
407+ map . addLayer ( {
408+ id : "storey-polygons-3d" ,
409+ type : "fill-extrusion" ,
410+ source : "storey-polygons" ,
411+ layout : { visibility : "none" } ,
412+ paint : STOREY_PAINT ,
413+ } ) ;
414+ }
415+
307416 // Labels
308417 if ( ! map . getLayer ( "buildings-labels" ) ) {
309418 map . addLayer ( {
@@ -389,6 +498,14 @@ export function plotResults(data) {
389498 map . setLayoutProperty ( "grid-cells-3d" , "visibility" , e . target . checked ? "visible" : "none" ) ;
390499 }
391500 } ) ;
501+ document . getElementById ( "layer-toggle-storeys" ) ?. addEventListener ( "change" , ( e ) => {
502+ if ( e . target . checked ) {
503+ ensureStoreyPolygonsConverted ( ) ;
504+ }
505+ if ( map . getLayer ( "storey-polygons-3d" ) ) {
506+ map . setLayoutProperty ( "storey-polygons-3d" , "visibility" , e . target . checked ? "visible" : "none" ) ;
507+ }
508+ } ) ;
392509 document . getElementById ( "layer-toggle-labels" ) ?. addEventListener ( "change" , ( e ) => {
393510 if ( map . getLayer ( "buildings-labels" ) ) {
394511 map . setLayoutProperty ( "buildings-labels" , "visibility" , e . target . checked ? "visible" : "none" ) ;
@@ -484,6 +601,12 @@ function initBasemapSwitcher() {
484601 map . addLayer ( { id : "grid-cells-3d" , type : "fill-extrusion" , source : "grid-cells" ,
485602 layout : { visibility : visGrid } , paint : GRID_PAINT } ) ;
486603
604+ const visStoreys = document . getElementById ( "layer-toggle-storeys" ) ?. checked ? "visible" : "none" ;
605+ const storeyData = storeyPolygonsGeoJSON || { type : "FeatureCollection" , features : [ ] } ;
606+ map . addSource ( "storey-polygons" , { type : "geojson" , data : storeyData } ) ;
607+ map . addLayer ( { id : "storey-polygons-3d" , type : "fill-extrusion" , source : "storey-polygons" ,
608+ layout : { visibility : visStoreys } , paint : STOREY_PAINT } ) ;
609+
487610 // Re-add cluster layers
488611 addClusterLayers ( visClusters ) ;
489612 }
0 commit comments