/* Landing widgets — visuals referenced from landing.jsx All charts are pure-SVG, no external chart libs. */ /* global React, window */ const { useMemo, useEffect, useState, useRef } = React; // ─── Mini live map for hero ──────────────────────────────────────────────── function HeroMap() { const W = 420, H = 420; const padX = 24, padY = 24; const innerW = W - padX * 2, innerH = H - padY * 2; const polandAspect = 1.05; const scaleW = Math.min(innerW, innerH * polandAspect); const scaleH = scaleW / polandAspect; const offX = (W - scaleW) / 2; const offY = (H - scaleH) / 2; const toPx = (x, y) => [offX + x * scaleW, offY + y * scaleH]; const [tick, setTick] = useState(0); useEffect(() => { const id = setInterval(() => setTick(t => t + 1), 80); return () => clearInterval(id); }, []); // Pick a handful of trains to animate const trains = (window.TRAINS || []).filter(t => t.status === 'enroute').slice(0, 20); function trainPos(tr, progress) { const path = (window.ROUTE_PATHS || {})[tr.route]; if (!path) return null; const segs = []; let total = 0; for (let i = 0; i < path.length - 1; i++) { const [x1, y1] = path[i], [x2, y2] = path[i + 1]; const d = Math.hypot(x2 - x1, y2 - y1); segs.push({ x1, y1, x2, y2, d, cum: total }); total += d; } const target = (progress % 1) * total; for (const s of segs) { if (target <= s.cum + s.d) { const k = (target - s.cum) / s.d; return toPx(s.x1 + (s.x2 - s.x1) * k, s.y1 + (s.y2 - s.y1) * k); } } return null; } const occColor = o => o < 0.40 ? '#86b06a' : o < 0.70 ? '#f59e0b' : '#b83826'; const border = window.POLAND_BORDER || []; const borderD = border.map((p, i) => (i === 0 ? 'M' : 'L') + toPx(p[0], p[1]).join(' ')).join(' ') + ' Z'; return ( {/* Grid */} {[0.25, 0.5, 0.75].map(g => ( ))} {[0.25, 0.5, 0.75].map(g => ( ))} {/* Poland outline */} {/* Route paths */} {Object.entries(window.ROUTE_PATHS || {}).map(([id, path]) => { const d = path.map((p, i) => (i === 0 ? 'M' : 'L') + toPx(p[0], p[1]).join(' ')).join(' '); return ; })} {/* City dots */} {Object.entries(window.CITIES || {}).map(([name, c]) => { if (c.offMap) return null; const [x, y] = toPx(c.x, c.y); return ( ); })} {/* Animated trains */} {trains.map((tr, i) => { const phase = (tr.progressPct + tick * 0.004 + i * 0.05) % 1; const pos = trainPos(tr, phase); if (!pos) return null; const [x, y] = pos; const meta = (window.CARRIERS || {})[tr.carrier] || { color: '#e0115f' }; return ( ); })} ); } // ─── Booking curve chart ─────────────────────────────────────────────────── function BookingCurve() { const W = 520, H = 320; const pad = { t: 28, r: 24, b: 32, l: 36 }; // Generate a forecast band and actuals. X = days to departure (90 → 0). Y = load % const days = Array.from({ length: 46 }, (_, i) => 90 - i * 2); const forecast = days.map(d => { // S-curve from ~8% at 90 days to ~78% at 0 days const t = 1 - d / 90; return 6 + 80 / (1 + Math.exp(-(t * 10 - 4.5))); }); const upper = forecast.map(v => Math.min(100, v + 6)); const lower = forecast.map(v => Math.max(0, v - 6)); // Actuals — stop at day 12, follow curve with noise, then a recent uptick const actual = []; for (let i = 0; i < days.length; i++) { const d = days[i]; if (d < 12) break; const noise = Math.sin(i * 1.7) * 2 + Math.cos(i * 0.6) * 1.5; const push = d < 30 ? 4 : 0; // beating forecast in the last 30 days actual.push(forecast[i] + noise + push); } const x = i => pad.l + (i / (days.length - 1)) * (W - pad.l - pad.r); const y = v => pad.t + (1 - v / 100) * (H - pad.t - pad.b); const forecastD = forecast.map((v, i) => (i === 0 ? 'M' : 'L') + x(i) + ' ' + y(v)).join(' '); const bandD = upper.map((v, i) => (i === 0 ? 'M' : 'L') + x(i) + ' ' + y(v)).join(' ') + ' ' + lower.map((v, i) => 'L' + x(days.length - 1 - i) + ' ' + y(lower[days.length - 1 - i])).join(' ') + ' Z'; const actualD = actual.map((v, i) => (i === 0 ? 'M' : 'L') + x(i) + ' ' + y(v)).join(' '); const actualLast = { i: actual.length - 1, v: actual[actual.length - 1] }; return ( {/* Y grid */} {[0, 25, 50, 75, 100].map(g => ( {g}% ))} {/* X axis */} {[90, 60, 30, 0].map(d => { const i = days.indexOf(d); return ( {d}d ); })} {/* Forecast band */} {/* Forecast line */} {/* Actual line */} {/* Actual end marker */} {/* Labels */} ACTUAL · WAW→KRK · 18 May FORECAST ±1σ ); } // ─── Pricing ladder ──────────────────────────────────────────────────────── function PricingLadder() { const classes = [ { k: 'A', cur: 39, rec: 49, cap: 60, label: 'Super Economy' }, { k: 'B', cur: 69, rec: 79, cap: 99, label: 'Economy' }, { k: 'C', cur: 109, rec: 119, cap: 149, label: 'Standard' }, { k: 'D', cur: 159, rec: 169, cap: 199, label: 'Flex' }, { k: 'E', cur: 229, rec: 249, cap: 299, label: 'Business' }, ]; const W = 520, H = 320; const pad = { t: 24, r: 28, b: 24, l: 110 }; const maxP = 320; const rowH = (H - pad.t - pad.b) / classes.length; const x = v => pad.l + (v / maxP) * (W - pad.l - pad.r); return ( {classes.map((c, i) => { const yTop = pad.t + i * rowH; const yMid = yTop + rowH / 2; return ( {c.k} · {c.label} {/* floor→ceiling corridor */} {/* current */} {/* recommended */} {/* ceiling */} {/* labels */} {c.cur} zł → {c.rec} zł ); })} CURRENT · RECOMMENDED · CEILING +18,420 zł / train · if applied ); } // ─── Competitive intelligence bars ───────────────────────────────────────── function CompeteBars() { const rows = [ { op: 'You · IC', price: 129, cap: 820, color: '#e0115f', own: true }, { op: 'RegioJet', price: 99, cap: 450, color: '#FABB00' }, { op: 'Leo Express', price: 109, cap: 380, color: '#F08200' }, { op: 'FlixBus (bus)', price: 79, cap: 260, color: '#6aa1d9' }, { op: 'PKS (bus)', price: 89, cap: 180, color: '#8f8f97' }, { op: 'Ryanair (KRK–WAW)',price: 59, cap: 189, color: '#b4b4b4' }, ]; const maxP = 160; const W = 520, H = 320; const pad = { t: 28, r: 72, b: 20, l: 140 }; const rowH = (H - pad.t - pad.b) / rows.length; return ( OPERATOR PRICE (avg, 7-day, WAW↔KRK) CAPACITY {rows.map((r, i) => { const y = pad.t + i * rowH + 6; const barW = (r.price / maxP) * (W - pad.l - pad.r); return ( {r.op} {r.price} zł {r.cap} seats ); })} ); } // ─── OD Matrix heatmap ───────────────────────────────────────────────────── function ODMatrix() { const stops = ['WAW', 'KRK', 'GDN', 'POZ', 'WRO', 'KAT', 'SZC']; // Symmetric-ish demand matrix (0–1) const seed = 'odmatrix'.split('').reduce((a, c) => a + c.charCodeAt(0), 0); let s = seed; const rand = () => { s = (s * 9301 + 49297) % 233280; return s / 233280; }; const m = stops.map((_, i) => stops.map((__, j) => { if (i === j) return null; // Warsaw corridor is denser const boost = (stops[i] === 'WAW' || stops[j] === 'WAW') ? 0.3 : 0; return Math.min(1, 0.15 + rand() * 0.6 + boost); }) ); const W = 520, H = 320; const cell = Math.min((W - 80) / (stops.length + 1), (H - 40) / (stops.length + 1)); const offX = (W - cell * (stops.length + 1)) / 2; const offY = 20; const heat = v => { if (v === null) return '#14161a'; const a = 0.08 + v * 0.9; return `rgba(224,17,95,${a})`; }; return ( {/* Column labels */} {stops.map((s, j) => ( {s} ))} {/* Row labels + cells */} {stops.map((r, i) => ( {r} {m[i].map((v, j) => { const x = offX + (j + 1) * cell; const y = offY + (i + 1) * cell; return ( {v !== null && v > 0.55 && ( {Math.round(v * 100)} )} ); })} ))} O&D DEMAND INDEX · 7-DAY AVG · ROW=ORIGIN · COL=DEST ); } // ─── Demo frame (wider fake dashboard preview) ───────────────────────────── function DemoFrame() { // Simulated multi-panel dashboard: revenue over time + fare class stack + alerts const W = 760, H = 460; // 24 hours of revenue const hours = Array.from({ length: 24 }, (_, i) => i); const rev = hours.map(h => { // Double-bump morning + evening const m = Math.exp(-Math.pow((h - 8) / 2.4, 2)) * 1.0; const e = Math.exp(-Math.pow((h - 17) / 3.0, 2)) * 1.2; return 25 + (m + e) * 110 + Math.sin(h) * 6; }); const pad = { t: 40, r: 24, b: 36, l: 40 }; const chartW = W - pad.l - pad.r; const chartH = 180; const maxR = Math.max(...rev); const x = h => pad.l + (h / 23) * chartW; const y = v => pad.t + chartH - (v / maxR) * chartH; const areaD = 'M' + x(0) + ' ' + y(rev[0]) + ' ' + rev.slice(1).map((v, i) => 'L' + x(i + 1) + ' ' + y(v)).join(' ') + ' L' + x(23) + ' ' + (pad.t + chartH) + ' L' + x(0) + ' ' + (pad.t + chartH) + ' Z'; const lineD = 'M' + x(0) + ' ' + y(rev[0]) + ' ' + rev.slice(1).map((v, i) => 'L' + x(i + 1) + ' ' + y(v)).join(' '); // Fare class stack const classes = [ { k: 'A', v: 14, color: '#86b06a' }, { k: 'B', v: 32, color: '#e0115f' }, { k: 'C', v: 28, color: '#e0115f' }, { k: 'D', v: 16, color: '#b83826' }, { k: 'E', v: 10, color: '#b4b4b4' }, ]; const stackTotal = classes.reduce((a, c) => a + c.v, 0); const stackY = pad.t + chartH + 68; const stackW = chartW; let acc = 0; // Alerts const alerts = [ { t: '08:42', m: 'RegioJet undercut detected · KRK→PRG · −18 zł', k: 'warn' }, { t: '08:31', m: 'Forecast beating plan · WAW→GDN · +4.2%', k: 'ok' }, { t: '08:17', m: 'Auto-repriced class C · WAW→POZ · +10 zł', k: 'ok' }, ]; return ( {/* Top row labels */} REVENUE · LAST 24H · ALL CORRIDORS 2.41 mln zł · +4.8% {/* Grid */} {[0.25, 0.5, 0.75, 1].map(g => ( ))} {/* Revenue area */} {/* Hour labels */} {[0, 6, 12, 18, 23].map(h => ( {String(h).padStart(2, '0')}:00 ))} {/* Divider */} {/* Stack */} FARE CLASS MIX {classes.map(c => { const w = (c.v / stackTotal) * stackW; const rx = pad.l + acc; acc += w; return ( {c.k} · {c.v}% ); })} {/* Alerts */} ALERTS · LIVE {alerts.map((a, i) => { const ay = stackY + 66 + i * 22; const color = a.k === 'warn' ? 'var(--bad)' : 'var(--ok)'; return ( {a.t} {a.m} ); })} ); } // Exports Object.assign(window, { HeroMap, BookingCurve, PricingLadder, CompeteBars, ODMatrix, DemoFrame });