// 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 ( {STOPS_MULTI_COLORS.map((c, i) => ( ))} ); } // ───────────────────────────────────────────────────────────── // Desktop — diagonal sweep top-left → bottom-right // ───────────────────────────────────────────────────────────── function StopsBlock({ showMeta = true, eyebrow = "So funktionierts", title = "In sieben Halten zur Gruppenreise", intro = "Vom ersten Klick bis zur Heimfahrt — jede Station ein Schritt im Buchungsablauf. Beim Scrollen zeichnet sich die Strecke nach.", stops = STOPS_DEFAULT, }) { useTweakTick(); const {geometry, colorMode, decoration} = readTweaks(); const blockRef = React.useRef(null); const pathRef = React.useRef(null); const [pathLen, setPathLen] = React.useState(2400); const progress = useScrollProgress(blockRef); React.useLayoutEffect(() => { if (pathRef.current) { const l = pathRef.current.getTotalLength(); if (l && Number.isFinite(l)) setPathLen(l); } }, [geometry, stops]); const W = 1240, H = 740; const d = buildPath(stops, geometry); const stopProg = cumulativeStopProgress(stops); const stroke = trackStroke(colorMode); // Ghost line — a faded second route in the background, for depth. const ghostD = "M 40 690 C 220 690, 280 200, 500 200 S 880 580, 1200 110"; const ghostStops = [ {x: 60, y: 700}, {x: 320, y: 380}, {x: 660, y: 220}, {x: 980, y: 380}, {x: 1180, y: 130}, ]; return (
{showMeta && }
{eyebrow &&
{eyebrow}
}

{title}

{intro &&

{intro}

}
{decoration === 'grid' && ( )} {decoration === 'ghost' && ( {ghostStops.map((p, i) => ( ))} )} {/* Track — faded base layer always visible */} {/* Track — filled portion driven by scroll progress */} {/* Stop markers — white-fill ring, lights up when track reaches them */} {stops.map((s, i) => { if (s.via) return null; const active = progress >= stopProg[i] - 0.001; const c = stopColor(colorMode, i); return ( ); })} {/* Labels — short title + 1-2 line description */} {stops.map((s, i) => { if (s.via) return null; const labelW = 240, labelH = 92; let x, y, textAlign = 'center'; switch (s.side) { case 'top': x = s.x - labelW / 2; y = s.y - labelH - 32; break; case 'bot': x = s.x - labelW / 2; y = s.y + 32; break; case 'tr': x = s.x + 22; y = s.y - labelH - 4; textAlign = 'left'; break; case 'tl': x = s.x - labelW - 22; y = s.y - labelH - 4; textAlign = 'right'; break; case 'br': x = s.x + 22; y = s.y + 4; textAlign = 'left'; break; case 'l': x = s.x - labelW - 22; y = s.y - labelH / 2; textAlign = 'right'; break; case 'r': x = s.x + 22; y = s.y - labelH / 2; textAlign = 'left'; break; case 'bl': x = s.x - labelW - 22; y = s.y + 4; textAlign = 'right'; break; default: x = s.x - labelW / 2; y = s.y + 32; } x = Math.max(8, Math.min(W - labelW - 8, x)); const active = progress >= stopProg[i] - 0.001; return (
{s.title}
{s.body}
); })}
); } // ───────────────────────────────────────────────────────────── // Mobile — vertical zig-zag, alternating columns // ───────────────────────────────────────────────────────────── function MStopsBlock({ showMeta = true, eyebrow = "So funktionierts", title = "Sieben Halte. Eine Gruppenreise.", intro = "Vom ersten Klick bis zur Heimfahrt — beim Scrollen zeichnet sich die Strecke nach.", stops = STOPS_MOBILE_DEFAULT, }) { useTweakTick(); const {geometry, colorMode, decoration} = readTweaks(); const blockRef = React.useRef(null); const pathRef = React.useRef(null); const [pathLen, setPathLen] = React.useState(2400); const progress = useScrollProgress(blockRef); React.useLayoutEffect(() => { if (pathRef.current) { const l = pathRef.current.getTotalLength(); if (l && Number.isFinite(l)) setPathLen(l); } }, [geometry, stops]); const W = 390; const H = (stops[stops.length - 1]?.y || 1000) + 120; const d = buildPath(stops, geometry); const stopProg = cumulativeStopProgress(stops); const stroke = trackStroke(colorMode); // Mobile ghost line — simple vertical S-curve in the background const ghostD = "M 40 60 C 200 220, 200 400, 60 560 S 200 880, 60 1160"; return (
{showMeta && }
{eyebrow &&
{eyebrow}
}

{title}

{intro &&

{intro}

}
{decoration === 'grid' && ( )} {decoration === 'ghost' && ( )} {/* Replace gradient ref for mobile in stroke */} {stops.map((s, i) => { if (s.via) return null; const active = progress >= stopProg[i] - 0.001; const c = stopColor(colorMode, i); return ( ); })} {/* Labels — text on the OPPOSITE side of the stop dot */} {stops.map((s, i) => { if (s.via) return null; // Dot on right (x ≈ 295) → text on left side of viewBox // Dot on left (x ≈ 95) → text on right side const dotRight = s.x > W / 2; const labelW = 190; const labelH = 88; const x = dotRight ? 18 : W - labelW - 18; const y = s.y - labelH / 2; const active = progress >= stopProg[i] - 0.001; return (
{s.title}
{s.body}
); })}
); } Object.assign(window, { StopsBlock, MStopsBlock, STOPS_DEFAULT, STOPS_MOBILE_DEFAULT, STOPS_MULTI_COLORS, });