Skip to content

Commit f291b27

Browse files
committed
updates
1 parent 273cdba commit f291b27

1 file changed

Lines changed: 5 additions & 373 deletions

File tree

index.html

Lines changed: 5 additions & 373 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
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: '&copy; 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: '&copy; 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

Comments
 (0)