1+ <!DOCTYPE html>
2+ < html lang ="en ">
3+
4+ < head >
5+ < meta charset ="utf-8 " />
6+ < meta name ="viewport " content ="width=device-width,initial-scale=1 " />
7+ < title > Public Transport Accessibility — Glyph Example</ title >
8+ < script src ="https://unpkg.com/maplibre-gl@4.0.0/dist/maplibre-gl.js "> </ script >
9+ < link href ="https://unpkg.com/maplibre-gl@4.0.0/dist/maplibre-gl.css " rel ="stylesheet " />
10+ < style >
11+ body {
12+ margin : 0 ;
13+ font-family : Arial, sans-serif;
14+ }
15+
16+ # map {
17+ width : 100vw ;
18+ height : 100vh ;
19+ }
20+
21+ .controls {
22+ position : absolute;
23+ right : 10px ;
24+ top : 10px ;
25+ background : white;
26+ padding : 10px ;
27+ border-radius : 6px ;
28+ box-shadow : 0 2px 10px rgba (0 , 0 , 0 , 0.12 );
29+ z-index : 1000
30+ }
31+
32+ .controls label {
33+ display : block;
34+ font-size : 13px ;
35+ margin-top : 6px
36+ }
37+
38+ .controls input [type = range ] {
39+ width : 200px
40+ }
41+
42+ .legend {
43+ margin-top : 8px ;
44+ font-size : 12px ;
45+ }
46+
47+ .legend .bar {
48+ width : 180px ;
49+ height : 12px ;
50+ border-radius : 3px ;
51+ margin-top : 6px ;
52+ border : 1px solid rgba (0 , 0 , 0 , 0.12 );
53+ }
54+
55+ .legend .row {
56+ display : flex;
57+ align-items : center;
58+ gap : 8px ;
59+ }
60+
61+ .legend .label {
62+ width : 90px ;
63+ }
64+ </ style >
65+ </ head >
66+
67+ < body >
68+ < div id ="map "> </ div >
69+ < div class ="controls ">
70+ < div > < strong > Public Transport Accessibility</ strong > </ div >
71+ < label > Time bin: < span id ="minuteLabel "> 120</ span > min</ label >
72+ < input id ="timeSlider " type ="range " min ="0 " max ="7 " step ="1 " value ="7 " />
73+ < label > Normalization:
74+ < select id ="normalizationSelect ">
75+ < option value ="max-local "> Max (local)</ option >
76+ < option value ="max-global "> Max (global)</ option >
77+ < option value ="z-score "> Z-score</ option >
78+ < option value ="percentile "> Percentile</ option >
79+ </ select >
80+ </ label >
81+ < label > < input id ="cumulativeToggle " type ="checkbox " /> Show cumulative layer</ label >
82+ < label > < input id ="sparkToggle " type ="checkbox " /> Show sparkline</ label >
83+ < div style ="margin-top:8px "> Glyph size: < input id ="glyphSize " type ="range " min ="0.3 " max ="1.2 " step ="0.1 "
84+ value ="0.8 " /> </ div >
85+ < div class ="legend " id ="legend ">
86+ < div > < strong > Legend</ strong > </ div >
87+ < div class ="row ">
88+ < div class ="label "> Cells (primary)</ div >
89+ < div class ="bar " id ="legend-primary " style ="background: linear-gradient(90deg, rgb(0,200,50), rgb(255,0,50)); ">
90+ </ div >
91+ </ div >
92+ < div class ="row ">
93+ < div class ="label "> Cumulative</ div >
94+ < div class ="bar " id ="legend-cumulative "
95+ style ="background: linear-gradient(90deg, rgb(0,120,255), rgb(200,0,0)); "> </ div >
96+ </ div >
97+ < div style ="margin-top:6px; font-size:11px; color:#333; "> Normalization: < span id ="legend-norm "> Max (local)</ span >
98+ </ div >
99+ < div style ="margin-top:4px; font-size:11px; color:#333; "> Cumulative = per-feature sum across selected time bins,
100+ averaged per cell.</ div >
101+ </ div >
102+ </ div >
103+
104+ < script type ="module ">
105+ import { ScreenGridLayerGL } from '../src/index.js' ;
106+ import '../src/glyphs/PublicTransportGlyph.js' ;
107+
108+ const MINUTES = [ 15 , 30 , 45 , 60 , 75 , 90 , 105 , 120 ] ;
109+
110+ const map = new maplibregl . Map ( {
111+ container : 'map' ,
112+ style : {
113+ version : 8 ,
114+ sources : {
115+ 'carto-dark' : {
116+ type : 'raster' ,
117+ tiles : [
118+ 'https://a.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}.png' ,
119+ 'https://b.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}.png' ,
120+ 'https://c.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}.png' ,
121+ 'https://d.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}.png'
122+ ] ,
123+ tileSize : 256 ,
124+ attribution : '© <a href="https://carto.com/">CARTO</a>'
125+ }
126+ } ,
127+ layers : [ { id : 'carto-dark' , type : 'raster' , source : 'carto-dark' } ]
128+ } ,
129+ center : [ - 1.25 , 52.5 ] ,
130+ zoom : 6
131+ } ) ;
132+
133+ const minuteLabel = document . getElementById ( 'minuteLabel' ) ;
134+ const timeSlider = document . getElementById ( 'timeSlider' ) ;
135+ const sparkToggle = document . getElementById ( 'sparkToggle' ) ;
136+ const glyphSizeInput = document . getElementById ( 'glyphSize' ) ;
137+
138+ let gridLayer = null ;
139+ let cumulativeLayer = null ;
140+ let data = null ;
141+ let currentGlobalMax = null ;
142+
143+ async function loadData ( ) {
144+ const url = 'data/public_transport_accessibility.json' ;
145+ try {
146+ const res = await fetch ( url ) ;
147+ data = await res . json ( ) ;
148+ console . log ( 'Loaded accessibility data, records:' , data . length ) ;
149+ setupLayer ( ) ;
150+ } catch ( e ) {
151+ console . error ( 'Failed to load data' , e ) ;
152+ }
153+ }
154+
155+ function setupLayer ( ) {
156+ if ( ! data ) return ;
157+ if ( gridLayer && map . getLayer ( gridLayer . id ) ) map . removeLayer ( gridLayer . id ) ;
158+
159+ const cellSize = 60 ;
160+ const timeIndex = Number ( timeSlider . value ) ;
161+ const showSpark = sparkToggle . checked ;
162+ const glyphSize = Number ( glyphSizeInput . value ) ;
163+ const normalization = document . getElementById ( 'normalizationSelect' ) . value ;
164+ const cumulativeOn = false ; // primary layer is per-time by default
165+
166+ gridLayer = new ScreenGridLayerGL ( {
167+ id : 'pta-glyph-layer' ,
168+ data,
169+ // data is expected to be features with a coordinate property named `centroid` or `COORDINATES`
170+ getPosition : ( d ) => {
171+ if ( ! d ) return null ;
172+ // If the feature already provides a centroid array
173+ if ( Array . isArray ( d . centroid ) && d . centroid . length >= 2 ) return d . centroid ;
174+ // If the feature provides a simple COORDINATES array
175+ if ( Array . isArray ( d . COORDINATES ) && d . COORDINATES . length >= 2 ) return d . COORDINATES ;
176+ // If GeoJSON geometry is a Point / MultiPoint, extract first coordinate pair
177+ if ( d . geometry && d . geometry . type === 'Point' && Array . isArray ( d . geometry . coordinates ) ) return d . geometry . coordinates ;
178+ if ( d . geometry && d . geometry . type === 'MultiPoint' && Array . isArray ( d . geometry . coordinates ) && d . geometry . coordinates [ 0 ] ) return d . geometry . coordinates [ 0 ] ;
179+ // If the properties provide centroids as numbers (e.g., `cent_long` / `cent_lat`), use them
180+ if ( d . properties && typeof d . properties . cent_long === 'number' && typeof d . properties . cent_lat === 'number' ) return [ d . properties . cent_long , d . properties . cent_lat ] ;
181+ // Fallback: try top-level cent_long / cent_lat
182+ if ( typeof d . cent_long === 'number' && typeof d . cent_lat === 'number' ) return [ d . cent_long , d . cent_lat ] ;
183+ return null ;
184+ } ,
185+ getWeight : ( ) => 1 ,
186+ cellSizePixels : cellSize ,
187+ colorScale : ( v ) => [ 255 * v , 200 * ( 1 - v ) , 50 , 200 ] ,
188+ enableGlyphs : true ,
189+ glyphSize : glyphSize ,
190+ glyph : 'public-transport' ,
191+ glyphConfig : { timeIndex : timeIndex , showSparkline : showSpark , debug : true } ,
192+ normalizationFunction : normalization ,
193+ normalizationContext : ( normalization === 'max-global' && currentGlobalMax ) ? { globalMax : currentGlobalMax } : { } ,
194+ showBackground : true ,
195+ onAggregate : ( grid ) => {
196+ console . log ( 'Aggregated' , grid . cols , 'x' , grid . rows ) ;
197+ // compute global max for max-global normalization
198+ try {
199+ const values = ( grid && grid . grid ) ? grid . grid . map ( v => ( typeof v === 'number' ? v : ( v && typeof v === 'object' ? Object . values ( v ) . filter ( n => typeof n === 'number' ) . reduce ( ( s , n ) => s + n , 0 ) : 0 ) ) ) : [ ] ;
200+ const gm = values . length ? Math . max ( ...values ) : 0 ;
201+ currentGlobalMax = gm || 0 ;
202+ if ( gridLayer ) gridLayer . setConfig ( { normalizationContext : ( document . getElementById ( 'normalizationSelect' ) . value === 'max-global' ) ? { globalMax : currentGlobalMax } : { } } ) ;
203+ if ( cumulativeLayer ) cumulativeLayer . setConfig ( { normalizationContext : ( document . getElementById ( 'normalizationSelect' ) . value === 'max-global' ) ? { globalMax : currentGlobalMax } : { } } ) ;
204+ } catch ( e ) {
205+ console . warn ( 'Failed to compute global max' , e ) ;
206+ }
207+ } ,
208+ onHover : ( { cell } ) => {
209+ // optional: show hover details
210+ // console.log('hover', cell);
211+ }
212+ } ) ;
213+
214+ map . addLayer ( gridLayer ) ;
215+
216+ // prepare cumulative layer but do not add to map by default
217+ cumulativeLayer = new ScreenGridLayerGL ( {
218+ id : 'pta-cumulative-layer' ,
219+ data,
220+ getPosition : gridLayer . config . getPosition ,
221+ getWeight : ( ) => 1 ,
222+ cellSizePixels : cellSize ,
223+ colorScale : ( v ) => [ 200 * v , 120 * ( 1 - v ) , 255 * ( 1 - v ) , 200 ] ,
224+ enableGlyphs : true ,
225+ glyphSize : glyphSize ,
226+ glyph : 'public-transport' ,
227+ glyphConfig : { timeIndex : timeIndex , showSparkline : false , cumulative : true } ,
228+ normalizationFunction : document . getElementById ( 'normalizationSelect' ) . value ,
229+ normalizationContext : ( document . getElementById ( 'normalizationSelect' ) . value === 'max-global' && currentGlobalMax ) ? { globalMax : currentGlobalMax } : { } ,
230+ showBackground : false ,
231+ onAggregate : ( grid ) => { /* reuse global max update via primary layer */ }
232+ } ) ;
233+ }
234+
235+ map . on ( 'load' , ( ) => {
236+ loadData ( ) ;
237+ } ) ;
238+
239+ timeSlider . addEventListener ( 'input' , ( ) => {
240+ const idx = Number ( timeSlider . value ) ;
241+ minuteLabel . textContent = MINUTES [ idx ] ;
242+ if ( gridLayer ) gridLayer . setConfig ( { glyphConfig : { timeIndex : idx , showSparkline : sparkToggle . checked } } ) ;
243+ if ( cumulativeLayer ) cumulativeLayer . setConfig ( { glyphConfig : { timeIndex : idx , cumulative : true } } ) ;
244+ } ) ;
245+
246+ sparkToggle . addEventListener ( 'change' , ( ) => {
247+ const idx = Number ( timeSlider . value ) ;
248+ if ( gridLayer ) gridLayer . setConfig ( { glyphConfig : { timeIndex : idx , showSparkline : sparkToggle . checked } } ) ;
249+ } ) ;
250+
251+ glyphSizeInput . addEventListener ( 'input' , ( ) => {
252+ const v = Number ( glyphSizeInput . value ) ;
253+ if ( gridLayer ) gridLayer . setConfig ( { glyphSize : v } ) ;
254+ if ( cumulativeLayer ) cumulativeLayer . setConfig ( { glyphSize : v } ) ;
255+ } ) ;
256+
257+ // normalization selector
258+ const normSelect = document . getElementById ( 'normalizationSelect' ) ;
259+ normSelect . addEventListener ( 'change' , ( ) => {
260+ const val = normSelect . value ;
261+ if ( gridLayer ) gridLayer . setConfig ( { normalizationFunction : val , normalizationContext : ( val === 'max-global' && currentGlobalMax ) ? { globalMax : currentGlobalMax } : { } } ) ;
262+ if ( cumulativeLayer ) cumulativeLayer . setConfig ( { normalizationFunction : val , normalizationContext : ( val === 'max-global' && currentGlobalMax ) ? { globalMax : currentGlobalMax } : { } } ) ;
263+ // update legend text
264+ const legendNorm = document . getElementById ( 'legend-norm' ) ;
265+ if ( legendNorm ) {
266+ const mapping = { 'max-local' : 'Max (local)' , 'max-global' : 'Max (global)' , 'z-score' : 'Z-score' , 'percentile' : 'Percentile' } ;
267+ legendNorm . textContent = mapping [ val ] || val ;
268+ }
269+ } ) ;
270+
271+ // cumulative toggle
272+ const cumToggle = document . getElementById ( 'cumulativeToggle' ) ;
273+ cumToggle . addEventListener ( 'change' , ( ) => {
274+ if ( cumToggle . checked ) {
275+ if ( cumulativeLayer && ! map . getLayer ( cumulativeLayer . id ) ) map . addLayer ( cumulativeLayer ) ;
276+ // highlight legend
277+ const el = document . getElementById ( 'legend-cumulative' ) ; if ( el ) el . style . outline = '2px solid rgba(255,255,255,0.12)' ;
278+ } else {
279+ if ( cumulativeLayer && map . getLayer ( cumulativeLayer . id ) ) map . removeLayer ( cumulativeLayer . id ) ;
280+ const el = document . getElementById ( 'legend-cumulative' ) ; if ( el ) el . style . outline = 'none' ;
281+ }
282+ // trigger repaint
283+ if ( gridLayer && map ) map . triggerRepaint ( ) ;
284+ } ) ;
285+
286+ // Initialize legend text on load
287+ const legendNormInit = document . getElementById ( 'legend-norm' ) ; if ( legendNormInit ) legendNormInit . textContent = document . getElementById ( 'normalizationSelect' ) . selectedOptions [ 0 ] . textContent ;
288+ </ script >
289+ </ body >
290+
291+ </ html >
0 commit comments