Knoebels: the park map gets its own page

The homepage had two competing visuals once the reading map landed, and the
park heat map was in the header — above the hero. Moved to /knoebels; the
homepage keeps the one-line crowd verdict as a teaser that links there.

Extracted verbatim into KnoebelsMap.astro. Its styles stay `is:global`: every
element inside the <svg> is built with createElementNS and so never carries
Astro's scoping attribute, which would make a scoped `.knb-map text` selector
silently match nothing.

Homepage HTML drops ~4 KB and no longer fetches /knoebels/rides or the Esri
tiles; the strip's own /knoebels/now call is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wes
2026-08-13 08:43:32 -04:00
co-authored by Claude Opus 5
parent 9519c46fe2
commit b8d7dad3e3
3 changed files with 264 additions and 215 deletions
+225
View File
@@ -0,0 +1,225 @@
---
/**
* The Knoebels park-heat map — one glow per ride, over darkened satellite imagery.
*
* Lived in the homepage header until 2026-08-13, when it moved to its own page (`/knoebels`)
* to make room for the reading map. The homepage keeps the one-line `knb-stats` strip as the
* teaser; it links here.
*
* Data: `api.c0smere.net/knoebels/rides` (live waits while the park is open, typical weekend
* peak otherwise). Fail-closed, like every garden widget.
*/
---
<figure id="knb-map" class="knb-map" hidden>
<figcaption class="knb-map-head">
<span id="knb-map-title">park heat</span>
<span class="knb-map-legend" aria-hidden="true">
<span class="kml kml-quiet"></span> &lt;10
<span class="kml kml-busy"></span> 1020
<span class="kml kml-packed"></span> 20+ min
</span>
</figcaption>
<svg id="knb-map-svg" role="img" aria-label="Knoebels ride-wait heat map"></svg>
</figure>
<script>
// Web-Mercator slippy math for BOTH tiles and rides = exact alignment.
// Fail-closed.
const knbFig = document.getElementById('knb-map');
if (knbFig) {
try {
const res = await fetch('https://api.c0smere.net/knoebels/rides');
if (!res.ok) throw new Error(String(res.status));
const data = await res.json();
const rides: any[] = data.rides;
if (rides.length < 10) throw new Error('too few rides');
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',
busy: '#d97706',
packed: '#ef4444',
};
const cls = (w: number) => (w < 10 ? 'quiet' : w < 20 ? 'busy' : 'packed');
const maxWait = Math.max(...rides.map((r) => r.wait_min), 1);
const defs = document.createElementNS(NS, 'defs');
for (const [key, c] of Object.entries(CLR)) {
const g = document.createElementNS(NS, 'radialGradient');
g.id = `knb-g-${key}`;
for (const [off, op] of [
['0%', '0.55'],
['55%', '0.22'],
['100%', '0'],
]) {
const s = document.createElementNS(NS, 'stop');
s.setAttribute('offset', off);
s.setAttribute('stop-color', c);
s.setAttribute('stop-opacity', op);
g.append(s);
}
defs.append(g);
}
svg.append(defs);
for (const r of [...rides].sort((a, b) => a.wait_min - b.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) * 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', 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', String(12 * k));
hit.setAttribute('fill', 'transparent');
const tip = document.createElementNS(NS, 'title');
tip.textContent = `${r.name} — ${r.wait_min} min`;
hit.append(tip);
svg.append(glow, core, hit);
}
// Fixed landmark labels for wayfinding, one per park region.
// Not the busiest rides — the glow + hover already carry the waits;
// these just give bearings against the satellite imagery.
const LANDMARKS = [
'phoenix',
'impulse',
'twister',
'sklooosh',
'haunted mansion',
'kiddie whip',
'motor boats',
];
const marks = LANDMARKS.map((key) =>
rides.find((r) => r.name.toLowerCase() === key),
)
.filter(Boolean)
.sort((a, b) => a.lon - b.lon);
marks.forEach((r, i) => {
const t = document.createElementNS(NS, 'text');
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();
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;
} catch {
/* API down: map stays hidden */
}
}
</script>
<style is:global>
/* Global, not scoped: every element inside the <svg> is built by the script with
createElementNS, so it never carries Astro's scoping attribute and a scoped
`.knb-map svg text` selector would not match. */
.knb-map {
margin: 0 0 1.9rem;
}
.knb-map-head {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 0.72rem;
color: var(--muted);
margin-bottom: 0.35rem;
}
.knb-map-legend {
display: inline-flex;
gap: 0.4rem;
align-items: center;
}
.kml {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
}
.kml-quiet { background: #16a34a; }
.kml-busy { background: #d97706; }
.kml-packed { background: #ef4444; }
.knb-map svg {
width: 100%;
height: auto;
display: block;
}
.knb-map text {
font-family: inherit;
font-size: 11px;
fill: var(--muted);
}
</style>