/* 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 (
);
}
// ─── 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%
{/* 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)}
);
}
// ─── Bottom bar ────────────────────────────────────────────────────────────
function BottomBar({ totalTrains, filtered }) {
return (
);
}
// Expose
window.App = App;