Park-heat map: Esri World Imagery basemap under the glows

Web-Mercator slippy projection for both tiles and rides (exact
alignment), dark tint to keep the garden tone, label halos for
legibility over imagery, required Esri attribution. Google tiles were
ruled out (ToS requires the Maps JS API + key/billing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wes
2026-07-22 11:19:39 -04:00
co-authored by Claude Fable 5
parent 16b837a2e7
commit df5b58c4fd
+80 -36
View File
@@ -284,9 +284,10 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
<script> <script>
// Homepage park-heat map: one glow per ride from /knoebels/rides // Homepage park-heat map: one glow per ride from /knoebels/rides
// (live while the park is open, typical weekend peak otherwise). // (live while the park is open, typical weekend peak otherwise),
// Color = wait class, glow radius = magnitude, top-5 direct labels + // over darkened Esri World Imagery so the layout has real bearings.
// native tooltips carry the values. Fail-closed. // Web-Mercator slippy math for BOTH tiles and rides = exact alignment.
// Fail-closed.
const knbFig = document.getElementById('knb-map'); const knbFig = document.getElementById('knb-map');
if (knbFig) { if (knbFig) {
try { try {
@@ -296,24 +297,52 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
const rides: any[] = data.rides; const rides: any[] = data.rides;
if (rides.length < 10) throw new Error('too few rides'); if (rides.length < 10) throw new Error('too few rides');
const W = 640; const Z = 17;
const PAD = 36; const T = 256;
const lats = rides.map((r) => r.lat); const n = 2 ** Z;
const lons = rides.map((r) => r.lon); const tx = (lo: number) => ((lo + 180) / 360) * n;
const mnLa = Math.min(...lats); const ty = (la: number) => {
const mxLa = Math.max(...lats); const r = (la * Math.PI) / 180;
const mnLo = Math.min(...lons); return (
const mxLo = Math.max(...lons); ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * n
const cosLat = Math.cos((((mnLa + mxLa) / 2) * Math.PI) / 180); );
const H = };
Math.round( const xs = rides.map((r) => tx(r.lon));
(W - 2 * PAD) * ((mxLa - mnLa) / ((mxLo - mnLo) * cosLat)), const ys = rides.map((r) => ty(r.lat));
) + const x0 = Math.floor(Math.min(...xs) - 0.3);
2 * PAD; const x1 = Math.floor(Math.max(...xs) + 0.3);
const px = (lo: number) => const y0 = Math.floor(Math.min(...ys) - 0.25);
PAD + ((lo - mnLo) / (mxLo - mnLo)) * (W - 2 * PAD); const y1 = Math.floor(Math.max(...ys) + 0.25);
const py = (la: number) => const W = (x1 - x0 + 1) * T;
H - PAD - ((la - mnLa) / (mxLa - mnLa)) * (H - 2 * PAD); const H = (y1 - y0 + 1) * T;
const px = (lo: number) => (tx(lo) - x0) * T;
const py = (la: number) => (ty(la) - y0) * T;
const k = W / 640; // scale marks/labels with the tile canvas
const NS = 'http://www.w3.org/2000/svg';
const svg = document.getElementById('knb-map-svg')!;
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
for (let xi = x0; xi <= x1; xi++) {
for (let yi = y0; yi <= y1; yi++) {
const img = document.createElementNS(NS, 'image');
img.setAttribute(
'href',
`https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${Z}/${yi}/${xi}`,
);
img.setAttribute('x', String((xi - x0) * T));
img.setAttribute('y', String((yi - y0) * T));
img.setAttribute('width', String(T));
img.setAttribute('height', String(T));
svg.append(img);
}
}
const tint = document.createElementNS(NS, 'rect');
tint.setAttribute('width', String(W));
tint.setAttribute('height', String(H));
tint.setAttribute('fill', '#0b0d12');
tint.setAttribute('opacity', '0.45');
svg.append(tint);
const CLR: Record<string, string> = { const CLR: Record<string, string> = {
quiet: '#16a34a', quiet: '#16a34a',
@@ -324,13 +353,10 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
w < 10 ? 'quiet' : w < 20 ? 'busy' : 'packed'; w < 10 ? 'quiet' : w < 20 ? 'busy' : 'packed';
const maxWait = Math.max(...rides.map((r) => r.wait_min), 1); const maxWait = Math.max(...rides.map((r) => r.wait_min), 1);
const NS = 'http://www.w3.org/2000/svg';
const svg = document.getElementById('knb-map-svg')!;
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
const defs = document.createElementNS(NS, 'defs'); const defs = document.createElementNS(NS, 'defs');
for (const [k, c] of Object.entries(CLR)) { for (const [key, c] of Object.entries(CLR)) {
const g = document.createElementNS(NS, 'radialGradient'); const g = document.createElementNS(NS, 'radialGradient');
g.id = `knb-g-${k}`; g.id = `knb-g-${key}`;
for (const [off, op] of [ for (const [off, op] of [
['0%', '0.55'], ['0%', '0.55'],
['55%', '0.22'], ['55%', '0.22'],
@@ -346,24 +372,26 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
} }
svg.append(defs); svg.append(defs);
// draw coolest-first so hot glows sit on top
for (const r of [...rides].sort((a, b) => a.wait_min - b.wait_min)) { for (const r of [...rides].sort((a, b) => a.wait_min - b.wait_min)) {
const k = cls(r.wait_min); const key = cls(r.wait_min);
const glow = document.createElementNS(NS, 'circle'); const glow = document.createElementNS(NS, 'circle');
glow.setAttribute('cx', String(px(r.lon))); glow.setAttribute('cx', String(px(r.lon)));
glow.setAttribute('cy', String(py(r.lat))); glow.setAttribute('cy', String(py(r.lat)));
glow.setAttribute('r', String(9 + (r.wait_min / maxWait) * 30)); glow.setAttribute(
glow.setAttribute('fill', `url(#knb-g-${k})`); 'r',
String((9 + (r.wait_min / maxWait) * 30) * k),
);
glow.setAttribute('fill', `url(#knb-g-${key})`);
glow.style.mixBlendMode = 'screen'; glow.style.mixBlendMode = 'screen';
const core = document.createElementNS(NS, 'circle'); const core = document.createElementNS(NS, 'circle');
core.setAttribute('cx', String(px(r.lon))); core.setAttribute('cx', String(px(r.lon)));
core.setAttribute('cy', String(py(r.lat))); core.setAttribute('cy', String(py(r.lat)));
core.setAttribute('r', '2.2'); core.setAttribute('r', String(2.4 * k));
core.setAttribute('fill', CLR[k]); core.setAttribute('fill', CLR[key]);
const hit = document.createElementNS(NS, 'circle'); const hit = document.createElementNS(NS, 'circle');
hit.setAttribute('cx', String(px(r.lon))); hit.setAttribute('cx', String(px(r.lon)));
hit.setAttribute('cy', String(py(r.lat))); hit.setAttribute('cy', String(py(r.lat)));
hit.setAttribute('r', '12'); hit.setAttribute('r', String(12 * k));
hit.setAttribute('fill', 'transparent'); hit.setAttribute('fill', 'transparent');
const tip = document.createElementNS(NS, 'title'); const tip = document.createElementNS(NS, 'title');
tip.textContent = `${r.name} — ${r.wait_min} min`; tip.textContent = `${r.name} — ${r.wait_min} min`;
@@ -371,20 +399,36 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
svg.append(glow, core, hit); svg.append(glow, core, hit);
} }
// direct labels on the five longest waits, alternating above/below
const top = [...rides] const top = [...rides]
.sort((a, b) => b.wait_min - a.wait_min) .sort((a, b) => b.wait_min - a.wait_min)
.slice(0, 5) .slice(0, 5)
.sort((a, b) => a.lon - b.lon); .sort((a, b) => a.lon - b.lon);
top.forEach((r, i) => { top.forEach((r, i) => {
const t = document.createElementNS(NS, 'text'); const t = document.createElementNS(NS, 'text');
t.setAttribute('x', String(Math.min(560, Math.max(80, px(r.lon))))); t.setAttribute(
t.setAttribute('y', String(py(r.lat) + (i % 2 ? 20 : -11))); 'x',
String(Math.min(W - 80 * k, Math.max(80 * k, px(r.lon)))),
);
t.setAttribute('y', String(py(r.lat) + (i % 2 ? 20 : -11) * k));
t.setAttribute('text-anchor', 'middle'); t.setAttribute('text-anchor', 'middle');
t.setAttribute('font-size', String(Math.round(12 * k)));
t.setAttribute('paint-order', 'stroke');
t.setAttribute('stroke', '#0b0d12');
t.setAttribute('stroke-width', String(3 * k));
t.textContent = `${r.name.toLowerCase()} ${Math.round(r.wait_min)}m`; t.textContent = `${r.name.toLowerCase()} ${Math.round(r.wait_min)}m`;
svg.append(t); svg.append(t);
}); });
// imagery attribution (required by Esri's free-use terms)
const att = document.createElementNS(NS, 'text');
att.setAttribute('x', String(W - 6 * k));
att.setAttribute('y', String(H - 7 * k));
att.setAttribute('text-anchor', 'end');
att.setAttribute('font-size', String(Math.round(10 * k)));
att.setAttribute('opacity', '0.75');
att.textContent = 'imagery © Esri';
svg.append(att);
document.getElementById('knb-map-title')!.textContent = document.getElementById('knb-map-title')!.textContent =
`park heat · ${data.label}`; `park heat · ${data.label}`;
knbFig.hidden = false; knbFig.hidden = false;