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:
@@ -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" /> <10
|
||||||
|
<span class="kml kml-busy" /> 10–20
|
||||||
|
<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;
|
||||||
|
|||||||
Reference in New Issue
Block a user