/* 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 (
);
}
// ─── 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 (
);
}
// ─── 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 (
);
}
// ─── 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 (
);
}
// ─── 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 (
);
}
// ─── 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 (
);
}
// Exports
Object.assign(window, { HeroMap, BookingCurve, PricingLadder, CompeteBars, ODMatrix, DemoFrame });