/* global React, ReactDOM */ const { useState, useMemo, useEffect, useRef } = React; // ─── Formatting helpers ──────────────────────────────────────────────────── const fmtPLN = n => { if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + ' mln zł'; if (n >= 10_000) return Math.round(n / 1000) + ' tys. zł'; if (n >= 1000) return (n / 1000).toFixed(1) + ' tys. zł'; return Math.round(n) + ' zł'; }; const fmtPLNShort = n => { if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M'; if (n >= 1000) return (n / 1000).toFixed(0) + 'k'; return '' + Math.round(n); }; const fmtInt = n => n.toLocaleString('pl-PL'); const pct = n => Math.round(n * 100) + '%'; const occColor = o => o < 0.40 ? '#52a873' : o < 0.70 ? '#e8b13d' : '#d9533f'; // ─── App ─────────────────────────────────────────────────────────────────── function App() { const tweaks = window.useTweaks ? window.useTweaks(window.TWEAK_DEFAULTS) : [window.TWEAK_DEFAULTS, () => {}]; const [t] = tweaks; const [filterCarrier, setFilterCarrier] = useState('ALL'); const [filterRoute, setFilterRoute] = useState('ALL'); const [sort, setSort] = useState('revenue'); const [hoverTrain, setHoverTrain] = useState(null); const [selectedTrain, setSelectedTrain] = useState(null); const trains = window.TRAINS; const filtered = useMemo(() => { // Only show trains currently in motion (departed, not yet arrived) let ts = trains.filter(x => x.status === 'enroute'); if (filterCarrier !== 'ALL') ts = ts.filter(x => x.carrier === filterCarrier); if (filterRoute !== 'ALL') ts = ts.filter(x => x.route === filterRoute); if (sort === 'revenue') ts = [...ts].sort((a, b) => b.revenue - a.revenue); else if (sort === 'occupancy') ts = [...ts].sort((a, b) => b.occupancy - a.occupancy); else if (sort === 'delay') ts = [...ts].sort((a, b) => b.delayMin - a.delayMin); else if (sort === 'dep') ts = [...ts].sort((a, b) => a.depTime.localeCompare(b.depTime)); return ts; }, [trains, filterCarrier, filterRoute, sort]); // Totals const totals = useMemo(() => { const byCarrier = {}; const byRoute = {}; let totalRev = 0, totalSeats = 0, totalCap = 0, totalTrains = 0; trains.forEach(tr => { totalRev += tr.revenue; totalSeats += tr.soldSeats; totalCap += tr.capacity; totalTrains += 1; if (!byCarrier[tr.carrier]) byCarrier[tr.carrier] = { revenue: 0, seats: 0, cap: 0, trains: 0 }; byCarrier[tr.carrier].revenue += tr.revenue; byCarrier[tr.carrier].seats += tr.soldSeats; byCarrier[tr.carrier].cap += tr.capacity; byCarrier[tr.carrier].trains += 1; if (!byRoute[tr.route]) byRoute[tr.route] = { revenue: 0, seats: 0, trains: 0, route: tr.route, from: tr.from, to: tr.to }; byRoute[tr.route].revenue += tr.revenue; byRoute[tr.route].seats += tr.soldSeats; byRoute[tr.route].trains += 1; }); return { byCarrier, byRoute, totalRev, totalSeats, totalCap, totalTrains }; }, [trains]); const topCarriers = Object.entries(totals.byCarrier) .map(([k, v]) => ({ carrier: k, ...v })) .sort((a, b) => b.revenue - a.revenue); const topRoutes = Object.values(totals.byRoute) .sort((a, b) => b.revenue - a.revenue); return (
{window.TweaksPanel_App ? : null}
); } // ─── Top bar ─────────────────────────────────────────────────────────────── function TopBar({ totals }) { return (
Gdzie jest złoty pociąg?
Polska · {window.TODAY_LABEL} · {window.NOW_LABEL} live
); } function Kpi({ label, value, hi }) { return (
{label}
{value}
); } // ─── Carrier leaderboard ─────────────────────────────────────────────────── function CarrierLeaderboard({ data, totalRev, filterCarrier, setFilterCarrier }) { const max = Math.max(...data.map(d => d.revenue)); return (
Przewoźnicy wg przychodu
setFilterCarrier('ALL')}>
Wszyscy
{fmtPLN(totalRev)}
{data.map(d => { const meta = window.CARRIERS[d.carrier]; const active = filterCarrier === d.carrier; return (
setFilterCarrier(active ? 'ALL' : d.carrier)}>
{meta.name}
{fmtPLN(d.revenue)}
{d.trains} poc. · {pct(d.seats / d.cap)}
); })}
); } // ─── Route leaderboard ───────────────────────────────────────────────────── function RouteLeaderboard({ data, totalRev, filterRoute, setFilterRoute }) { const max = Math.max(...data.map(d => d.revenue)); return (
Trasy top 10
{data.slice(0, 10).map(d => { const active = filterRoute === d.route; return (
setFilterRoute(active ? 'ALL' : d.route)}>
{d.from.replace(' C.', '').replace(' Gł.', '')} {d.to.replace(' C.', '').replace(' Gł.', '').replace(' hl.n.', '').replace(' Hbf', '')}
{fmtPLN(d.revenue)} {d.trains} poc.
); })}
); } // ─── Map panel ───────────────────────────────────────────────────────────── function MapPanel({ trains, hoverTrain, setHoverTrain, selectedTrain, setSelectedTrain, filterCarrier, filterRoute, showPaths, showCityLabels = true, showOutline = true }) { const W = 720, H = 560; const padX = 40, padY = 30; // Aspect-preserving scale: Poland is ~1.05 wide:tall after lat correction. // Fit to whichever is smaller and center the result. 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]; // Visible trains const visible = trains.filter(tr => { if (filterCarrier !== 'ALL' && tr.carrier !== filterCarrier) return false; if (filterRoute !== 'ALL' && tr.route !== filterRoute) return false; // Only enroute (departed, not yet arrived) — matches PLK live position feed return tr.status === 'enroute'; }); // Position of each train along its route polyline based on progressPct function trainPos(tr) { const path = window.ROUTE_PATHS[tr.route]; if (!path) return null; // total length in normalized units 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 = tr.progressPct * 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); } } const last = segs[segs.length - 1]; return toPx(last.x2, last.y2); } return (
Mapa · live {visible.length} pociągów w ruchu
<40% 40–70% >70%
{/* Poland outline (stylized) */} {showOutline && } {/* Route paths */} {showPaths && Object.entries(window.ROUTE_PATHS).map(([id, path]) => { const active = filterRoute === 'ALL' || filterRoute === id; const d = path.map((p, i) => (i === 0 ? 'M' : 'L') + toPx(p[0], p[1]).join(' ')).join(' '); return ; })} {/* City labels + dots */} {Object.entries(window.CITIES).map(([name, c]) => { const [x, y] = toPx(c.x, c.y); return ( {showCityLabels && ( {c.label} )} ); })} {/* Train markers */} {visible.map(tr => { const pos = trainPos(tr); if (!pos) return null; const [x, y] = pos; const meta = window.CARRIERS[tr.carrier]; const isHover = hoverTrain === tr.id; const isSel = selectedTrain === tr.id; const revNorm = Math.min(1, tr.revenue / 60000); const r = 5 + revNorm * 9; return ( setHoverTrain(tr.id)} onMouseLeave={() => setHoverTrain(null)} onClick={() => setSelectedTrain(isSel ? null : tr.id)}> {(isHover || isSel) && ( )} {tr.delayMin >= 10 && ( )} ); })} {/* Tooltip */} {hoverTrain && (() => { const tr = trains.find(t => t.id === hoverTrain); if (!tr) return null; return ; })()}
); } function PolandOutline({ toPx, W, H, padX, padY }) { const pts = window.POLAND_BORDER; const d = pts.map((p, i) => (i === 0 ? 'M' : 'L') + toPx(p[0], p[1]).join(' ')).join(' ') + ' Z'; return ( {/* Subtle grid */} {[0.2, 0.4, 0.6, 0.8].map(g => ( ))} {[0.25, 0.5, 0.75].map(g => ( ))} ); } function TrainTooltip({ train }) { const meta = window.CARRIERS[train.carrier]; return (
{train.id} {train.depTime} → {train.arrTime}
{train.from.replace(' Gł.','').replace(' C.','')} → {train.to.replace(' Gł.','').replace(' C.','').replace(' hl.n.','').replace(' Hbf','')}
Przychód
{fmtPLN(train.revenue)}
Obłożenie
{pct(train.occupancy)} · {train.soldSeats}/{train.capacity}
Śr. cena
{train.avgPrice} zł
Opóźn.
= 5 ? '#d9533f' : '#6b6b72' }}>{train.delayMin > 0 ? '+' + train.delayMin + ' min' : 'punktualnie'}
); } // ─── Train list ──────────────────────────────────────────────────────────── function TrainList({ trains, sort, setSort, hoverTrain, setHoverTrain, selectedTrain, setSelectedTrain, seatGrid, compact }) { const maxRev = Math.max(...trains.map(t => t.revenue), 1); return (
Pociągi {trains.length} w ruchu
{trains.slice(0, 80).map(tr => ( ))}
); } function TrainRow({ train, maxRev, hover, selected, onHover, onSelect, seatGrid }) { const meta = window.CARRIERS[train.carrier]; const barPct = (train.revenue / maxRev) * 100; // Build a seat grid: soldSeats filled, empty seats as hollow pixels. Cap display at 200 cells. const cells = Math.min(train.capacity, 200); const filledRatio = train.soldSeats / train.capacity; const filled = Math.round(cells * filledRatio); return (
onHover(train.id)} onMouseLeave={() => onHover(null)} onClick={() => onSelect(selected ? null : train.id)}>
{train.id}
{train.depTime} {train.delayMin > 0 && +{train.delayMin}}
{train.from.replace(' Gł.','').replace(' C.','')} {train.to.replace(' Gł.','').replace(' C.','').replace(' hl.n.','').replace(' Hbf','')}
{train.km} km · {Math.floor(train.travelMin/60)}h {train.travelMin%60}m · {train.avgPrice} zł/bilet
{seatGrid && (
{Array.from({ length: cells }).map((_, i) => ( ))}
{train.soldSeats}/{train.capacity}
)}
{Math.round(train.occupancy * 100)}
{fmtPLN(train.revenue)}
); } // ─── Bottom bar ──────────────────────────────────────────────────────────── function BottomBar({ totalTrains, filtered }) { return ( ); } // Expose window.App = App;