Files
garden-astro/src/components/KnoebelsMap.astro
T
wesandClaude Opus 5 b8d7dad3e3 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>
2026-08-13 08:43:32 -04:00

226 lines
7.8 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
/**
* 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>