Files
mcm-mfp/task1/fig8_delta.html

202 lines
7.2 KiB
HTML
Raw Normal View History

2026-01-19 19:43:57 +08:00
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fig.8: 2019 vs 2021 (CartoDB + Leaflet)</title>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
crossorigin=""
/>
<style>
html,
body,
#map {
height: 100%;
margin: 0;
}
.panel {
background: rgba(255, 255, 255, 0.92);
padding: 10px 12px;
border-radius: 10px;
box-shadow: 0 1px 10px rgba(0, 0, 0, 0.15);
font: 12px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
}
.panel .title {
font-weight: 800;
margin-bottom: 6px;
}
.panel .row {
display: flex;
gap: 10px;
align-items: center;
margin-top: 6px;
}
.panel label {
cursor: pointer;
user-select: none;
}
.legend-bar {
height: 10px;
border-radius: 6px;
background: linear-gradient(90deg, #2563eb, #f3f4f6, #dc2626);
margin-top: 6px;
}
.muted {
color: rgba(0, 0, 0, 0.65);
}
</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="./fig1_points.js"></script>
<script>
const points = window.FIG1_POINTS || [];
if (!points.length) {
alert("FIG1_POINTS 为空:请确认 fig1_points.js 与本文件在同一目录。");
}
const map = L.map("map", { preferCanvas: true });
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
subdomains: "abcd",
maxZoom: 20,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
}).addTo(map);
const total2019 = points.reduce((s, p) => s + (p.visits_2019 ?? 0), 0);
const total2021 = points.reduce((s, p) => s + (p.k ?? 0), 0);
const scale2019 = total2019 > 0 ? total2021 / total2019 : 1;
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
function radiusLinear(val, vmin, vmax, rmin, rmax) {
const t = clamp01((val - vmin) / (vmax - vmin || 1));
return lerp(rmin, rmax, t);
}
function divergingColor(t) {
// t in [-1,1], blue -> light -> red
const x = clamp01((t + 1) / 2);
const c1 = [37, 99, 235]; // blue
const c2 = [243, 244, 246]; // light gray
const c3 = [220, 38, 38]; // red
const from = x < 0.5 ? c1 : c2;
const to = x < 0.5 ? c2 : c3;
const tt = x < 0.5 ? x / 0.5 : (x - 0.5) / 0.5;
const r = Math.round(lerp(from[0], to[0], tt));
const g = Math.round(lerp(from[1], to[1], tt));
const b = Math.round(lerp(from[2], to[2], tt));
return `rgb(${r},${g},${b})`;
}
const v2019 = points.map((p) => p.visits_2019);
const v2021 = points.map((p) => p.k);
const vDelta = points.map((p) => (p.k ?? 0) - (p.visits_2019 ?? 0) * scale2019);
const v2019Min = Math.min(...v2019);
const v2019Max = Math.max(...v2019);
const v2021Min = Math.min(...v2021);
const v2021Max = Math.max(...v2021);
const deltaAbsMax = Math.max(...vDelta.map((d) => Math.abs(d))) || 1;
const layer = L.featureGroup();
const markers = [];
for (const p of points) {
const marker = L.circleMarker([p.lat, p.lng], {
radius: 6,
color: "white",
weight: 1,
fillColor: "#3b82f6",
fillOpacity: 0.85,
});
marker.bindPopup(() => {
const scaled = (p.visits_2019 ?? 0) * scale2019;
const delta = (p.k ?? 0) - scaled;
const sign = delta >= 0 ? "+" : "";
return (
`<div style="font: 13px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;">` +
`<div style="font-weight:800;margin-bottom:6px;">${p.site_id}. ${p.site_name}</div>` +
`<div><b>2019 visits</b>: ${p.visits_2019}</div>` +
`<div><b>2021 plan (k)</b>: ${p.k}</div>` +
`<div class="muted"><b>2019 scaled → 2021 total</b>: ${scaled.toFixed(2)}</div>` +
`<div><b>Δ</b> = k - scaled2019: ${sign}${delta.toFixed(2)}</div>` +
`</div>`
);
});
layer.addLayer(marker);
markers.push({ p, marker });
}
layer.addTo(map);
map.fitBounds(layer.getBounds().pad(0.12));
function applyMode(mode) {
for (const { p, marker } of markers) {
if (mode === "2019") {
const r = radiusLinear(p.visits_2019, v2019Min, v2019Max, 4, 18);
marker.setStyle({ radius: r, fillColor: "#3b82f6" });
} else if (mode === "2021") {
const r = radiusLinear(p.k, v2021Min, v2021Max, 4, 18);
marker.setStyle({ radius: r, fillColor: "#10b981" });
} else {
const scaled = (p.visits_2019 ?? 0) * scale2019;
const delta = (p.k ?? 0) - scaled;
const t = Math.max(-1, Math.min(1, delta / deltaAbsMax));
const r = radiusLinear(Math.abs(delta), 0, deltaAbsMax, 4, 18);
marker.setStyle({ radius: r, fillColor: divergingColor(t) });
}
}
const label = document.getElementById("modeLabel");
const meta = document.getElementById("meta");
if (label && meta) {
if (mode === "2019") {
label.textContent = "2019 visits (Point size)";
meta.textContent = `Total: ${total2019}`;
} else if (mode === "2021") {
label.textContent = "2021 Plan k (Point size)";
meta.textContent = `总计: ${total2021}`;
} else {
label.textContent = "Δ = 2021(k) - 2019_scaled(Point size=|Δ|Color=Δ)";
meta.textContent = `Fixed: ${scale2019.toFixed(4)}`;
}
}
}
const control = L.control({ position: "topright" });
control.onAdd = function () {
const div = L.DomUtil.create("div", "panel");
div.innerHTML =
`<div class="title">Fig.8: 2019 vs 2021</div>` +
`<div id="modeLabel" style="font-weight:700"></div>` +
`<div class="row">` +
`<label><input type="radio" name="mode" value="2019" checked /> 2019</label>` +
`<label><input type="radio" name="mode" value="2021" /> 2021</label>` +
`<label><input type="radio" name="mode" value="delta" /> Δ</label>` +
`</div>` +
`<div class="legend-bar"></div>` +
`<div id="meta" class="muted" style="margin-top:6px;"></div>` +
`<div class="muted" style="margin-top:6px;">Click to view details</div>`;
L.DomEvent.disableClickPropagation(div);
return div;
};
control.addTo(map);
document.addEventListener("change", (e) => {
const t = e.target;
if (t && t.name === "mode") applyMode(t.value);
});
applyMode("2019");
</script>
</body>
</html>