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>
// Homepage park-heat map: one glow per ride from /knoebels/rides
// (live while the park is open, typical weekend peak otherwise).
// Color = wait class, glow radius = magnitude, top-5 direct labels +
// native tooltips carry the values. Fail-closed.
// (live while the park is open, typical weekend peak otherwise),
// over darkened Esri World Imagery so the layout has real bearings.
// Web-Mercator slippy math for BOTH tiles and rides = exact alignment.
// Fail-closed.
const knbFig = document.getElementById('knb-map');
if (knbFig) {
try {
@@ -296,24 +297,52 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
const rides: any[] = data.rides;
if (rides.length < 10) throw new Error('too few rides');
const W = 640;
const PAD = 36;
const lats = rides.map((r) => r.lat);
const lons = rides.map((r) => r.lon);
const mnLa = Math.min(...lats);
const mxLa = Math.max(...lats);
const mnLo = Math.min(...lons);
const mxLo = Math.max(...lons);
const cosLat = Math.cos((((mnLa + mxLa) / 2) * Math.PI) / 180);
const H =
Math.round(
(W - 2 * PAD) * ((mxLa - mnLa) / ((mxLo - mnLo) * cosLat)),
) +
2 * PAD;
const px = (lo: number) =>
PAD + ((lo - mnLo) / (mxLo - mnLo)) * (W - 2 * PAD);
const py = (la: number) =>
H - PAD - ((la - mnLa) / (mxLa - mnLa)) * (H - 2 * PAD);
const Z = 17;
const T = 256;
const n = 2 ** Z;
const tx = (lo: number) => ((lo + 180) / 360) * n;
const ty = (la: number) => {
const r = (la * Math.PI) / 180;
return (
((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * n
);
};
const xs = rides.map((r) => tx(r.lon));
const ys = rides.map((r) => ty(r.lat));
const x0 = Math.floor(Math.min(...xs) - 0.3);
const x1 = Math.floor(Math.max(...xs) + 0.3);
const y0 = Math.floor(Math.min(...ys) - 0.25);
const y1 = Math.floor(Math.max(...ys) + 0.25);
const W = (x1 - x0 + 1) * T;
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> = {
quiet: '#16a34a',
@@ -324,13 +353,10 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
w < 10 ? 'quiet' : w < 20 ? 'busy' : 'packed';
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');
for (const [k, c] of Object.entries(CLR)) {
for (const [key, c] of Object.entries(CLR)) {
const g = document.createElementNS(NS, 'radialGradient');
g.id = `knb-g-${k}`;
g.id = `knb-g-${key}`;
for (const [off, op] of [
['0%', '0.55'],
['55%', '0.22'],
@@ -346,24 +372,26 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
}
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)) {
const k = cls(r.wait_min);
const key = cls(r.wait_min);
const glow = document.createElementNS(NS, 'circle');
glow.setAttribute('cx', String(px(r.lon)));
glow.setAttribute('cy', String(py(r.lat)));
glow.setAttribute('r', String(9 + (r.wait_min / maxWait) * 30));
glow.setAttribute('fill', `url(#knb-g-${k})`);
glow.setAttribute(
'r',
String((9 + (r.wait_min / maxWait) * 30) * k),
);
glow.setAttribute('fill', `url(#knb-g-${key})`);
glow.style.mixBlendMode = 'screen';
const core = document.createElementNS(NS, 'circle');
core.setAttribute('cx', String(px(r.lon)));
core.setAttribute('cy', String(py(r.lat)));
core.setAttribute('r', '2.2');
core.setAttribute('fill', CLR[k]);
core.setAttribute('r', String(2.4 * k));
core.setAttribute('fill', CLR[key]);
const hit = document.createElementNS(NS, 'circle');
hit.setAttribute('cx', String(px(r.lon)));
hit.setAttribute('cy', String(py(r.lat)));
hit.setAttribute('r', '12');
hit.setAttribute('r', String(12 * k));
hit.setAttribute('fill', 'transparent');
const tip = document.createElementNS(NS, 'title');
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);
}
// direct labels on the five longest waits, alternating above/below
const top = [...rides]
.sort((a, b) => b.wait_min - a.wait_min)
.slice(0, 5)
.sort((a, b) => a.lon - b.lon);
top.forEach((r, i) => {
const t = document.createElementNS(NS, 'text');
t.setAttribute('x', String(Math.min(560, Math.max(80, px(r.lon)))));
t.setAttribute('y', String(py(r.lat) + (i % 2 ? 20 : -11)));
t.setAttribute(
'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('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`;
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 =
`park heat · ${data.label}`;
knbFig.hidden = false;