// stops-block.jsx — AAGU "Stops" block (alternative to Steps)
// Metro-map style SVG that draws itself as the page scrolls.
//
// Globals consumed: React, BlockMeta (from blocks.jsx).
// Exposes: StopsBlock, MStopsBlock.
// ─────────────────────────────────────────────────────────────
// Default content — 7 stops, framed as a playful journey
// through the AAGU group-booking process. (Place-names are
// invented; matched to the actual workflow stages.)
// ─────────────────────────────────────────────────────────────
// Desktop — lightning-bolt sweep with even 80-unit legs and
// three extended runs: S1→S2 east leg doubled (2L), S3→S4
// east leg tripled (3L), S6→S7 west leg tripled (3L). Each
// direction change carries a `via:true` waypoint so 45°
// corners stay crisp.
// Sequence of legs between stops:
// (1) E×2, SE (2) S, SE (3) E×3 (4) SE, E
// (5) SE, S (6) S, SW, W×3 (7)
//
// L=80. Origin S1=(200, 50). Total path span 800 × 640.
const STOPS_DEFAULT = [
{x: 200, y: 50, side: 'bot', title: 'Anfrage-Allmend',
body: 'Online-Formular ausfüllen — Datum, Personenzahl und Wunschroute angeben.'},
// S1 → S2 (E×2, SE)
{x: 360, y: 50, via: true},
{x: 440, y: 130, side: 'tr', title: 'Wartesaal Werktag',
body: 'Wir prüfen Verfügbarkeit und Verbindung innert zwei Werktagen.'},
// S2 → S3 (S, SE)
{x: 440, y: 210, via: true},
{x: 520, y: 290, side: 'bot', title: 'Bestätigungs-Brücke',
body: 'Die schriftliche Buchungsbestätigung landet in Ihrem Posteingang.'},
// S3 → S4 (E×3) — single straight leg, no via
{x: 760, y: 290, side: 'top', title: 'Billett-Bahnhof',
body: 'Gruppenbillette am AAGU-Schalter Schattdorf oder direkt beim Chauffeur.'},
// S4 → S5 (SE, E)
{x: 840, y: 370, via: true},
{x: 920, y: 370, side: 'tr', title: 'Treffpunkt-Halt',
body: 'Pünktlich an der vereinbarten Haltestelle einfinden — wir warten.'},
// S5 → S6 (SE, S)
{x: 1000, y: 450, via: true},
{x: 1000, y: 530, side: 'l', title: 'Panorama-Pass',
body: 'Zurücklehnen, schauen, geniessen. Der Chauffeur übernimmt den Rest.'},
// S6 → S7 (S, SW, W×3)
{x: 1000, y: 610, via: true},
{x: 920, y: 690, via: true},
{x: 680, y: 690, side: 'bot', title: 'Ankunfts-Terminal',
body: 'Ziel erreicht — Gruppenreise erlebt. Bis zur nächsten Fahrt.'},
];
// Mobile path — each inter-stop connector is composed of FIVE
// segments: vertical down → 45° diagonal → horizontal → 45°
// diagonal → vertical down. Stops sit at the top and bottom of
// each S-bend, alternating between the right column (x=260)
// and the left column (x=140).
const STOPS_MOBILE_DEFAULT = [
{x: 260, y: 110, side: 'left', title: 'Anfrage-Allmend',
body: 'Online-Formular ausfüllen — Datum, Personen, Wunschroute.'},
// S1 → S2 (down-left S-bend)
{x: 260, y: 150, via: true},
{x: 220, y: 190, via: true},
{x: 180, y: 190, via: true},
{x: 140, y: 230, via: true},
{x: 140, y: 270, side: 'right', title: 'Wartesaal Werktag',
body: 'Wir prüfen Verbindung & Verfügbarkeit innert 2 Werktagen.'},
// S2 → S3 (down-right S-bend)
{x: 140, y: 310, via: true},
{x: 180, y: 350, via: true},
{x: 220, y: 350, via: true},
{x: 260, y: 390, via: true},
{x: 260, y: 430, side: 'left', title: 'Bestätigungs-Brücke',
body: 'Schriftliche Buchungsbestätigung landet im Posteingang.'},
// S3 → S4 (down-left)
{x: 260, y: 470, via: true},
{x: 220, y: 510, via: true},
{x: 180, y: 510, via: true},
{x: 140, y: 550, via: true},
{x: 140, y: 590, side: 'right', title: 'Billett-Bahnhof',
body: 'Gruppenbillett am Schalter Schattdorf oder beim Chauffeur.'},
// S4 → S5 (down-right)
{x: 140, y: 630, via: true},
{x: 180, y: 670, via: true},
{x: 220, y: 670, via: true},
{x: 260, y: 710, via: true},
{x: 260, y: 750, side: 'left', title: 'Treffpunkt-Halt',
body: 'Pünktlich an der vereinbarten Haltestelle einfinden.'},
// S5 → S6 (down-left)
{x: 260, y: 790, via: true},
{x: 220, y: 830, via: true},
{x: 180, y: 830, via: true},
{x: 140, y: 870, via: true},
{x: 140, y: 910, side: 'right', title: 'Panorama-Pass',
body: 'Zurücklehnen, schauen, geniessen — Chauffeur übernimmt.'},
// S6 → S7 (down-right)
{x: 140, y: 950, via: true},
{x: 180, y: 990, via: true},
{x: 220, y: 990, via: true},
{x: 260, y: 1030, via: true},
{x: 260, y: 1070, side: 'left', title: 'Ankunfts-Terminal',
body: 'Ziel erreicht. Bis zur nächsten Fahrt mit AAGU.'},
];
// Multi-color palette — built from official line colors
const STOPS_MULTI_COLORS = [
'#E3000F', '#F18E00', '#E89600', '#7BB028', '#00427D', '#5B2483', '#19171C',
];
// ─────────────────────────────────────────────────────────────
// Hooks
// ─────────────────────────────────────────────────────────────
// Re-render on Tweaks panel changes (dispatched by applyTweaks).
function useTweakTick() {
const [, setT] = React.useState(0);
React.useEffect(() => {
const f = () => setT(n => n + 1);
window.addEventListener('aagu:tweaks-changed', f);
return () => window.removeEventListener('aagu:tweaks-changed', f);
}, []);
}
// Compute scroll-progress of an element through its nearest
// scrollable ancestor: 0 when the element's top is at the
// bottom of the viewport, 1 when its bottom has scrolled past
// the top. Falls back to ~0.45 for non-scrollable contexts
// so the canvas grid view shows a meaningful partial fill.
function useScrollProgress(elRef) {
const [progress, setProgress] = React.useState(0.45);
React.useEffect(() => {
const el = elRef.current;
if (!el) return;
let sc = el.parentElement;
while (sc) {
const s = getComputedStyle(sc);
if (s.overflowY === 'auto' || s.overflowY === 'scroll') break;
sc = sc.parentElement;
}
const useWindow = !sc;
if (useWindow) sc = window;
let raf = 0;
const update = () => {
raf = 0;
const elRect = el.getBoundingClientRect();
const cTop = useWindow ? 0 : sc.getBoundingClientRect().top;
const cH = useWindow ? window.innerHeight : sc.clientHeight;
const elTop = elRect.top - cTop;
const total = cH + elRect.height;
const traveled = cH - elTop;
const p = Math.max(0, Math.min(1, traveled / total));
setProgress(p);
};
const schedule = () => { if (!raf) raf = requestAnimationFrame(update); };
update();
sc.addEventListener('scroll', schedule, {passive: true});
window.addEventListener('resize', schedule);
return () => {
sc.removeEventListener('scroll', schedule);
window.removeEventListener('resize', schedule);
if (raf) cancelAnimationFrame(raf);
};
}, []);
return progress;
}
// ─────────────────────────────────────────────────────────────
// Path geometry
// ─────────────────────────────────────────────────────────────
function buildPath(stops, geom) {
if (geom === 'rounded') {
const R = 28;
let d = `M ${stops[0].x} ${stops[0].y}`;
for (let i = 1; i < stops.length; i++) {
const prev = stops[i - 1], curr = stops[i], next = stops[i + 1];
if (next) {
const px = curr.x - prev.x, py = curr.y - prev.y;
const pl = Math.hypot(px, py) || 1;
const nx = next.x - curr.x, ny = next.y - curr.y;
const nl = Math.hypot(nx, ny) || 1;
const cut = Math.min(R, pl / 2, nl / 2);
const ax = curr.x - (px / pl) * cut, ay = curr.y - (py / pl) * cut;
const bx = curr.x + (nx / nl) * cut, by = curr.y + (ny / nl) * cut;
d += ` L ${ax.toFixed(2)} ${ay.toFixed(2)}`;
d += ` Q ${curr.x} ${curr.y} ${bx.toFixed(2)} ${by.toFixed(2)}`;
} else {
d += ` L ${curr.x} ${curr.y}`;
}
}
return d;
}
if (geom === 'curvy') {
let d = `M ${stops[0].x} ${stops[0].y}`;
for (let i = 1; i < stops.length; i++) {
const prev = stops[i - 1], curr = stops[i];
const dx = curr.x - prev.x;
const dy = curr.y - prev.y;
// S-curve: control points offset along the dominant axis
const horiz = Math.abs(dx) >= Math.abs(dy);
const c1x = horiz ? prev.x + dx * 0.55 : prev.x;
const c1y = horiz ? prev.y : prev.y + dy * 0.55;
const c2x = horiz ? curr.x - dx * 0.55 : curr.x;
const c2y = horiz ? curr.y : curr.y - dy * 0.55;
d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${curr.x} ${curr.y}`;
}
return d;
}
// angled (default) — straight segments, mitered, square caps
return `M ${stops[0].x} ${stops[0].y}` +
stops.slice(1).map(s => ` L ${s.x} ${s.y}`).join('');
}
// Approx cumulative progress at each stop using straight-line
// distance between consecutive coordinates. Close enough for
// timing the marker "light-up" relative to the drawn path.
function cumulativeStopProgress(stops) {
const cum = [0];
for (let i = 1; i < stops.length; i++) {
const dx = stops[i].x - stops[i - 1].x;
const dy = stops[i].y - stops[i - 1].y;
cum.push(cum[i - 1] + Math.hypot(dx, dy));
}
const total = cum[cum.length - 1] || 1;
return cum.map(l => l / total);
}
// ─────────────────────────────────────────────────────────────
// Tweak readers — body data attrs set by applyTweaks()
// ─────────────────────────────────────────────────────────────
function readTweaks() {
const ds = (typeof document !== 'undefined' && document.body && document.body.dataset) || {};
return {
geometry: ds.stopsGeometry || 'angled',
colorMode: ds.stopsColor || 'brand',
decoration: ds.stopsDecoration || 'ghost',
};
}
function stopColor(colorMode, i) {
if (colorMode === 'dark') return '#19171C';
if (colorMode === 'multi') return STOPS_MULTI_COLORS[i] || '#F18E00';
return '#F18E00';
}
function trackStroke(colorMode) {
if (colorMode === 'dark') return 'var(--gray-900)';
if (colorMode === 'multi') return 'url(#stops-grad)';
return 'var(--brand-orange)';
}
// ─────────────────────────────────────────────────────────────
// Shared SVG defs (gradient, grid pattern, ghost decoration)
// ─────────────────────────────────────────────────────────────
function StopsDefs({W, H, gradId, gridId}) {
return (
{intro}
}{intro}
}