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>
|
<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;
|
||||||
|
|||||||
Reference in New Issue
Block a user