Celium for sites
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Ekaropolus 2026-01-03 03:24:53 -06:00
parent a271b43318
commit fa135e1394

View File

@ -4,10 +4,12 @@
{% block title %}Sites · Run viewer{% endblock title %} {% block title %}Sites · Run viewer{% endblock title %}
{% block extra_css %} {% block extra_css %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" <link
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""> rel="stylesheet"
href="https://cesium.com/downloads/cesiumjs/releases/1.114/Build/Cesium/Widgets/widgets.css"
/>
<style> <style>
#sites-map { height: 70vh; border-radius: 0.5rem; } #sites-map { height: 70vh; border-radius: 0.5rem; overflow: hidden; }
.layer-chip { margin-right: .75rem; } .layer-chip { margin-right: .75rem; }
.leaflet-popup-content { margin: 10px 14px; } .leaflet-popup-content { margin: 10px 14px; }
.scrub-wrap { display:flex; align-items:center; gap:.5rem; } .scrub-wrap { display:flex; align-items:center; gap:.5rem; }
@ -69,6 +71,15 @@
<label class="form-check-label" for="chkComp">Competition (sample)</label> <label class="form-check-label" for="chkComp">Competition (sample)</label>
</div> </div>
</div> </div>
<div class="d-flex align-items-center">
<label class="me-2">Basemap</label>
<select id="basemap" class="form-select form-select-sm" style="min-width: 180px;">
<option value="osm">OpenStreetMap</option>
<option value="esri">ESRI World Imagery</option>
<option value="carto_light">CARTO Light</option>
<option value="carto_dark">CARTO Dark</option>
</select>
</div>
</div> </div>
</div> </div>
@ -80,7 +91,7 @@
</div> </div>
<div class="text-muted small mt-1"> <div class="text-muted small mt-1">
Tip: hover candidates for score & breakdown; toggle layers on the right; use the scrubber to switch minutes. Tip: click candidates for score & breakdown; toggle layers on the right; use the scrubber to switch minutes.
</div> </div>
</div> </div>
</div> </div>
@ -89,8 +100,7 @@
{% endblock content %} {% endblock content %}
{% block extra_js %} {% block extra_js %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" <script src="https://cesium.com/downloads/cesiumjs/releases/1.114/Build/Cesium/Cesium.js"></script>
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script> <script>
(function(){ (function(){
const q = new URLSearchParams(window.location.search); const q = new URLSearchParams(window.location.search);
@ -113,23 +123,93 @@
return; return;
} }
// Base map const viewer = new Cesium.Viewer('sites-map', {
const map = L.map('sites-map', { zoomControl: true }); imageryProvider: false,
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', baseLayerPicker: false,
{ maxZoom: 19, attribution: '&copy; OpenStreetMap' }).addTo(map); geocoder: false,
homeButton: false,
navigationHelpButton: false,
sceneModePicker: false,
animation: false,
timeline: false,
infoBox: true,
selectionIndicator: true,
});
const basemapSelect = document.getElementById('basemap');
const basemapLayers = {};
function getBasemapProvider(key) {
if (key === 'osm') {
return new Cesium.OpenStreetMapImageryProvider({
url: 'https://tile.openstreetmap.org/',
});
}
if (key === 'esri') {
return new Cesium.UrlTemplateImageryProvider({
url: 'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
credit: 'Esri',
});
}
if (key === 'carto_light') {
return new Cesium.UrlTemplateImageryProvider({
url: 'https://cartodb-basemaps-a.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
credit: 'CARTO',
});
}
if (key === 'carto_dark') {
return new Cesium.UrlTemplateImageryProvider({
url: 'https://cartodb-basemaps-a.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
credit: 'CARTO',
});
}
return null;
}
function setBaseLayer(key) {
let layer = basemapLayers[key];
if (!layer) {
const provider = getBasemapProvider(key);
if (!provider) {
return;
}
layer = viewer.imageryLayers.addImageryProvider(provider);
basemapLayers[key] = layer;
}
Object.keys(basemapLayers).forEach((k) => {
basemapLayers[k].show = k === key;
});
viewer.imageryLayers.raiseToTop(layer);
}
basemapSelect.addEventListener('change', () => {
setBaseLayer(basemapSelect.value);
});
setBaseLayer(basemapSelect.value);
viewer.scene.globe.depthTestAgainstTerrain = false;
// Layers
const layers = { const layers = {
iso: L.layerGroup().addTo(map), iso: new Map(),
cand: L.layerGroup().addTo(map), cand: null,
demand: L.layerGroup(), demand: null,
comp: L.layerGroup(), comp: null,
}; };
document.getElementById('chkIso').addEventListener('change', (e)=> toggle(layers.iso, e.target.checked));
document.getElementById('chkCand').addEventListener('change', (e)=> toggle(layers.cand, e.target.checked)); let isoVisible = true;
document.getElementById('chkDemand').addEventListener('change', (e)=> toggle(layers.demand, e.target.checked)); document.getElementById('chkIso').addEventListener('change', (e)=> {
document.getElementById('chkComp').addEventListener('change', (e)=> toggle(layers.comp, e.target.checked)); isoVisible = e.target.checked;
function toggle(layer, on){ if (on) layer.addTo(map); else map.removeLayer(layer); } renderMinute(currentIndex);
});
document.getElementById('chkCand').addEventListener('change', (e)=> {
if (layers.cand) layers.cand.show = e.target.checked;
});
document.getElementById('chkDemand').addEventListener('change', (e)=> {
if (layers.demand) layers.demand.show = e.target.checked;
});
document.getElementById('chkComp').addEventListener('change', (e)=> {
if (layers.comp) layers.comp.show = e.target.checked;
});
const isoURL = `/api/sites/geojson/isochrones/${encodeURIComponent(sid)}`; const isoURL = `/api/sites/geojson/isochrones/${encodeURIComponent(sid)}`;
const candURL = `/api/sites/geojson/candidates/${encodeURIComponent(sid)}`; const candURL = `/api/sites/geojson/candidates/${encodeURIComponent(sid)}`;
@ -137,12 +217,10 @@
const compURL = `/api/sites/geojson/pois_competition/${encodeURIComponent(sid)}`; const compURL = `/api/sites/geojson/pois_competition/${encodeURIComponent(sid)}`;
const artURL = `/media/sites/run_${encodeURIComponent(sid)}.json`; const artURL = `/media/sites/run_${encodeURIComponent(sid)}.json`;
let fitBounds = null;
// 55A scrubber state // 55A scrubber state
const bandColors = ['#2E86AB','#F18F01','#C73E1D','#6C5B7B','#17B890','#7E57C2']; const bandColors = ['#2E86AB','#F18F01','#C73E1D','#6C5B7B','#17B890','#7E57C2'];
let minutes = []; let minutes = [];
const isoGroups = new Map(); // minute -> LayerGroup const isoGroups = new Map(); // minute -> array of features
const minuteAreas = new Map(); // minute -> total area_km2 const minuteAreas = new Map(); // minute -> total area_km2
let currentIndex = 0; let currentIndex = 0;
let timer = null; let timer = null;
@ -162,14 +240,13 @@
function renderMinute(idx) { function renderMinute(idx) {
currentIndex = Math.max(0, Math.min(idx, minutes.length - 1)); currentIndex = Math.max(0, Math.min(idx, minutes.length - 1));
const m = minutes[currentIndex]; const m = minutes[currentIndex];
minuteText.textContent = `${m} min`; minuteText.textContent = m != null ? `${m} min` : '—';
areaText.textContent = fmtArea(minuteAreas.get(m)); areaText.textContent = fmtArea(minuteAreas.get(m));
layers.iso.clearLayers(); layers.iso.forEach((ds, minute)=>{
const group = isoGroups.get(m); ds.show = isoVisible && minute === m;
if (group) layers.iso.addLayer(group); });
// 55B: move chart marker
updateAreaMarker(m); updateAreaMarker(m);
} }
@ -274,7 +351,7 @@
function updateAreaMarker(minute) { function updateAreaMarker(minute) {
if (!chartState) return; if (!chartState) return;
const { mins, areas, x, y, padT, H } = chartState; const { mins, areas, x, y } = chartState;
const i = Math.max(0, mins.indexOf(minute)); const i = Math.max(0, mins.indexOf(minute));
const X = x(mins[i]); const X = x(mins[i]);
const Y = y(areas[i]); const Y = y(areas[i]);
@ -285,6 +362,51 @@
if (dot) { dot.setAttribute('cx', X); dot.setAttribute('cy', Y); } if (dot) { dot.setAttribute('cx', X); dot.setAttribute('cy', Y); }
} }
function extendBounds(bounds, lon, lat) {
if (!bounds) {
return { minLon: lon, minLat: lat, maxLon: lon, maxLat: lat };
}
return {
minLon: Math.min(bounds.minLon, lon),
minLat: Math.min(bounds.minLat, lat),
maxLon: Math.max(bounds.maxLon, lon),
maxLat: Math.max(bounds.maxLat, lat),
};
}
function boundsFromGeometry(bounds, geom) {
if (!geom) return bounds;
if (geom.type === 'Point') {
return extendBounds(bounds, geom.coordinates[0], geom.coordinates[1]);
}
if (geom.type === 'Polygon') {
geom.coordinates.forEach(ring => {
ring.forEach(coord => { bounds = extendBounds(bounds, coord[0], coord[1]); });
});
}
if (geom.type === 'MultiPolygon') {
geom.coordinates.forEach(poly => {
poly.forEach(ring => {
ring.forEach(coord => { bounds = extendBounds(bounds, coord[0], coord[1]); });
});
});
}
return bounds;
}
function flyToBounds(bounds) {
if (!bounds) return;
const rect = Cesium.Rectangle.fromDegrees(bounds.minLon, bounds.minLat, bounds.maxLon, bounds.maxLat);
viewer.camera.flyTo({ destination: rect });
}
async function loadGeoJson(fc, options) {
if (!fc || !fc.features || fc.features.length === 0) {
return null;
}
return Cesium.GeoJsonDataSource.load(fc, options);
}
// Fetch everything // Fetch everything
Promise.allSettled([ Promise.allSettled([
fetch(isoURL).then(r=>r.ok?r.json():null), fetch(isoURL).then(r=>r.ok?r.json():null),
@ -292,32 +414,45 @@
fetch(demURL).then(r=>r.ok?r.json():null), fetch(demURL).then(r=>r.ok?r.json():null),
fetch(compURL).then(r=>r.ok?r.json():null), fetch(compURL).then(r=>r.ok?r.json():null),
fetch(artURL).then(r=>r.ok?r.json():null) fetch(artURL).then(r=>r.ok?r.json():null)
]).then(([iso, cand, dem, comp, art])=>{ ]).then(async ([iso, cand, dem, comp, art])=>{
const isoFC = iso.value || {type:'FeatureCollection',features:[]}; const isoFC = iso.value || {type:'FeatureCollection',features:[]};
const candFC = cand.value|| {type:'FeatureCollection',features:[]}; const candFC = cand.value|| {type:'FeatureCollection',features:[]};
const demFC = dem.value || {type:'FeatureCollection',features:[]}; const demFC = dem.value || {type:'FeatureCollection',features:[]};
const compFC = comp.value|| {type:'FeatureCollection',features:[]}; const compFC = comp.value|| {type:'FeatureCollection',features:[]};
const artObj = art.value || null; const artObj = art.value || null;
// Fit bounds to all isochrones let bounds = null;
const allIso = L.geoJSON(isoFC);
const b = allIso.getBounds();
if (b.isValid()) { map.fitBounds(b.pad(0.05)); fitBounds = b; }
// Build minute groups + areas // Build minute groups + areas
(isoFC.features || []).forEach((f)=>{ (isoFC.features || []).forEach((f)=>{
const m = (f.properties && Number(f.properties.minutes)) || 0; const m = (f.properties && Number(f.properties.minutes)) || 0;
if (!isoGroups.has(m)) isoGroups.set(m, L.layerGroup()); if (!isoGroups.has(m)) isoGroups.set(m, []);
const i = Math.max(0, Math.floor(m/5) - 1); isoGroups.get(m).push(f);
const c = bandColors[i % bandColors.length];
const lyr = L.geoJSON(f, { style: { color:c, weight:2, fillColor:c, fillOpacity:.25 } });
isoGroups.get(m).addLayer(lyr);
const a = Number((f.properties && f.properties.area_km2) || 0); const a = Number((f.properties && f.properties.area_km2) || 0);
minuteAreas.set(m, (minuteAreas.get(m) || 0) + (isFinite(a) ? a : 0)); minuteAreas.set(m, (minuteAreas.get(m) || 0) + (isFinite(a) ? a : 0));
bounds = boundsFromGeometry(bounds, f.geometry);
}); });
minutes = Array.from(isoGroups.keys()).sort((a,b)=>a-b); minutes = Array.from(isoGroups.keys()).sort((a,b)=>a-b);
for (const minute of minutes) {
const i = Math.max(0, Math.floor(minute/5) - 1);
const colorHex = bandColors[i % bandColors.length];
const color = Cesium.Color.fromCssColorString(colorHex);
const fc = { type: 'FeatureCollection', features: isoGroups.get(minute) || [] };
const ds = await loadGeoJson(fc, {
stroke: color,
fill: color.withAlpha(0.25),
strokeWidth: 2,
clampToGround: true,
});
if (ds) {
ds.show = false;
viewer.dataSources.add(ds);
layers.iso.set(minute, ds);
}
}
// 55B: draw chart if ≥2 bands // 55B: draw chart if ≥2 bands
if (minutes.length >= 2) { if (minutes.length >= 2) {
const areas = minutes.map(m => minuteAreas.get(m) || 0); const areas = minutes.map(m => minuteAreas.get(m) || 0);
@ -340,49 +475,75 @@
} }
// Candidates // Candidates
L.geoJSON(candFC, { layers.cand = await loadGeoJson(candFC, { clampToGround: true });
pointToLayer: (f, latlng)=>{ if (layers.cand) {
const s = Number(f.properties?.score ?? 0.5); viewer.dataSources.add(layers.cand);
const r = 6 + Math.round(s * 10); layers.cand.entities.values.forEach((entity)=>{
return L.circleMarker(latlng, { radius: r, weight: 2, color: '#111', fillOpacity: 0.9 }); const score = entity.properties?.score?.getValue(Cesium.JulianDate.now()) || 0;
}, const rank = entity.properties?.rank?.getValue(Cesium.JulianDate.now()) || '—';
onEachFeature: (f, layer)=>{ const demand = entity.properties?.demand?.getValue(Cesium.JulianDate.now()) || 0;
const p = f.properties || {}; const competition = entity.properties?.competition?.getValue(Cesium.JulianDate.now()) || 0;
const score = Number(p.score ?? 0).toFixed(2); const access = entity.properties?.access?.getValue(Cesium.JulianDate.now()) || 0;
const br = `Demand ${Number(p.demand||0).toFixed(2)}, ` const radius = 6 + Math.round(score * 10);
+ `Comp ${Number(p.competition||0).toFixed(2)}, ` entity.point = new Cesium.PointGraphics({
+ `Access ${Number(p.access||0).toFixed(2)}`; pixelSize: radius,
layer.bindPopup(`<b>Rank ${p.rank || '—'}</b> · score ${score}<br><small>${br}</small>`); color: Cesium.Color.fromCssColorString('#1B998B'),
outlineColor: Cesium.Color.BLACK.withAlpha(0.7),
outlineWidth: 1,
});
entity.description = `<b>Rank ${rank}</b> · score ${Number(score).toFixed(2)}<br>` +
`<small>Demand ${Number(demand).toFixed(2)}, ` +
`Comp ${Number(competition).toFixed(2)}, ` +
`Access ${Number(access).toFixed(2)}</small>`;
});
if (!bounds) {
candFC.features.forEach((f)=>{ bounds = boundsFromGeometry(bounds, f.geometry); });
} }
}).addTo(layers.cand);
if (!fitBounds) {
const b2 = L.geoJSON(candFC).getBounds();
if (b2.isValid()) map.fitBounds(b2.pad(0.1));
else map.setView([19.4326, -99.1332], 11);
} }
// Demand & Competition (start hidden) // Demand & Competition (start hidden)
L.geoJSON(demFC, { layers.demand = await loadGeoJson(demFC, { clampToGround: true });
pointToLayer: (f, latlng)=> L.circleMarker(latlng, { radius: 3, color: '#d9534f', weight: 0, fillOpacity: .7 }), if (layers.demand) {
onEachFeature: (f, layer)=>{ viewer.dataSources.add(layers.demand);
const pop = f.properties?.pop; layers.demand.show = false;
if (pop) layer.bindPopup(`Population: ${Math.round(pop)}`); layers.demand.entities.values.forEach((entity)=>{
} entity.point = new Cesium.PointGraphics({
}).addTo(layers.demand); pixelSize: 4,
L.geoJSON(compFC, { color: Cesium.Color.fromCssColorString('#d9534f'),
pointToLayer: (f, latlng)=> L.circleMarker(latlng, { radius: 3, color: '#0d6efd', weight: 0, fillOpacity: .7 }), outlineColor: Cesium.Color.BLACK.withAlpha(0.4),
onEachFeature: (f, layer)=>{ outlineWidth: 1,
const name = f.properties?.name || '(poi)'; });
const cat = f.properties?.category || ''; });
layer.bindPopup(`${name}${cat?' — '+cat:''}`); }
}
}).addTo(layers.comp); layers.comp = await loadGeoJson(compFC, { clampToGround: true });
map.removeLayer(layers.demand); if (layers.comp) {
map.removeLayer(layers.comp); viewer.dataSources.add(layers.comp);
layers.comp.show = false;
layers.comp.entities.values.forEach((entity)=>{
const name = entity.properties?.name?.getValue(Cesium.JulianDate.now()) || '(poi)';
const cat = entity.properties?.category?.getValue(Cesium.JulianDate.now()) || '';
entity.point = new Cesium.PointGraphics({
pixelSize: 4,
color: Cesium.Color.fromCssColorString('#0d6efd'),
outlineColor: Cesium.Color.BLACK.withAlpha(0.4),
outlineWidth: 1,
});
entity.description = `${name}${cat ? ' — ' + cat : ''}`;
});
}
document.getElementById('chkDemand').checked = false; document.getElementById('chkDemand').checked = false;
document.getElementById('chkComp').checked = false; document.getElementById('chkComp').checked = false;
if (bounds) {
flyToBounds(bounds);
} else {
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(-99.1332, 19.4326, 14000),
});
}
// Meta (best-effort via artifact) // Meta (best-effort via artifact)
if (artObj && artObj.request) { if (artObj && artObj.request) {
const req = artObj.request; const req = artObj.request;
@ -397,7 +558,7 @@
let _rt = null; let _rt = null;
window.addEventListener('resize', ()=>{ window.addEventListener('resize', ()=>{
if (!_rt) { if (!_rt) {
_rt = requestAnimationFrame(()=>{ _rt = requestAnimationFrame(()=>{
if (minutes.length >= 2) { if (minutes.length >= 2) {
const areas = minutes.map(m => minuteAreas.get(m) || 0); const areas = minutes.map(m => minuteAreas.get(m) || 0);
drawAreaChart(minutes, areas); drawAreaChart(minutes, areas);