This commit is contained in:
parent
a271b43318
commit
fa135e1394
@ -4,10 +4,12 @@
|
||||
{% block title %}Sites · Run viewer{% endblock title %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cesium.com/downloads/cesiumjs/releases/1.114/Build/Cesium/Widgets/widgets.css"
|
||||
/>
|
||||
<style>
|
||||
#sites-map { height: 70vh; border-radius: 0.5rem; }
|
||||
#sites-map { height: 70vh; border-radius: 0.5rem; overflow: hidden; }
|
||||
.layer-chip { margin-right: .75rem; }
|
||||
.leaflet-popup-content { margin: 10px 14px; }
|
||||
.scrub-wrap { display:flex; align-items:center; gap:.5rem; }
|
||||
@ -69,6 +71,15 @@
|
||||
<label class="form-check-label" for="chkComp">Competition (sample)</label>
|
||||
</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>
|
||||
|
||||
@ -80,7 +91,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@ -89,8 +100,7 @@
|
||||
{% endblock content %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
<script src="https://cesium.com/downloads/cesiumjs/releases/1.114/Build/Cesium/Cesium.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
const q = new URLSearchParams(window.location.search);
|
||||
@ -113,23 +123,93 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Base map
|
||||
const map = L.map('sites-map', { zoomControl: true });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
{ maxZoom: 19, attribution: '© OpenStreetMap' }).addTo(map);
|
||||
const viewer = new Cesium.Viewer('sites-map', {
|
||||
imageryProvider: false,
|
||||
baseLayerPicker: false,
|
||||
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 = {
|
||||
iso: L.layerGroup().addTo(map),
|
||||
cand: L.layerGroup().addTo(map),
|
||||
demand: L.layerGroup(),
|
||||
comp: L.layerGroup(),
|
||||
iso: new Map(),
|
||||
cand: null,
|
||||
demand: null,
|
||||
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));
|
||||
document.getElementById('chkDemand').addEventListener('change', (e)=> toggle(layers.demand, e.target.checked));
|
||||
document.getElementById('chkComp').addEventListener('change', (e)=> toggle(layers.comp, e.target.checked));
|
||||
function toggle(layer, on){ if (on) layer.addTo(map); else map.removeLayer(layer); }
|
||||
|
||||
let isoVisible = true;
|
||||
document.getElementById('chkIso').addEventListener('change', (e)=> {
|
||||
isoVisible = e.target.checked;
|
||||
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 candURL = `/api/sites/geojson/candidates/${encodeURIComponent(sid)}`;
|
||||
@ -137,12 +217,10 @@
|
||||
const compURL = `/api/sites/geojson/pois_competition/${encodeURIComponent(sid)}`;
|
||||
const artURL = `/media/sites/run_${encodeURIComponent(sid)}.json`;
|
||||
|
||||
let fitBounds = null;
|
||||
|
||||
// 55A scrubber state
|
||||
const bandColors = ['#2E86AB','#F18F01','#C73E1D','#6C5B7B','#17B890','#7E57C2'];
|
||||
let minutes = [];
|
||||
const isoGroups = new Map(); // minute -> LayerGroup
|
||||
const isoGroups = new Map(); // minute -> array of features
|
||||
const minuteAreas = new Map(); // minute -> total area_km2
|
||||
let currentIndex = 0;
|
||||
let timer = null;
|
||||
@ -162,14 +240,13 @@
|
||||
function renderMinute(idx) {
|
||||
currentIndex = Math.max(0, Math.min(idx, minutes.length - 1));
|
||||
const m = minutes[currentIndex];
|
||||
minuteText.textContent = `${m} min`;
|
||||
minuteText.textContent = m != null ? `${m} min` : '—';
|
||||
areaText.textContent = fmtArea(minuteAreas.get(m));
|
||||
|
||||
layers.iso.clearLayers();
|
||||
const group = isoGroups.get(m);
|
||||
if (group) layers.iso.addLayer(group);
|
||||
layers.iso.forEach((ds, minute)=>{
|
||||
ds.show = isoVisible && minute === m;
|
||||
});
|
||||
|
||||
// 55B: move chart marker
|
||||
updateAreaMarker(m);
|
||||
}
|
||||
|
||||
@ -274,7 +351,7 @@
|
||||
|
||||
function updateAreaMarker(minute) {
|
||||
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 X = x(mins[i]);
|
||||
const Y = y(areas[i]);
|
||||
@ -285,6 +362,51 @@
|
||||
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
|
||||
Promise.allSettled([
|
||||
fetch(isoURL).then(r=>r.ok?r.json():null),
|
||||
@ -292,32 +414,45 @@
|
||||
fetch(demURL).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)
|
||||
]).then(([iso, cand, dem, comp, art])=>{
|
||||
]).then(async ([iso, cand, dem, comp, art])=>{
|
||||
const isoFC = iso.value || {type:'FeatureCollection',features:[]};
|
||||
const candFC = cand.value|| {type:'FeatureCollection',features:[]};
|
||||
const demFC = dem.value || {type:'FeatureCollection',features:[]};
|
||||
const compFC = comp.value|| {type:'FeatureCollection',features:[]};
|
||||
const artObj = art.value || null;
|
||||
|
||||
// Fit bounds to all isochrones
|
||||
const allIso = L.geoJSON(isoFC);
|
||||
const b = allIso.getBounds();
|
||||
if (b.isValid()) { map.fitBounds(b.pad(0.05)); fitBounds = b; }
|
||||
let bounds = null;
|
||||
|
||||
// Build minute groups + areas
|
||||
(isoFC.features || []).forEach((f)=>{
|
||||
const m = (f.properties && Number(f.properties.minutes)) || 0;
|
||||
if (!isoGroups.has(m)) isoGroups.set(m, L.layerGroup());
|
||||
const i = Math.max(0, Math.floor(m/5) - 1);
|
||||
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);
|
||||
if (!isoGroups.has(m)) isoGroups.set(m, []);
|
||||
isoGroups.get(m).push(f);
|
||||
const a = Number((f.properties && f.properties.area_km2) || 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);
|
||||
|
||||
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
|
||||
if (minutes.length >= 2) {
|
||||
const areas = minutes.map(m => minuteAreas.get(m) || 0);
|
||||
@ -340,49 +475,75 @@
|
||||
}
|
||||
|
||||
// Candidates
|
||||
L.geoJSON(candFC, {
|
||||
pointToLayer: (f, latlng)=>{
|
||||
const s = Number(f.properties?.score ?? 0.5);
|
||||
const r = 6 + Math.round(s * 10);
|
||||
return L.circleMarker(latlng, { radius: r, weight: 2, color: '#111', fillOpacity: 0.9 });
|
||||
},
|
||||
onEachFeature: (f, layer)=>{
|
||||
const p = f.properties || {};
|
||||
const score = Number(p.score ?? 0).toFixed(2);
|
||||
const br = `Demand ${Number(p.demand||0).toFixed(2)}, `
|
||||
+ `Comp ${Number(p.competition||0).toFixed(2)}, `
|
||||
+ `Access ${Number(p.access||0).toFixed(2)}`;
|
||||
layer.bindPopup(`<b>Rank ${p.rank || '—'}</b> · score ${score}<br><small>${br}</small>`);
|
||||
layers.cand = await loadGeoJson(candFC, { clampToGround: true });
|
||||
if (layers.cand) {
|
||||
viewer.dataSources.add(layers.cand);
|
||||
layers.cand.entities.values.forEach((entity)=>{
|
||||
const score = entity.properties?.score?.getValue(Cesium.JulianDate.now()) || 0;
|
||||
const rank = entity.properties?.rank?.getValue(Cesium.JulianDate.now()) || '—';
|
||||
const demand = entity.properties?.demand?.getValue(Cesium.JulianDate.now()) || 0;
|
||||
const competition = entity.properties?.competition?.getValue(Cesium.JulianDate.now()) || 0;
|
||||
const access = entity.properties?.access?.getValue(Cesium.JulianDate.now()) || 0;
|
||||
const radius = 6 + Math.round(score * 10);
|
||||
entity.point = new Cesium.PointGraphics({
|
||||
pixelSize: radius,
|
||||
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)
|
||||
L.geoJSON(demFC, {
|
||||
pointToLayer: (f, latlng)=> L.circleMarker(latlng, { radius: 3, color: '#d9534f', weight: 0, fillOpacity: .7 }),
|
||||
onEachFeature: (f, layer)=>{
|
||||
const pop = f.properties?.pop;
|
||||
if (pop) layer.bindPopup(`Population: ${Math.round(pop)}`);
|
||||
layers.demand = await loadGeoJson(demFC, { clampToGround: true });
|
||||
if (layers.demand) {
|
||||
viewer.dataSources.add(layers.demand);
|
||||
layers.demand.show = false;
|
||||
layers.demand.entities.values.forEach((entity)=>{
|
||||
entity.point = new Cesium.PointGraphics({
|
||||
pixelSize: 4,
|
||||
color: Cesium.Color.fromCssColorString('#d9534f'),
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.4),
|
||||
outlineWidth: 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
}).addTo(layers.demand);
|
||||
L.geoJSON(compFC, {
|
||||
pointToLayer: (f, latlng)=> L.circleMarker(latlng, { radius: 3, color: '#0d6efd', weight: 0, fillOpacity: .7 }),
|
||||
onEachFeature: (f, layer)=>{
|
||||
const name = f.properties?.name || '(poi)';
|
||||
const cat = f.properties?.category || '';
|
||||
layer.bindPopup(`${name}${cat?' — '+cat:''}`);
|
||||
|
||||
layers.comp = await loadGeoJson(compFC, { clampToGround: true });
|
||||
if (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 : ''}`;
|
||||
});
|
||||
}
|
||||
}).addTo(layers.comp);
|
||||
map.removeLayer(layers.demand);
|
||||
map.removeLayer(layers.comp);
|
||||
|
||||
document.getElementById('chkDemand').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)
|
||||
if (artObj && artObj.request) {
|
||||
const req = artObj.request;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user