55 < meta name ="viewport " content ="width=device-width, initial-scale=1 " />
66 < title > Car Dependency Index</ title >
77 < link rel ="stylesheet " href ="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css " integrity ="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY= " crossorigin ="" />
8- < script src ="https://cdn.jsdelivr.net/npm/papaparse@5.4.1/papaparse.min.js "> </ script >
98 < link rel ="stylesheet " href ="style.css ">
109 < link rel ="icon " href ="./favicon.ico " sizes ="any ">
1110</ head >
1211< body >
1312< div class ="app ">
1413 < header >
14+ < a href ="#/ " id ="homeLink ">
15+ < img src ="favicon.ico " alt ="Home " class ="site-icon " />
16+ </ a >
1517 < h1 id ="title "> Car Dependency Index</ h1 >
1618 < div id ="subtitle " class ="muted "> </ div >
1719 < a class ="repo " id ="repoLink " href ="https://github.com/mat701/CDI " target ="_blank " rel ="noopener "> GitHub repository</ a >
@@ -41,377 +43,7 @@ <h1 id="title">Car Dependency Index</h1>
4143</ div >
4244
4345< script src ="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js " integrity ="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo= " crossorigin =""> </ script >
44- < script >
45-
46- // Global list to be filled from data/index.json
47- let CITIES = [ ] ;
48-
49- async function loadCitiesManifest ( ) {
50- const res = await fetch ( 'data/index.json' , { cache : 'no-store' } ) ;
51- if ( ! res . ok ) {
52- console . error ( '❌ Failed to load data/index.json' ) ;
53- return ;
54- }
55- const manifest = await res . json ( ) ;
56-
57- // Build each city definition automatically
58- CITIES = manifest . map ( m => ( {
59- name : m . name || ( m . slug [ 0 ] . toUpperCase ( ) + m . slug . slice ( 1 ) ) ,
60- slug : m . slug ,
61- center : m . center || [ 42.5 , 12.5 ] ,
62- zoom : m . zoom || 9 ,
63- layers : [
64- {
65- name : 'CDI (hex grid)' ,
66- url : `data/${ m . slug } /hexes.geojson` ,
67- join : {
68- csv : `data/${ m . slug } /cdi.csv` ,
69- csvId : 'hexagon_id' ,
70- geoId : 'id' ,
71- valueColumn : 'CDI'
72- }
73- }
74- ]
75- } ) ) ;
76- }
77-
78- const REPO_URL = 'https://github.com/mat701/CDI' ; // set your GitHub repo URL here
79- document . getElementById ( 'repoLink' ) . href = REPO_URL ;
80-
81- // =====================
82- // LANDING: list + pins
83- // =====================
84-
85- // Boot AFTER manifest is loaded (build pins + list, then start router)
86- async function boot ( ) {
87- await loadCitiesManifest ( ) ;
88-
89- const landingMap = L . map ( 'mapLanding' , { zoomControl : true } ) . setView ( [ 42.5 , 12.5 ] , 5 ) ;
90- const baseLanding = L . tileLayer ( 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' , { attribution : '© OpenStreetMap & CARTO' , maxZoom : 19 } ) . addTo ( landingMap ) ;
91-
92- const markers = [ ] ;
93- CITIES . forEach ( c => {
94- const m = L . marker ( c . center ) . addTo ( landingMap ) . bindPopup ( `<b>${ c . name } </b><br/><a href="#/city/${ c . slug } ">Open map</a>` ) ;
95- m . on ( 'click' , ( ) => goCity ( c . slug ) ) ;
96- markers . push ( m ) ;
97- } ) ;
98- try {
99- const group = L . featureGroup ( markers ) ;
100- landingMap . fitBounds ( group . getBounds ( ) . pad ( 0.2 ) ) ;
101- } catch ( e ) { }
102-
103- const listEl = document . getElementById ( 'cityList' ) ;
104- function renderList ( filter = '' ) {
105- const q = filter . trim ( ) . toLowerCase ( ) ;
106- listEl . innerHTML = '' ;
107- CITIES . filter ( c => ! q || c . name . toLowerCase ( ) . includes ( q ) ) . forEach ( c => {
108- const card = document . createElement ( 'div' ) ;
109- card . className = 'city-card' ;
110- card . innerHTML = `<div><div><strong>${ c . name } </strong></div><div class=\"muted\">${ c . layers . length } layer${ c . layers . length > 1 ?'s' :'' } </div></div><div>›</div>` ;
111- card . onclick = ( ) => goCity ( c . slug ) ;
112- listEl . appendChild ( card ) ;
113- } ) ;
114- }
115- renderList ( ) ;
116-
117- document . getElementById ( 'search' ) . addEventListener ( 'input' , ( e ) => renderList ( e . target . value ) ) ;
118- document . getElementById ( 'clear' ) . addEventListener ( 'click' , ( ) => { document . getElementById ( 'search' ) . value = '' ; renderList ( '' ) ; } ) ;
119-
120- router ( )
121- }
122-
123- // =====================
124- // COLOR SCALES
125- // =====================
126- function fmt ( v ) { return Number . isFinite ( v ) ? new Intl . NumberFormat ( ) . format ( v ) : 'n/a' ; }
127-
128- const BLUE = [ 0x00 , 0x00 , 0x4C ] ; // ~seismic min
129- const MID = [ 0xF0 , 0xF0 , 0xF0 ] ; // softer mid (try F7F7F7, EDEDED, etc.)
130- const RED = [ 0x80 , 0x00 , 0x00 ] ; // ~seismic max
131-
132- function lerp ( a , b , t ) { return a + ( b - a ) * t ; }
133- function toHex ( rgb ) {
134- return '#' + rgb . map ( v => Math . round ( v ) . toString ( 16 ) . padStart ( 2 , '0' ) ) . join ( '' ) ;
135- }
136-
137- /**
138- * Piecewise-linear color:
139- * v in [-1, 0]: BLUE → MID
140- * v in [ 0, 1]: MID → RED
141- */
142- const SEISMIC_COLORS = [
143- { stop : 0.00 , rgb : [ 0x00 , 0x00 , 0x4C ] } , // deep blue
144- { stop : 0.25 , rgb : [ 0x6E , 0x8B , 0xC6 ] } , // light blue
145- { stop : 0.50 , rgb : [ 0xFF , 0xFF , 0xFF ] } , // white
146- { stop : 0.75 , rgb : [ 0xD6 , 0x83 , 0x83 ] } , // light red
147- { stop : 1.00 , rgb : [ 0x80 , 0x00 , 0x00 ] } // dark red
148- ] ;
149-
150- function lerp ( a , b , t ) { return a + ( b - a ) * t ; }
151-
152- function toHex ( rgb ) {
153- return '#' + rgb . map ( v => Math . round ( v ) . toString ( 16 ) . padStart ( 2 , '0' ) ) . join ( '' ) ;
154- }
155-
156- function seismicColor ( v ) {
157- if ( ! Number . isFinite ( v ) ) return '#cccccc' ;
158- const t = Math . max ( - 1 , Math . min ( 1 , v ) ) ; // clamp
159- const x = ( t + 1 ) / 2 ; // normalize to [0,1]
160-
161- // find the two color stops surrounding x
162- let lower = SEISMIC_COLORS [ 0 ] , upper = SEISMIC_COLORS [ SEISMIC_COLORS . length - 1 ] ;
163- for ( let i = 0 ; i < SEISMIC_COLORS . length - 1 ; i ++ ) {
164- if ( x >= SEISMIC_COLORS [ i ] . stop && x <= SEISMIC_COLORS [ i + 1 ] . stop ) {
165- lower = SEISMIC_COLORS [ i ] ;
166- upper = SEISMIC_COLORS [ i + 1 ] ;
167- break ;
168- }
169- }
170-
171- const u = ( x - lower . stop ) / ( upper . stop - lower . stop ) ;
172- const rgb = [
173- lerp ( lower . rgb [ 0 ] , upper . rgb [ 0 ] , u ) ,
174- lerp ( lower . rgb [ 1 ] , upper . rgb [ 1 ] , u ) ,
175- lerp ( lower . rgb [ 2 ] , upper . rgb [ 2 ] , u )
176- ] ;
177- return toHex ( rgb ) ;
178- }
179-
180-
181- function makeQuantileScale ( values , n = 7 ) {
182- const sorted = values . filter ( v => Number . isFinite ( v ) ) . sort ( ( a , b ) => a - b ) ;
183- if ( ! sorted . length ) return v => '#cccccc' ;
184- const qs = Array . from ( { length :n + 1 } , ( _ , i ) => sorted [ Math . min ( sorted . length - 1 , Math . floor ( i * ( sorted . length - 1 ) / n ) ) ] ) ;
185- const palette = [ '#f1eef6' , '#d4b9da' , '#c994c7' , '#df65b0' , '#e7298a' , '#ce1256' , '#91003f' ] ;
186- return function ( v ) { if ( ! Number . isFinite ( v ) ) return '#cccccc' ; for ( let i = 0 ; i < n ; i ++ ) if ( v <= qs [ i + 1 ] ) return palette [ i ] ; return palette [ n - 1 ] ; } ;
187- }
188-
189- // =====================
190- // LEGEND CONTROL
191- // =====================
192- /*const Legend = L.Control.extend({
193- options: { position: 'bottomright' },
194- onAdd: function(){ const d = L.DomUtil.create('div','legend'); d.innerHTML = '<b>Legend</b><div class="scale" id="legend-scale"></div><div class="muted" id="legend-note"></div>'; this._div=d; return d; },
195- updateSwatches: function(colors, labels){
196- const scale=this._div.querySelector('#legend-scale');
197- scale.innerHTML='';
198- for(let i=0;i<colors.length;i++){
199- const sw=document.createElement('div'); sw.className='swatch'; sw.style.background=colors[i]; sw.title=labels && labels[i] ? labels[i] : '';
200- scale.appendChild(sw);
201- }
202- this._div.querySelector('#legend-note').textContent = '';
203- },
204- note: function(text){ this._div.querySelector('#legend-note').textContent = text || ''; }
205- });*/
206- const Legend = L . Control . extend ( {
207- options : { position : 'topright' } ,
208-
209- onAdd : function ( ) {
210- const div = L . DomUtil . create ( 'div' , 'legend' ) ;
211- div . innerHTML = `
212- <b>CDI</b>
213- <div class="legend-bar">
214- <div class="legend-gradient"></div>
215- <div class="legend-labels">
216- <div><b>1</b></div>
217- <div><b>0.5</b></div>
218- <div><b>0</b></div>
219- <div><b>-0.5</b></div>
220- <div><b>-1</b></div>
221- </div>
222- </div>` ;
223- return ( this . _div = div ) ;
224- } ,
225-
226- // not used for continuous scale, but keep for compatibility
227- updateSwatches : function ( ) { } ,
228- note : function ( text ) {
229- if ( this . _div ) this . _div . querySelector ( 'b' ) . textContent = text || 'CDI' ;
230- }
231- } ) ;
232-
233-
234- // =====================
235- // CSV LOADER / JOIN
236- // =====================
237- async function loadCsvMap ( url , keyCol , valueCol ) {
238- const res = await fetch ( url ) ;
239- if ( ! res . ok ) throw new Error ( 'Failed to load CSV ' + url ) ;
240- const text = await res . text ( ) ;
241- const parsed = Papa . parse ( text , { header :true , dynamicTyping :true , skipEmptyLines :true } ) ;
242- const map = new Map ( ) ;
243- parsed . data . forEach ( r => {
244- const k = r [ keyCol ] ;
245- const v = r [ valueCol ] ;
246- if ( k !== undefined ) map . set ( String ( k ) , v ) ;
247- } ) ;
248- return map ;
249- }
250-
251- // =====================
252- // CITY VIEW (reuses Leaflet)
253- // =====================
254- let cityMap , layerControl , legend , baseLayer ;
255- let currentOverlays = [ ] ;
256-
257- async function loadCity ( slug ) {
258- if ( layerControl ) {
259- // remove overlay layers from the map and from the control
260- currentOverlays . forEach ( lyr => {
261- try { cityMap . removeLayer ( lyr ) ; } catch ( e ) { }
262- try { layerControl . removeLayer ( lyr ) ; } catch ( e ) { }
263- } ) ;
264- currentOverlays = [ ] ;
265-
266- // (optional but clean) rebuild the control so no stale entries remain
267- try { cityMap . removeControl ( layerControl ) ; } catch ( e ) { }
268- layerControl = L . control . layers ( { 'Light' : baseLayer } , { } , { collapsed : false } ) . addTo ( cityMap ) ;
269- }
270-
271- // reset legend (optional)
272- if ( legend ) { legend . note ( '' ) ; }
273-
274- const city = CITIES . find ( c => c . slug === slug ) ;
275- if ( ! city ) return goHome ( ) ;
276-
277- // swap views
278- document . getElementById ( 'view-landing' ) . style . display = 'grid' ; // keep layout while we prep
279- document . getElementById ( 'view-city' ) . style . display = 'block' ;
280- document . getElementById ( 'view-landing' ) . style . display = 'none' ;
281- document . getElementById ( 'subtitle' ) . textContent = city . name ;
282- document . getElementById ( 'cityBreadcrumb' ) . textContent = city . name ;
283-
284- if ( ! cityMap ) {
285- cityMap = L . map ( 'map' , { zoomControl :true } ) ;
286- baseLayer = L . tileLayer ( 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' , { attribution : '© OpenStreetMap & CARTO' , maxZoom :19 } ) . addTo ( cityMap ) ;
287- layerControl = L . control . layers ( { 'Light' : baseLayer } , { } , { collapsed :false } ) . addTo ( cityMap ) ;
288- legend = new Legend ( { position : 'topright' } ) ; legend . addTo ( cityMap ) ;
289- }
290-
291- cityMap . setView ( city . center , city . zoom || 12 ) ;
292-
293- // remove existing overlays from control
294- const toRemove = [ ] ;
295- cityMap . eachLayer ( l => { /* keep base */ } ) ;
296- if ( layerControl && layerControl . _layers ) {
297- Object . values ( layerControl . _layers ) . forEach ( obj => { if ( obj . overlay ) toRemove . push ( obj . layer ) ; } ) ;
298- toRemove . forEach ( l => { try { cityMap . removeLayer ( l ) ; } catch ( e ) { } } ) ;
299- }
300-
301- const overlayGroups = { } ;
302-
303- for ( const def of city . layers ) {
304- try {
305- const res = await fetch ( def . url ) ;
306- if ( ! res . ok ) throw new Error ( 'failed ' + def . url ) ;
307- const gj = await res . json ( ) ;
308-
309- // Optional CSV join for CDI
310- let joinMap = null , csvId , geoId , valueCol ;
311- if ( def . join ) {
312- csvId = def . join . csvId || 'hexagon_id' ;
313- geoId = def . join . geoId || 'id' ;
314- valueCol = def . join . valueColumn || 'CDI' ;
315- joinMap = await loadCsvMap ( def . join . csv , csvId , valueCol ) ;
316- // annotate features with CDI
317- ( gj . features || [ ] ) . forEach ( f => {
318- const k = f . properties ?. [ geoId ] ;
319- if ( k !== undefined ) {
320- const v = joinMap . get ( String ( k ) ) ;
321- if ( v !== undefined ) f . properties [ valueCol ] = v ;
322- }
323- } ) ;
324- }
325-
326- // Determine color function
327- let styleColor , legendColors , legendLabels , legendNote ;
328- if ( def . join ) {
329- styleColor = f => seismicColor ( Number ( f . properties ?. [ valueCol ] ) ) ;
330- // Build a simple diverging legend with 9 swatches from -1 .. 1
331- const steps = 9 ;
332- legendColors = Array . from ( { length :steps } , ( _ , i ) => seismicColor ( ( i / ( steps - 1 ) ) * 2 - 1 ) ) ;
333- legendLabels = [ '-1 … 1 scale' ] ;
334- legendNote = `${ valueCol } ` ;
335- } else {
336- // fallback quantiles by def.valueProp
337- const vals = [ ] ; ( gj . features || [ ] ) . forEach ( f => { const v = Number ( f . properties ?. [ def . valueProp ] ) ; if ( Number . isFinite ( v ) ) vals . push ( v ) ; } ) ;
338- const color = makeQuantileScale ( vals , 7 ) ;
339- styleColor = f => color ( Number ( f . properties ?. [ def . valueProp ] ) ) ;
340- legendColors = [ '#f1eef6' , '#d4b9da' , '#c994c7' , '#df65b0' , '#e7298a' , '#ce1256' , '#91003f' ] ;
341- legendNote = def . valueProp || '' ;
342- }
343-
344-
345- const layer = L . geoJSON ( gj , {
346- style : f => ( { color :'transparent' /*'#333'*/ , weight :0 , fillOpacity :0.65 , fillColor : seismicColor ( Number ( f . properties ?. [ valueCol ] ) ) /*styleColor(f)*/ } ) ,
347- onEachFeature :( feature , lyr ) => { /* ... */ }
348- } ) ;
349-
350- // attach handler BEFORE adding the layer
351- layer . on ( 'add' , ( ) => {
352- /*legend.updateSwatches(legendColors);
353- legend.note(legendNote);*/
354- legend . note ( `${ valueCol } ` ) ;
355- } ) ;
356-
357- // now add to map (this will trigger the handler and show the legend immediately)
358- layer . addTo ( cityMap ) ;
359-
360- currentOverlays . push ( layer ) ; // <— track it
361- layerControl . addOverlay ( layer , def . name ) ; // register in the control
362-
363- /*overlayGroups[def.name] = layer;
364- layerControl.addOverlay(layer, def.name);*/
365-
366- // (optional) also call once explicitly to be extra safe:
367- legend . updateSwatches ( legendColors ) ;
368- legend . note ( legendNote ) ;
369-
370- /*const layer = L.geoJSON(gj, {
371- style: f => ({ color:'#333', weight:0.6, fillOpacity:0.85, fillColor: styleColor(f) }),
372- onEachFeature:(feature, lyr)=>{
373- const p=feature.properties||{};
374- const cdi = def.join ? Number(p[valueCol]) : undefined;
375- const showVal = def.join ? `${valueCol}: <b>${Number.isFinite(cdi)? cdi.toFixed(3):'n/a'}</b>` : `${def.valueProp}: <b>${fmt(Number(p[def.valueProp]))}</b>`;
376- const keys=Object.keys(p).filter(k=>!['uid','id'].includes(k));
377- const rows=keys.map(k=>`<tr><td><strong>${k}</strong></td><td>${p[k]}</td></tr>`).join('');
378- lyr.bindPopup(`<div><b>${def.name}</b><br/>${showVal}<table>${rows}</table></div>`);
379- }
380- }).addTo(cityMap);
381-
382- overlayGroups[def.name]=layer;
383- layerControl.addOverlay(layer, def.name);
384- // Update legend when toggled
385- layer.on('add', ()=> { legend.updateSwatches(legendColors); legend.note(legendNote); });*/
386- } catch ( e ) { console . error ( e ) ; }
387- }
388-
389- // fit to first overlay
390- const names = Object . keys ( overlayGroups ) ;
391- if ( names . length ) { try { cityMap . fitBounds ( overlayGroups [ names [ 0 ] ] . getBounds ( ) , { padding :[ 20 , 20 ] } ) ; } catch ( e ) { } }
392- }
393-
394- function goCity ( slug ) { location . hash = `#/city/${ slug } ` ; }
395- function goHome ( ) { location . hash = '#/' ; }
396-
397- document . getElementById ( 'backBtn' ) . addEventListener ( 'click' , goHome ) ;
398-
399- // simple hash router
400- function router ( ) {
401- const hash = location . hash || '#/' ;
402- if ( hash . startsWith ( '#/city/' ) ) {
403- const slug = hash . split ( '/' ) [ 2 ] ;
404- document . getElementById ( 'title' ) . textContent = 'City • ' + slug ;
405- loadCity ( slug ) ;
406- } else {
407- document . getElementById ( 'title' ) . textContent = 'Car Dependency Index' ;
408- document . getElementById ( 'subtitle' ) . textContent = '' ;
409- document . getElementById ( 'view-landing' ) . style . display = 'grid' ;
410- document . getElementById ( 'view-city' ) . style . display = 'none' ;
411- }
412- }
413- window . addEventListener ( 'hashchange' , router ) ;
414- boot ( ) ;
415- </ script >
46+ < script src ="https://cdn.jsdelivr.net/npm/papaparse@5.4.1/papaparse.min.js "> </ script >
47+ < script src ="app.js "> </ script >
41648</ body >
41749</ html >
0 commit comments