Homepage: Knoebels park-heat map under the live strip

One glow per ride from api.c0smere.net/knoebels/rides — live waits while
the park is open, typical-weekend-peak means otherwise. Color = wait
class (validated dark palette), glow radius = magnitude, top-5 direct
labels + native tooltips. Fail-closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wes
2026-07-22 11:11:07 -04:00
co-authored by Claude Fable 5
parent 01d7ce8aa8
commit 2c7f467cf9
+158
View File
@@ -90,6 +90,17 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
<span id="live-knb" /> <span id="live-knb" />
</div> </div>
</div> </div>
<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" /> &lt;10
<span class="kml kml-busy" /> 1020
<span class="kml kml-packed" /> 20+ min
</span>
</figcaption>
<svg id="knb-map-svg" role="img" aria-label="Knoebels ride-wait heat map" />
</figure>
) )
} }
</header> </header>
@@ -267,6 +278,118 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
} }
</script> </script>
<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.
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 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 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 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)) {
const g = document.createElementNS(NS, 'radialGradient');
g.id = `knb-g-${k}`;
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);
// 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 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.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]);
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('fill', 'transparent');
const tip = document.createElementNS(NS, 'title');
tip.textContent = `${r.name} — ${r.wait_min} min`;
hit.append(tip);
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(px(r.lon)));
t.setAttribute('y', String(py(r.lat) + (i % 2 ? 20 : -11)));
t.setAttribute('text-anchor', 'middle');
t.textContent = `${r.name.toLowerCase()} ${Math.round(r.wait_min)}m`;
svg.append(t);
});
document.getElementById('knb-map-title')!.textContent =
`park heat · ${data.label}`;
knbFig.hidden = false;
} catch {
/* API down: map stays hidden */
}
}
</script>
<script> <script>
// Odometer: fetch totals once, then animate forward at the lifetime // Odometer: fetch totals once, then animate forward at the lifetime
// average rate. Looks live, but deliberately never exposes current // average rate. Looks live, but deliberately never exposes current
@@ -568,6 +691,41 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
.knb-dot-typical { background: #e8a13c; } .knb-dot-typical { background: #e8a13c; }
.knb-dot-packed { background: #d9480f; } .knb-dot-packed { background: #d9480f; }
.knb-dot-closed { background: var(--muted); animation: none; } .knb-dot-closed { background: var(--muted); animation: none; }
.knb-map {
margin: -0.9rem 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);
}
.live-dot { .live-dot {
flex: none; flex: none;
width: 7px; width: 7px;