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>
226 lines
7.8 KiB
Plaintext
226 lines
7.8 KiB
Plaintext
---
|
||
/**
|
||
* 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>
|