/* Hero Route Visualization — v3. Bigger, bolder, switchable. Three distinct route options with their own tradeoffs. Keeps the Spain-map aesthetic + animated truck + waypoint activation. */ // NOTE on positioning: // - `cityAt` : 0..1 parametric position on the compound path where each city node sits // (the code snaps the city node to this exact point on the path) // - `wp.at` : parametric position where the waypoint dot sits (also snapped to path) // - `rx, ry` : top-left of the callout rectangle (hand-positioned for clarity) // Leader line goes from the snapped dot to the nearest corner of the callout rect. const ROUTES = [ { id: "profit", label: "Max profit", subtitle: "Balanced loop, four loads", badge: "€2,840", stats: { km: 1317, legs: 4, hours: 21, profit: 2840, eur_per_km: 2.16 }, compound: "M 492 332 Q 396 300, 320 268 Q 352 228, 398 196 Q 466 202, 540 222 Q 524 284, 492 332", cities: [ { code:"VLC", name:"Valencia", meta:"BASE · 01", cityAt:0.00, align:"start", base:true }, { code:"MAD", name:"Madrid", meta:"02 · 22T PALLET", cityAt:0.25, align:"end" }, { code:"ZGZ", name:"Zaragoza", meta:"03 · 18T BOX", cityAt:0.50, align:"top" }, { code:"BCN", name:"Barcelona", meta:"04 · 24T REEFER", cityAt:0.75, align:"start" }, ], waypoints: [ { kind:"REST", at:0.12, rx:296, ry:348, w:140, h:38, l1:"REST · 45 MIN", l2:"EU 561/2006" }, { kind:"FUEL", at:0.35, rx:180, ry:210, w:148, h:38, l1:"FUEL · €1.42/L", l2:"A-2 · 320 L" }, { kind:"TOLL", at:0.60, rx:448, ry:128, w:140, h:38, l1:"TOLL · AP-2", l2:"€47.10" }, ], callout: "BACK BY FRI 19:00 · +€2,840 NET", }, { id: "fast", label: "Fastest home", subtitle: "Quick triangle, three loads", badge: "14h", stats: { km: 918, legs: 3, hours: 14, profit: 1920, eur_per_km: 2.09 }, compound: "M 492 332 Q 568 280, 540 222 Q 468 206, 398 196 Q 438 268, 492 332", cities: [ { code:"VLC", name:"Valencia", meta:"BASE · 01", cityAt:0.00, align:"start", base:true }, { code:"BCN", name:"Barcelona", meta:"02 · 24T REEFER", cityAt:0.333, align:"start" }, { code:"ZGZ", name:"Zaragoza", meta:"03 · 18T BOX", cityAt:0.667, align:"top" }, ], waypoints: [ { kind:"TOLL", at:0.18, rx:594, ry:260, w:108, h:38, l1:"TOLL · AP-7", l2:"€31.40" }, { kind:"REST", at:0.52, rx:456, ry:112, w:140, h:38, l1:"REST · 45 MIN", l2:"EU 561/2006" }, { kind:"FUEL", at:0.82, rx:282, ry:308, w:148, h:38, l1:"FUEL · €1.42/L", l2:"A-23 · 210 L" }, ], callout: "BACK BY THU 11:00 · +€1,920 NET", }, { id: "short", label: "Short distance", subtitle: "Southern arc, four loads", badge: "1,140 km", stats: { km: 1140, legs: 4, hours: 18, profit: 2240, eur_per_km: 1.96 }, compound: "M 492 332 Q 440 394, 370 406 Q 262 394, 226 336 Q 278 286, 314 268 Q 398 296, 492 332", cities: [ { code:"VLC", name:"Valencia", meta:"BASE · 01", cityAt:0.00, align:"start", base:true }, { code:"MUR", name:"Murcia", meta:"02 · 20T TAUT", cityAt:0.25, align:"bottom" }, { code:"SEV", name:"Sevilla", meta:"03 · 22T BOX", cityAt:0.50, align:"end" }, { code:"MAD", name:"Madrid", meta:"04 · 18T PALLET", cityAt:0.72, align:"top" }, ], waypoints: [ { kind:"FUEL", at:0.14, rx:478, ry:412, w:148, h:38, l1:"FUEL · €1.40/L", l2:"A-7 · 280 L" }, { kind:"REST", at:0.42, rx:112, ry:420, w:140, h:38, l1:"REST · 45 MIN", l2:"EU 561/2006" }, { kind:"TOLL", at:0.76, rx:398, ry:252, w:140, h:38, l1:"TOLL · A-4", l2:"€28.60" }, ], callout: "BACK BY SAT 08:00 · +€2,240 NET", }, ]; const RouteDiagram = () => { const [routeIdx, setRouteIdx] = React.useState(0); const route = ROUTES[routeIdx]; const pathRef = React.useRef(null); const [progress, setProgress] = React.useState(0); const [dwelling, setDwelling] = React.useState(false); // reset animation when switching route React.useEffect(() => { setProgress(0); setDwelling(false); }, [routeIdx]); // animation loop React.useEffect(() => { let raf, start = 0, dwellUntil = 0, mounted = true; const LOOP_MS = 16000; const DWELL_MS = 1800; const tick = (ts) => { if (!mounted) return; if (!start) start = ts; if (dwellUntil) { if (ts >= dwellUntil) { start = ts; dwellUntil = 0; setDwelling(false); setProgress(0); } raf = requestAnimationFrame(tick); return; } const p = Math.min(1, (ts - start) / LOOP_MS); setProgress(p); if (p >= 1) { dwellUntil = ts + DWELL_MS; setDwelling(true); } raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => { mounted = false; cancelAnimationFrame(raf); }; }, [routeIdx]); // point lookup on current route path const ptRef = React.useRef({ x:492, y:332, angle:0, len:0 }); // positions of cities and waypoints snapped onto the path const [snaps, setSnaps] = React.useState({ cities: [], waypoints: [], len: 1 }); const [, force] = React.useReducer(x => x+1, 0); // recompute snapped positions whenever the route changes React.useEffect(() => { const el = pathRef.current; if (!el) return; const L = el.getTotalLength(); const snap = (t) => { const p = el.getPointAtLength(L * Math.max(0, Math.min(1, t))); return { x: p.x, y: p.y }; }; setSnaps({ len: L, cities: route.cities.map(c => snap(c.cityAt)), waypoints: route.waypoints.map(w => snap(w.at)), }); }, [routeIdx]); React.useEffect(() => { const el = pathRef.current; if (!el) return; const L = el.getTotalLength(); const at = L * progress; const pt = el.getPointAtLength(at); const pt2 = el.getPointAtLength(Math.min(L, at + 1)); const angle = Math.atan2(pt2.y - pt.y, pt2.x - pt.x) * 180 / Math.PI; ptRef.current = { x: pt.x, y: pt.y, angle, len: L }; force(); }, [progress, routeIdx]); const legs = route.cities.length; const activeLegIdx = Math.min(legs - 1, Math.floor(progress * legs)); const legFrac = (progress * legs) - activeLegIdx; const legFrom = route.cities[activeLegIdx]; const legTo = route.cities[(activeLegIdx + 1) % route.cities.length]; const kmDone = Math.round(route.stats.km * progress); const etaH = Math.max(0, Math.round((route.stats.km - kmDone) / (route.stats.km / route.stats.hours))); const netNow = Math.round(route.stats.profit * Math.min(1, progress * 1.03)); const hotWP = React.useCallback((at, window=0.09) => { const d = progress - at; if (d < 0 || d > window) return 0; return Math.sin((d/window) * Math.PI); }, [progress]); const reachedFlash = (atEnd) => { const d = progress - atEnd; if (Math.abs(d) > 0.04) return 0; return 1 - Math.abs(d) / 0.04; }; return (