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>
|
||||||
+9
-215
@@ -108,19 +108,11 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
|
|||||||
<div id="knb-stats" class="live-stats" hidden>
|
<div id="knb-stats" class="live-stats" hidden>
|
||||||
<span class="live-dot" id="knb-dot" aria-hidden="true" />
|
<span class="live-dot" id="knb-dot" aria-hidden="true" />
|
||||||
<span id="live-knb" />
|
<span id="live-knb" />
|
||||||
|
{/* The park-heat map moved to its own page 2026-08-13; this
|
||||||
|
strip is now its teaser. */}
|
||||||
|
<a class="knb-more" href="/knoebels">park map ›</a>
|
||||||
</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>
|
||||||
@@ -321,175 +313,6 @@ 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),
|
|
||||||
// over darkened Esri World Imagery so the layout has real bearings.
|
|
||||||
// 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>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Homepage Susquehanna strip: live stage + the 48h quantile stage
|
// Homepage Susquehanna strip: live stage + the 48h quantile stage
|
||||||
// forecast from the sunbury-flood pipeline. Fail-closed.
|
// forecast from the sunbury-flood pipeline. Fail-closed.
|
||||||
@@ -833,6 +656,12 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
|
|||||||
.live-stats .v {
|
.live-stats .v {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
/* teaser link out of the knoebels strip to the full park map */
|
||||||
|
.knb-more {
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
.live-strips {
|
.live-strips {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -850,41 +679,6 @@ 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;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
import KnoebelsMap from '../components/KnoebelsMap.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base
|
||||||
|
title="Knoebels"
|
||||||
|
description="Live ride waits at Knoebels Amusement Resort, drawn over the park, plus the outlook for the days ahead."
|
||||||
|
>
|
||||||
|
<h1>Knoebels</h1>
|
||||||
|
<p>
|
||||||
|
Ride waits at <a href="https://knoebels.com">Knoebels</a>, one glow per ride
|
||||||
|
— live while the park is open, typical weekend peak when it is closed.
|
||||||
|
Hover a ride for its name and current wait. Predictions come from
|
||||||
|
<code>knb-oracle</code>, an hourly model trained on the park's own feed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<KnoebelsMap />
|
||||||
|
|
||||||
|
<p class="knb-note">
|
||||||
|
The homepage carries the one-line verdict; this is the whole park.
|
||||||
|
</p>
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.knb-note {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user