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:
+80
-36
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user