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